IoT fleet, UNS, sensor catalog, fleet management, predictive maintenance, and edge¶
Status: design spec, implementation contract.
Owner: this section defines every package, event, config key, and test that produces telemetry and manages the devices that produce it. Everything downstream (the LSS engine, twin sync, the historian, the agent) consumes the contracts defined here.
Binding cross-references: docs/design/DOCTRINE.md holds the cross-cutting rulings D-01 to
D-14, and every place this section applies one names the ruling id.
docs/design/variability-and-faults.md is the single source of truth for the RNG mechanism,
the distribution catalog, and the fault catalog; this section cites it and never restates it.
1. Scope¶
1.1 Requirements owned in full¶
| Req | Title | Where covered |
|---|---|---|
| 2 | IoT fleet: 50+ simulated devices over MQTT into an ISA-95 UNS, device-type abstraction, field-realistic failure modes, one device agent in Rust | 2.4, 2.7, 2.11, 3.3, 4.1, 4.3, 5.1 to 5.12, 6.4, 7.1, 8.1 |
| 2b | Sensor catalog as data: 80 implemented types across 8 categories, each declaring physics-informed signal model, failure modes, UNS mapping, attached twin subsystem | 2.3, 3.1, 3.2, 3.9, 5.2, 5.3, 6.2, 7.1, 7.2, 8.1 |
| 3 | Fleet management and predictive maintenance: device registry, FMEA S-O-D health scoring, mean-time-to-detect, degraded-device flagging, PdM with time-to-threshold | 2.8, 2.10, 3.4, 3.6, 5.13, 5.14, 6.7, 6.8, 7.2, 8.1 |
| 6c | Edge resilience: store-and-forward, QoS levels, retained messages, broker-kill resilience, mTLS with client certificates | 2.6, 5.4, 5.6, 5.15, 6.3, 6.5, 7.3, 7.5, 8.1 |
| E3 | Sparkplug B v3.0.0 payloads: birth certificates, death certificates, metric aliasing, state management | 2.4, 4.1, 5.4, 5.5, 6.3, 7.4, 8.2 |
| E36 | Edge computing as explicit architecture: tiers 0 to 3, latency budgets, measured bandwidth and latency economics, graceful degradation | 2.9, 5.16, 5.17, 6.6, 7.6, 8.2 |
| E44 | IoT device lifecycle: zero-touch provisioning, desired-versus-reported twin drift reconciliation, OTA campaigns with canary rings and rollback | 3.5, 5.18, 5.19, 5.20, 6.7, 6.10, 7.3, 8.1 |
| E46 | RF physics and read-zone modeling: read probability against orientation, speed and interference; missed reads; cross-reads; antenna placement as a what-if | 2.5, 3.7, 5.21 to 5.25, 5.30, 6.9, 7.4, 8.2 |
| E47 | Hardware-in-the-loop: one real ESP32 joining the simulated fleet as a first-class device | 2.11, 5.26, 6.11, 7.6, 8.3 |
| E48 | Field-grade failure artifacts: auto-generated troubleshooting runbooks and scheduled fleet config-compliance audits | 3.8, 5.27, 5.28, 6.7, 7.5, 7.7, 8.1 |
scripts/checks/spec-xref.py regenerates the "Where covered" column from the headings that
exist in this file and fails CI when a cited subsection is absent. IC-IOT-07 is that check.
The column above was generated by it, so the offsets it once carried cannot return.
1.2 Requirements this section partly serves (interface owned here, consumer owned elsewhere)¶
| Req | What this section provides |
|---|---|
| 1b | Telemetry channels for the AMR fleet, the palletizer cell, the ASRS crane, and sortation diverts. Catalog entries and UNS paths only; the resources themselves belong to the twin section. |
| 4 | The camera_area_scan_synthetic catalog entry, its UNS path, its device-agent behavior, and its failure modes. Frame generation and CV logic belong to the CV section. |
| 5 | Every input the LSS engine judges: telemetry readings with quality codes, fleet health alerts, read-rate proportions for p-charts, MTTD series for I-MR charts. |
| 5 (MSA) | The measurement-system inputs Gage R and R needs: per-channel repeatability and reproducibility variance components declared in the catalog (3.1), provisioned per device and per appraiser, and the repeated-measures scenario S-IOT-14 that exercises them. The study itself belongs to the LSS engine. |
| 5 (statistical seam) | ota.ring.gate.requested.v1 and rf.whatif.evaluated.v1, and the lss.test.completed.v1 reply this section consumes. The seam is defined in 5.30. |
| 6b | maintenance.workorder.requested.v1 published by the PdM layer with a deferral-cost slot the twin fills. |
| 6a2, 6a9, 6a10, 6a15 | Catalog categories that land with those subsystems: process and chemical, safety and compliance, structural, and the ITSM asset records for brokers and gateways. |
| C1 | The device agent, the sensor models, the RF model, and the fault injector are written against the kernel Clock, Rng, Network, EventBus, and Storage ports. Cross-language RNG parity is VG-IOT-02. |
| C2 | All device timestamps are sim-time. The clock-drift model is defined against sim-time, never against wall-clock (D-02). |
| C3 | 50 event schemas registered under /schemas/iot/ and /schemas/fleet/, inventoried in 4.10 and asserted by INV-SCH-1. |
| C4 | 53 named Hypothesis invariants (7.2), 17 seeded end-to-end scenarios (7.3), golden files for runbooks, the edge placement report, and the compliance report. |
| C5 | Catalog and device config validation with line-numbered, suggestion-bearing errors. |
| C10, C11 | Rust crate in the CI matrix; cargo-audit; EPL-2.0 vendored .proto handled under the license allowlist. |
| A1 | Nine independently installable Python bricks and one Rust crate; an import-linter layered contract. |
| A3 | Broker tiers Mosquitto, EMQX, and NanoMQ selected by config, never by rewrite. |
| A4 | Device-count scaling harness feeding the published scaling curves. |
| A6 | Entry-point plugin groups so a third party adds a sensor type or failure mode from their own package. |
| E1 | The recorded event log the replay viewer plays back is the telemetry stream defined here. |
| E7 | The power_meter_3ph and motor_current_signature energy channels; energy per pallet is computed elsewhere. |
| E13 | Broker-to-broker bridging seam: the UNS path already carries site, so a bridge is a topic filter, never a schema change. |
| E18 | Sparkplug spoofing attack surface: the bdSeq and client-id collision detection defined in 5.9 is the mechanism the OT drill exercises. |
| E25 | The iot.telemetry.groundtruth.v1 companion record, plus iot.fault.injected.v1, is the labeled fault dataset. |
| E32 | The tier 1 gateway is the deployment host for the edge SLM. |
| E43 | PdM model registry entries, drift monitors on features and residuals, and the model.train.<model_id>.seed stream that makes a retrain reproducible (D-04). |
1.3 Explicitly not covered here¶
Findings creation, alarm rationalization and shelving, control-chart evaluation, the historian schema, the twin's resources, and the agent tool surface. This section names the events it hands to those sections and the constraints those sections must respect, notably the ground-truth firewall in 5.12 and the resource-selector grammar in 3.9.
2. Packages¶
Nine Python bricks and one Rust crate. Every one has its own README, its own tests, and its own
pip install (or cargo add) path. A reader who wants only a physics-informed synthetic sensor
generator, or only a Sparkplug B codec, takes one and leaves the rest.
The dependency DAG is enforced in CI by import-linter with a layered contract in
.importlinter. A package may import the public API of a package strictly below it. No package
imports another package's private modules (anything under a _ prefix). Communication between
subsystems that are not in an ancestor relationship happens only through versioned events in
/schemas.
That reading of the locked architecture constraint is granted by D-09, which rules that every public symbol has exactly one owning package and that other packages import it rather than redeclare it. ARCHITECTURE.md carries the amendment in one sentence: the versioned schema registry is the only interface between subsystems, and a downward public-API import inside one subsystem's own DAG is a layering fact the import-linter contract states and tests.
twinflow-kernel (owned by the core section: Clock, Rng, Network, EventBus, Storage)
|
+-- twinflow-contracts (pydantic value types shared by every brick above)
|
+-- twinflow-sparkplug (contracts + protobuf)
+-- twinflow-sensors (contracts)
+-- twinflow-rf (contracts)
+-- twinflow-buffer (contracts)
|
+-- twinflow-pdm (sensors, buffer)
|
+-- twinflow-device (sensors, sparkplug, rf, buffer)
+-- twinflow-edge (sparkplug, buffer, pdm)
|
+-- twinflow-fleet (contracts, sparkplug, edge, buffer)
Three edges in that graph are deliberate corrections and each one is tested.
twinflow-buffer exists because a device's RAM ring buffer and a gateway's disk-backed spill
are the same object with a different capacity, and because the tier-0 deadband runs on the
device while the tier-1 aggregation runs on the gateway. Putting StoreAndForward,
DeadbandPolicy, and AggregationPolicy in a brick below both is what lets 5.6 say "same
interface, different cap_bytes and a spill_path" without twinflow-edge importing a
sibling.
twinflow-edge sits above twinflow-pdm because LocalInferenceHost runs EwmaResidual at
tier 1, and EwmaResidual is owned by twinflow-pdm (D-09: one owner, imported not
redeclared).
twinflow-fleet does not depend on twinflow-device. The registry, the health scorer, and the
campaign engine consume iot.device.* and fleet.* events plus the pydantic models in
twinflow-contracts. Pulling the connection state machine, an MQTT client library, and
cryptography into the server brick would break A1's promise that a reader takes one brick and
leaves the rest. test_fleet_imports_no_device_module asserts it, and the import-linter
contract lists twinflow-device as forbidden from twinflow-fleet.
twinflow-sensors does not import twinflow-rf. The rf_read_zone signal-model kind is
registered by twinflow-rf through the twinflow.signal_models entry-point group, so the
catalog names a kind that twinflow-sensors resolves from the plugin registry at load time and
never imports. test_sensors_has_no_rf_import asserts it, and the import-linter contract lists
twinflow-rf as forbidden from twinflow-sensors.
twinflow-device-agent (Rust) sits beside twinflow-device and shares only wire formats and
test vectors, never source.
2.1 twinflow-kernel (dependency, not owned here)¶
This section consumes five ports and needs them to exist before Phase 1 work starts. Two of them carry requirements this section is raising on the core section now, because the tests in 7.1 are unimplementable without them.
class Clock(Protocol):
def now(self) -> SimInstant: ... # sim-time, monotone
def sleep(self, seconds: float) -> Awaitable[None]: ...
def at(self, t: SimInstant) -> Awaitable[None]: ...
def wall_mapping(self) -> WallMapping: ... # recorded in the provenance sidecar (C2, D-01)
class Rng(Protocol):
def stream(self, name: str, **template_args: str) -> Generator: ...
# name-addressed, per docs/design/variability-and-faults.md A.1 and A.4
class Network(Protocol): # MQTT-shaped port (D-08)
async def resolve(self, host: str) -> list[Address]: ...
async def tcp_connect(self, addr: Address, port: int) -> Socket: ...
async def tls_handshake(self, sock: Socket, profile: TlsProfile) -> TlsLink: ...
async def mqtt_connect(self, link: TlsLink | Socket, opts: MqttOptions) -> MqttSession: ...
# MqttOptions carries retain, qos, last will, wildcard subscribe
# simulation mode implements partition, latency, reorder, duplicate, idle-kill
class EventBus(Protocol): # subject-addressed fan-out (D-08)
async def publish(self, subject: str, payload: bytes) -> None: ...
async def subscribe(self, subject: str) -> AsyncIterator[bytes]: ...
class Storage(Protocol):
def open_append(self, name: str) -> Appender: ...
def open_kv(self, name: str) -> KeyValue: ... # device NVS analog
D-08 splits the ports and this section binds to both halves. The device fleet and Sparkplug
always use Network, because Sparkplug B v3.0.0 death certificates ride on the MQTT last will
and testament and a subject-addressed bus has no last will. Analytics fan-out from the fleet
layer uses EventBus.
The four-rung diagnostic ladder in 5.11 is why Network exposes resolve, tcp_connect,
tls_handshake, and mqtt_connect as separate calls rather than one connect. A single
connect cannot produce a dns_nxdomain result distinct from a tcp_timeout result, and the
thirteen-value classification enum plus its per-classification unit tests depend on exactly
that distinction. This is a requirement on the core section, raised here because the core
section freezes its ports at Phase 0.
The RNG mechanism is not defined here. docs/design/variability-and-faults.md A.1 fixes it as
a content-addressed BLAKE2b spawn key feeding a numpy SeedSequence and a PCG64DXSM bit
generator, and A.2 fixes the stream-name grammar. This section declares its stream names in
5.29 against that grammar and nothing else. D-06 binds the Rust agent to the same derivation
byte for byte, and VG-IOT-02 is the cross-language conformance gate.
2.2 twinflow-contracts¶
Purpose: the pydantic value types that more than one brick needs, held in a leaf package so no brick has to import a heavier sibling to name a type (D-09, D-10).
Public API:
from twinflow.contracts import (
DeviceRecord, EdgeNodeRecord, DeviceSpec, EdgeNodeSpec,
LifecycleState, Quality, # GOOD | UNCERTAIN | BAD | SUBSTITUTED (OPC UA style)
HealthScore, FmeaRating, ActionPriority,
ResourceSelector, # the attaches_to grammar, 3.9
AuthorityTier, # L1 advise | L2 recommend | L3 auto-apply (E5 stub)
CapabilityId, CapabilityRegistry,
RunbookRef, SignatureId,
)
Depends on: pydantic>=2. Nothing else. A CI job installs it alone in a clean environment and
imports it (D-10).
AuthorityTier lands here as a three-value enum with E5a's autonomy metadata at P1, and the
append-only decision register it writes to arrives with E21a at the ECON phase. Both precede
E44's reconciliation work at P6-W1, so 5.19's accept action has its authority tier and its
register when it is written. No resequencing is needed, and section 8 records the trace.
2.3 twinflow-sensors¶
Purpose: the sensor catalog as data, the physics-informed signal models that read it, and the fault transforms that corrupt the clean signal.
Public API:
from twinflow.sensors import (
Catalog, # Catalog.load(path) -> Catalog ; .entry(type_id) ; .entries_by_category()
CatalogEntry, # frozen pydantic model, the schema in 3.1
Channel,
SignalModel, # Protocol; sample(ctx) -> ChannelSample
SampleContext,
ChannelSample, # clean_value, observed_value, unit, quality, fault_tags
register_signal_model, # decorator; also an entry-point group
FaultTransform, # Protocol; apply(sample, params, ctx) -> ChannelSample
register_fault_mode,
FaultInjector, # schedules fault activation from the materialised schedule
MsaProfile, # repeatability and reproducibility variance components
)
Entry-point groups so a third party extends the catalog without forking:
[project.entry-points."twinflow.signal_models"]
my_kind = "mypkg.models:MyModel"
[project.entry-points."twinflow.failure_modes"]
my_mode = "mypkg.faults:MyFault"
Depends on: twinflow-kernel, twinflow-contracts, numpy, ruamel.yaml (needed for line
and column marks, see section 6).
Standalone value: a physics-informed synthetic sensor generator with a declarative catalog and a fault library. Usable with no MQTT, no twin, and no broker.
2.4 twinflow-sparkplug¶
Purpose: Eclipse Sparkplug B v3.0.0 encode and decode, session and sequence management, alias tables, and the ISA-95 UNS path algebra that produces both the Sparkplug identifiers and the human-readable mirror topic from one value object.
Public API:
from twinflow.sparkplug import (
UnsPath, # enterprise, site, area, line, equipment, parameter
SparkplugIds, # group_id, edge_node_id, device_id (round-trips with UnsPath)
topic_for, # (ids, MessageType) -> str
mirror_topic_for, # (UnsPath, root) -> str
MessageType, # NBIRTH NDATA NDEATH DBIRTH DDATA DDEATH NCMD DCMD STATE
Payload, Metric, DataType,
encode, decode, # protobuf codec
EdgeNodeSession, # one MQTT client session: bdSeq, seq, alias table, birth and death
HostAppSession, # STATE publication and primary-host tracking
ConformanceMatrix, # loads schemas/iot/sparkplug/conformance.yaml
)
Depends on: twinflow-kernel, twinflow-contracts, protobuf. The sparkplug_b.proto file is
vendored under its own EPL-2.0 license into schemas/iot/sparkplug/, recorded in
THIRD_PARTY_NOTICES.md, and excluded from the Apache-2.0 relicensing sweep. See open question
9.2.
The version claim is verified. The Eclipse Sparkplug specification revision history lists revision 3.0 dated 2022-10-21 as the current revision, published by the Sparkplug Specification Project Team, and the specification document is titled 3.0.0. Retrieved 2026-08-09 from https://sparkplug.eclipse.org/specification/, HTTP 200, and the document PDF from https://sparkplug.eclipse.org/specification/version/3.0/documents/sparkplug-specification-3.0.0.pdf, HTTP 200.
Standalone value: highest of the bricks. A Python Sparkplug B v3.0.0 codec with committed TCK evidence is directly useful to anyone in the Ignition, HiveMQ, or EMQX ecosystem.
2.5 twinflow-rf¶
Purpose: RF physics and read-zone geometry for UHF RFID. Link budget, antenna patterns, polarization, tag orientation, dwell time, collision, interference, cross-reads, and missed-read cause attribution.
Public API:
from twinflow.rf import (
Antenna, AntennaPattern, ReadZone, Portal,
TagModel, # sensitivity, backscatter loss, pattern, polarization
PathLoss, # exponent n, free-space closed form, fading model
link_budget, # -> forward_margin_db, reverse_margin_db
read_probability, # (tag_pose, speed, tag_count, interference) -> p, cause
MissCause, # OUT_OF_RANGE ORIENTATION_NULL OCCLUSION COLLISION SPEED INTERFERENCE
simulate_pass, # a pallet passing a portal -> list[ReadEvent]
cross_read_candidates,
RegulatoryLimits, # per-region, per-band ceilings; see VG-IOT-04
PhysicalConstant, # value plus a mandatory `source` string; see INV-RF-6
PlacementProblem, # antenna placement, aim, and power as a search space
diagnose, # the physics-versus-device decision rule of 5.25
)
It registers the rf_read_zone signal-model kind through the twinflow.signal_models
entry-point group. That is the whole coupling to twinflow-sensors, and it runs in the
direction sensors to registry to rf, never sensors to rf.
Depends on: twinflow-kernel, twinflow-contracts, numpy. Optuna is an optional extra
(twinflow-rf[search]) because only PlacementProblem needs it (D-10).
Standalone value: high and rare. Almost nobody publishes a read-zone model.
2.6 twinflow-buffer¶
Purpose: the rate-reduction and durability primitives that a device and a gateway both need. One implementation, two capacities.
Public API:
from twinflow.buffer import (
StoreAndForward, # ring buffer with cap_bytes, drop policy, dropped counter
SpillingStoreAndForward, # the same, with a disk-backed spill beyond RAM
DropPolicy, # OLDEST | NEWEST
DeadbandPolicy, # absolute | percent | none, with max_publish_interval_s
AggregationPolicy, # window stats for high-rate channels
BufferCounters, # generated, published, dropped, buffered, deadbanded
)
Depends on: twinflow-kernel, twinflow-contracts.
BufferCounters carries the five terms INV-SF-1 balances, including deadbanded. A deadband
that suppresses a publish is a fifth disposition of a generated record, not an invisible one,
and making it a counted term is what lets 5.17 report the suppression rate as a measurement
rather than a config restatement.
2.7 twinflow-device¶
Purpose: the Python device agent. State machine, time gate, backoff, store-and-forward wiring, diagnostic ladder, boot forensics, provisioning client, and OTA client.
Public API:
from twinflow.device import (
DeviceAgent, # run(); the state machine in 5.1
EdgeNodeAgent, # one process, one MQTT client session, N Sparkplug devices
DeviceState, # enum in 5.1
DeviceConfig, # validated from the facility.yaml devices: block
TimeGate, # blocked_until_epoch_floor
FullJitterBackoff, # next_delay(attempt_index) -> seconds
DiagnosticLadder, # classify(broker) -> LadderResult
BootRecord, # reset_reason, crash_loop_count, brownout_count, ...
ResetReason, # enum mirroring esp_reset_reason_t; see VG-IOT-13
ProvisioningClient, # ZTP: CSR to operational cert
OtaClient, # chunked, resumable, A/B slot, mark-valid
)
Depends on: twinflow-sensors, twinflow-sparkplug, twinflow-rf, twinflow-buffer,
twinflow-kernel, twinflow-contracts, cryptography.
paho-mqtt (MQTT 3.1.1 and 5.0) is not a core dependency. It ships as the extra
twinflow-device[mqtt] and is imported only by twinflow.device._adapters.paho, which is one
concrete adapter behind the kernel Network port (D-08, D-10). This mirrors the Rust crate's
sim feature in 2.11: production transport is a build-time or install-time choice, never a
compile-in default.
paho-mqtt 2.1.0 declares the license expression EPL-2.0 OR BSD-3-Clause, read from the Python
Package Index JSON API on 2026-08-09, HTTP 200. twinflow takes the BSD-3-Clause arm, which the
C11 allowlist accepts without an exception, and THIRD_PARTY_NOTICES.md records the arm taken.
The vendored sparkplug_b.proto is a separate matter and stays EPL-2.0 (2.4, open question 9.2).
Two guards keep the seam honest. test_device_core_installs_without_paho installs
twinflow-device alone in a clean environment and runs the full simulation-mode suite. A
runtime guard in DeviceAgent.run raises when simulation.mode is simulation and any
adapter attempts a real socket operation, and test_simulation_mode_opens_no_socket asserts
the guard fires by monkeypatching socket.socket.
2.8 twinflow-pdm¶
Purpose: predictive maintenance. Feature extraction, trend detection, time-to-threshold with intervals, and the statistical-baseline-versus-learned-model bakeoff.
Public API:
from twinflow.pdm import (
FeatureWindow, extract_features,
DutyCycleRegression, # OLS on the twin-state design matrix; the residual is the health signal
OrdinaryLeastSquares, # slope, intercept, residual standard deviation; gated by VG-IOT-09
MannKendall, # S, tau, p (nonparametric monotone trend test)
TheilSen, # slope, intercept, slope CI
EwmaResidual, # EWMA on the duty-cycle-corrected residual
TimeToThreshold, # estimate(level, slope_ci, limit) -> Interval
BandEnergy, # 1x/2x/3x, BPFO/BPFI/BSF/FTF band energies
IsolationForestDetector,
Bakeoff, # runs all detectors on labeled incidents, publishes the table
DetectorReport, # pr_auc, mttd_p50, mttd_p90, fp_per_device_week, horizon
)
OrdinaryLeastSquares is the estimator VG-IOT-09 gates. It is not decoration: the duty-cycle
correction in 5.14 step 1 subtracts a twin-state-conditional expected value, and that
conditional expectation is an ordinary least squares fit against a design matrix of twin-state
indicators. It also serves as the parametric comparator against which TheilSen is judged on
the same series, which is how the bakeoff shows robustness earning its place rather than
assuming it.
Depends on: twinflow-sensors, twinflow-buffer, twinflow-kernel, twinflow-contracts,
numpy, scipy. scikit-learn is the extra twinflow-pdm[learned], because only
IsolationForestDetector and the RUL regressor need it (D-10).
Standalone value: high. A trend-to-threshold PdM kit validated against published statistical references is a self-contained thing.
2.9 twinflow-edge¶
Purpose: the tier 1 gateway process and the compute-placement model. Protocol translation, tier-0 deadband policy enforcement, tier-1 aggregation, local inference host, store-and-forward at the gateway, bridging to the site broker, and the measured economics.
Public API:
from twinflow.edge import (
ComputeTier, # TIER0 TIER1 TIER2 TIER3
FunctionPlacement, # function_id, tier, latency_budget_ms, rationale
PlacementTable, # loaded from the facility.yaml edge.placement block
Gateway, # run(); aggregation + local inference + bridging
LocalInferenceHost, # hosts twinflow.pdm.EwmaResidual and the safety interlocks
LinkAccountant, # bytes and messages per tier boundary, plus wire-byte capture
PlacementReport, # renders artifacts/edge/placement_report.md
DegradationReport, # renders the retained and lost capability lists
)
Depends on: twinflow-sparkplug, twinflow-buffer, twinflow-pdm, twinflow-kernel,
twinflow-contracts.
2.10 twinflow-fleet¶
Purpose: the server side. Device registry, FMEA health scoring, MTTD accounting, degraded flagging, the desired-versus-reported twin, the config-compliance audit, runbook generation, OTA campaign orchestration, and the certificate authority client.
Public API:
from twinflow.fleet import (
DeviceRegistry, # event-sourced projection over iot.device.* and fleet.* events
HealthScorer, # score(device_id) -> HealthScore (5.13)
FmeaMode,
OccurrenceTable, DetectionTable, # data-driven S-O-D rating tables
OccurrencePrior, DetectionPrior, # cold-start priors, 5.13
MttdAccountant, # pairs iot.fault.injected.v1 with fleet.fault.detected.v1
DeviceTwin, DriftItem, Reconciler,
CompliancePolicy, ComplianceAuditor,
RunbookGenerator, Runbook,
OtaCampaign, Ring, CampaignEngine,
ProvisioningAuthority, # allowlist, nonce, CSR signing
)
Depends on: twinflow-contracts, twinflow-sparkplug, twinflow-edge, twinflow-buffer,
twinflow-kernel, cryptography.
2.11 twinflow-device-agent (Rust crate)¶
Purpose: satisfies component 2's "one device agent in Rust", and is the same code that runs on the ESP32 for E47.
Modules: state, timegate, backoff, sf, diag, boot, sparkplug, provision, ota,
transport, rng.
Two build targets from one source:
--features hostlinkstokio,rumqttc, andrustls. It joins the simulated fleet as a normal edge node.--features esp32builds againstesp-idf-svc(std-capable ESP-IDF bindings), giving Wi-Fi, SNTP, mbedTLS, NVS, and the native OTA partition API.
esp-hal (no_std) is recorded as the considered alternative and rejected: TLS, SNTP, and OTA
rollback would all have to be hand-rolled, which buys nothing for this repo's purpose.
Determinism: the crate is generic over trait Clock, trait Rng, and trait Transport. The
sim feature swaps in deterministic implementations driven by the same name-addressed
derivation the Python side uses, specified in docs/design/variability-and-faults.md A.1 and
bound by D-06. VG-IOT-02 gates the derivation against external known-answer vectors in both
languages, and IC-IOT-03 gates the state-machine transition traces and encoded payloads against
the shared parity corpus.
D-05 scopes what parity means, and this section claims neither more nor less. The cross-language
claim is exact on the integer half and tolerance-bounded on the float half. Bit generator output,
slot-counter draws, bdSeq and seq values, state-machine transition traces, and encoded
Sparkplug frames are byte-identical between the Python and Rust agents, because every one of them
is an integer or a byte string and no floating-point rounding stands between the seed and the
result. Continuous channel values are value-equivalent: the two implementations agree within a
tolerance derived from the divergence VG-IOT-02 measures across the supported platform set, and
the gate reports the observed maximum divergence rather than asserting a number chosen in
advance. When the observed maximum exceeds the recorded tolerance, the gate names which of the
two explanations applies, a wrong tolerance or a real defect, and fails either way. Byte-identical
whole-run equality holds on one pinned platform with one pinned dependency set, which is the
first tier of D-05, and nothing in this section claims byte-identity across platforms.
Published to crates.io by the same automated release job as the Python bricks (C9).
3. Domain model¶
3.1 CatalogEntry (component 2b)¶
One YAML document per sensor type. Catalog files live at catalog/sensors/{category}.yaml as a
list of entries, so the eight category files are the eight README tables.
- type_id: rfid_portal_reader_uhf # ^[a-z][a-z0-9_]{2,47}$, globally unique
display_name: UHF RFID portal reader
category: warehouse_logistics # exactly one of the 8 enumerated categories
status: implemented # implemented | planned (CI: no 'planned' at release)
phase: P1 # earliest phase whose subsystem consumes it
attaches_to: "receiving.dock.*.portal" # ResourceSelector, grammar frozen in 3.9
edge_tier_default: 1 # 0..3
capability_unlocks: # NON-EMPTY, and each id resolves in catalog/capabilities.yaml
- rfid_read_counting
- lot_genealogy
- rf_read_zone_diagnosis
physics_note: >
Backscatter link budget with framed slotted-ALOHA singulation; read outcome is produced by
twinflow-rf, not by this entry's own signal model.
limit_source: "repo default; no external standard applies"
channels:
- channel_id: read_event
measurand: rfid_read
unit: "1" # UCUM code, validated (VG-IOT-05)
datatype: Template # Sparkplug DataType
uns_parameter: read
publish_policy: { kind: event }
signal_model:
kind: rf_read_zone # registered kind; params validated by that model
params: { portal_ref: self }
baseline: { kind: delegated, to: twinflow_rf }
noise: { kind: delegated, to: twinflow_rf }
drift: { kind: none, why: "read outcome carries no analog drift term" }
msa:
repeatability_sigma: 0.0 # a read is a Bernoulli outcome, not a measurement
reproducibility_sigma: 0.0
msa_note: "excluded from Gage R and R; the LSS engine skips discrete-outcome channels"
- channel_id: antenna_vswr
measurand: voltage_standing_wave_ratio
unit: "1"
datatype: Float
uns_parameter: ant_vswr
publish_policy: { kind: periodic, interval_s: 60 }
signal_model:
kind: steady_state
params: { baseline: 1.25 }
baseline: { kind: constant, value: 1.25 }
noise: { kind: gaussian, sigma: 0.02 }
drift: { kind: wiener, per_day: 0.004, sigma_per_sqrt_day: 0.002 }
degradation:
kind: random_walk
rate_per_day: 0.01
warn_limit: 1.6
alarm_limit: 2.0
trip_limit: 2.4
range: { min: 1.0, max: 10.0 }
resolution: 0.01
accuracy: { kind: absolute, value: 0.05 }
msa:
repeatability_sigma: 0.018 # equipment variation, one appraiser, repeated measures
reproducibility_sigma: 0.009 # appraiser variation
msa_source: "repo-defined modeling assumption, not a gauge study"
failure_modes:
- mode_id: antenna_cable_degradation
catalog_id: F-DEV-DRIFT # the id in variability-and-faults C.4
kind: drift
target_channel: antenna_vswr
hazard: { kind: weibull, shape: 2.1, scale_hours: 26000 }
params: { rate_multiplier: 12.0 }
detection_signature: "VSWR EWMA rises while read-rate falls uniformly across orientations"
severity: 6 # S, 1..10, engineering judgement, documented
pf_interval_hours: 720 # P-F interval; grounds the D rating (5.13, VG-IOT-06)
- mode_id: degraded_read_rate
catalog_id: F-DEV-READRATE
kind: read_rate_loss
target_channel: read_event
hazard: { kind: exponential, mtbf_hours: 9000 }
params: { p_multiplier: 0.7 }
detection_signature: "p-chart on reads per expected pass drops below LCL for one portal only"
severity: 5
pf_interval_hours: 168
- mode_id: read_event_dropout
catalog_id: F-DEV-DROP
kind: sample_dropout
target_channel: read_event
hazard: { kind: exponential, mtbf_hours: 4000 }
params: { p_dropout: 0.06, burst_len_mean: 4 }
detection_signature: "sample-count deficit against the expected cadence, with no value shift"
severity: 4
pf_interval_hours: 96
device_faults: # whole-device modes, not channel-specific
- link_flap
- brownout_crash_loop
- duplicate_client_id
- nvs_config_corruption
Invariants on the catalog, all CI-enforced and all named in 7.2:
type_idis unique across all category files, andcategoryis a scalar. An entry lives in exactly one category file. A subsystem in another category that consumes the entry expresses that throughattaches_to, and 3.9 holds the consumption table.- Every entry has at least one channel, at least one failure mode across channels plus device
faults, and a non-empty
capability_unlocks. - Every
capability_unlocksid resolves incatalog/capabilities.yaml, and every capability registered there names the module or the test that realizes it (INV-CAT-4). - Every channel carries the uniform quartet the source asks for:
unit, abaselineblock, anoiseblock, and adriftblock.driftmay be{kind: none}only with awhystring, so an entry cannot silently have no drift characteristic (INV-CAT-1). - Every channel carries an
msablock withrepeatability_sigmaandreproducibility_sigma, or an explicitmsa_noteexcluding it, so the LSS engine's Gage R and R study has declared variance components to be scored against. - Every
unitresolves in the published Unified Code for Units of Measure code list. The pinned edition is UCUM version 2.2, read from https://ucum.org/ucum on 2026-08-09, HTTP 200. The version is recorded incatalog/UCUM_VERSION, VG-IOT-05 is the gate, and a UCUM release that retires a code twinflow uses fails that gate rather than silently changing meaning. (attaches_to, uns_parameter)is unique, so no two channels collide on a UNS topic.warn_limit < alarm_limit < trip_limit, and all insiderange.- Every
signal_model.kindandfailure_modes[].kindresolves in the plugin registry, and everyfailure_modes[].catalog_idresolves in the fault catalog ofdocs/design/variability-and-faults.md. - Every entry has a
phase, and that phase exists in ROADMAP.md. - Every entry with numeric alarm limits carries a
limit_sourcestring. - No collection the catalog loader builds is a
set(D-03).Catalog.entries_by_category()returns categories in the enumerated category order and entries sorted bytype_id,capability_unlocksanddevice_faultskeep their YAML declaration order, and the loader sorts any collection it derives from a mapping before it is iterated. INV-CAT-7 loads the shipped catalog twice in separate processes under differentPYTHONHASHSEEDvalues and asserts the two binding orders and the two resulting Sparkplug alias tables are identical, which is the observation that fails if a set reaches the alias assignment.
The capability_unlocks rule is what stops the catalog padding toward 500 thin entries, and it
is enforced rather than asserted. catalog/capabilities.yaml holds one row per capability with
the module path or test id that realizes it, and a capability with neither is a load error
naming the capability. An entry that unlocks nothing cannot be merged, and an entry that claims
a capability nothing implements cannot be merged either.
3.2 The 80 implemented types and the twelve signal-model kinds¶
The status: implemented count at v1.0 is 80, the top of the source's stated 60 to 80 band.
Each entry's phase is the phase of the earliest subsystem that consumes it, per the author's
constraint that sensor categories grow with their subsystems.
| Category | Count | Phase |
|---|---|---|
| industrial_equipment | 12 | P1 (1 type), P3 (11) |
| environmental_facility | 8 | P3 |
| warehouse_logistics | 18 | P1 (2 types), P3b (15), P4 (camera) |
| transportation_fleet | 10 | P3h |
| electrical_power | 8 | P3b |
| safety_compliance | 9 | 6a10 |
| process_chemical | 10 | P3i |
| structural | 5 | 6a10 |
Total 80. rtd_pt100 is an industrial_equipment entry and appears in no other category file.
The warehouse subsystem that reads a Pt100 does so through attaches_to, which is what that
field is for, and 3.9 records the consumption.
Twelve signal-model kinds cover all 80 types. That claim is the proof of the "type 300 is a
catalog entry, not code" argument, so the full assignment is published rather than sampled, and
INV-CAT-3 asserts that the set of distinct signal_model.kind values across the shipped
catalog has cardinality at most twelve and is a subset of the twelve named below.
| kind | Physics |
|---|---|
steady_state |
baseline plus noise, no dynamics |
first_order_lag |
dx/dt = (x_env - x)/tau + k*load; thermal mass, ventilation decay, HVAC |
duty_cycled |
value switches on twin equipment state with a settling time constant |
rotating_machine_vibration |
harmonic series at running speed plus bearing defect frequencies plus a floor |
counter |
monotone non-decreasing accumulator with rollover |
event_stream |
discrete events driven by twin activity, plus a Poisson background |
rf_read_zone |
delegates to twinflow-rf through the plugin registry |
positional |
coordinate plus dilution-of-precision noise, with a multipath term indoors |
binary_state |
debounced two-state with configurable chatter |
electrochemical |
Nernst response with slope and offset that decay with electrode age |
level_gauge |
level from echo time or capacitance, with dropout rising with foam, dust, slosh |
load_bridge |
strain-bridge output with creep, temperature coefficient, and zero drift |
The assignment is per channel, not per type, which is why a multi-channel device appears with
more than one kind. power_meter_3ph is the clearest case: its instantaneous power channel is
duty_cycled and its energy register is a counter.
| type_id | Category | Phase | Channels | Signal-model kinds |
|---|---|---|---|---|
| vibration_velocity_iso | industrial_equipment | P3 | 3 | rotating_machine_vibration |
| accelerometer_triaxial_mems | industrial_equipment | P3 | 3 | rotating_machine_vibration |
| strain_gauge_bridge | industrial_equipment | P3 | 2 | load_bridge |
| load_cell_shear_beam | industrial_equipment | P3 | 2 | load_bridge |
| torque_transducer_rotary | industrial_equipment | P3 | 2 | load_bridge |
| rotary_encoder_incremental | industrial_equipment | P3 | 2 | counter, duty_cycled |
| thermocouple_type_k | industrial_equipment | P3 | 2 | first_order_lag |
| rtd_pt100 | industrial_equipment | P1 | 2 | first_order_lag |
| acoustic_emission_piezo | industrial_equipment | P3 | 3 | event_stream, rotating_machine_vibration |
| oil_quality_dielectric | industrial_equipment | P3 | 3 | steady_state, counter |
| motor_current_signature | industrial_equipment | P3 | 4 | duty_cycled, rotating_machine_vibration |
| motor_winding_temp_ptc | industrial_equipment | P3 | 1 | first_order_lag |
| ambient_temp_room | environmental_facility | P3 | 1 | first_order_lag |
| relative_humidity_capacitive | environmental_facility | P3 | 1 | steady_state |
| dew_point_chilled_mirror | environmental_facility | P3 | 1 | steady_state |
| co2_ndir | environmental_facility | P3 | 1 | first_order_lag |
| particulate_pm25_optical | environmental_facility | P3 | 2 | steady_state |
| water_leak_rope | environmental_facility | P3 | 1 | binary_state |
| thermal_imager_spot | environmental_facility | P3 | 3 | first_order_lag |
| differential_pressure_room | environmental_facility | P3 | 1 | steady_state |
| rfid_portal_reader_uhf | warehouse_logistics | P1 | 3 | rf_read_zone, steady_state |
| rfid_tag_passive_uhf | warehouse_logistics | P1 | 1 | rf_read_zone |
| barcode_scanner_fixed | warehouse_logistics | P3b | 2 | event_stream |
| barcode_scanner_handheld | warehouse_logistics | P3b | 2 | event_stream |
| shelf_weight_pad | warehouse_logistics | P3b | 1 | load_bridge |
| pallet_weight_platform | warehouse_logistics | P3b | 1 | load_bridge |
| uwb_anchor | warehouse_logistics | P3b | 2 | positional |
| uwb_asset_tag | warehouse_logistics | P3b | 2 | positional |
| ble_beacon | warehouse_logistics | P3b | 2 | positional |
| ble_gateway | warehouse_logistics | P3b | 2 | positional |
| dock_door_position | warehouse_logistics | P3b | 1 | binary_state |
| conveyor_speed_encoder | warehouse_logistics | P3b | 2 | duty_cycled, counter |
| conveyor_load_cell | warehouse_logistics | P3b | 1 | load_bridge |
| pick_to_light_module | warehouse_logistics | P3b | 2 | event_stream, counter |
| smart_shelf_rfid | warehouse_logistics | P3b | 2 | rf_read_zone |
| dock_scale_floor | warehouse_logistics | P3b | 1 | load_bridge |
| trailer_presence_loop | warehouse_logistics | P3b | 1 | binary_state |
| camera_area_scan_synthetic | warehouse_logistics | P4 | 2 | event_stream |
| gnss_receiver | transportation_fleet | P3h | 3 | positional |
| imu_6dof | transportation_fleet | P3h | 6 | rotating_machine_vibration |
| fuel_level_capacitive | transportation_fleet | P3h | 1 | level_gauge |
| tpms_tire_pressure | transportation_fleet | P3h | 2 | steady_state |
| engine_coolant_temp | transportation_fleet | P3h | 1 | first_order_lag |
| reefer_cargo_temp_probe | transportation_fleet | P3h | 2 | first_order_lag |
| cargo_humidity | transportation_fleet | P3h | 1 | steady_state |
| shock_logger_triaxial | transportation_fleet | P3h | 2 | event_stream |
| tilt_switch_cargo | transportation_fleet | P3h | 1 | binary_state |
| telematics_gateway_j1939 | transportation_fleet | P3h | 4 | counter, duty_cycled |
| voltage_transducer_l_n | electrical_power | P3b | 1 | steady_state |
| current_clamp_ct | electrical_power | P3b | 1 | duty_cycled |
| power_meter_3ph | electrical_power | P3b | 5 | duty_cycled, counter |
| power_factor_relay | electrical_power | P3b | 2 | duty_cycled |
| smart_meter_revenue | electrical_power | P3b | 2 | counter |
| battery_pack_bms | electrical_power | P3b | 5 | duty_cycled, counter |
| ups_status | electrical_power | P3b | 2 | binary_state |
| charger_dc_bus | electrical_power | P3b | 3 | duty_cycled |
| smoke_detector_photoelectric | safety_compliance | 6a10 | 2 | steady_state, binary_state |
| gas_leak_catalytic | safety_compliance | 6a10 | 2 | electrochemical |
| co_detector_electrochemical | safety_compliance | 6a10 | 2 | electrochemical |
| estop_status | safety_compliance | 6a10 | 1 | binary_state |
| machine_guard_interlock | safety_compliance | 6a10 | 1 | binary_state |
| light_curtain | safety_compliance | 6a10 | 2 | binary_state |
| worker_proximity_uwb_badge | safety_compliance | 6a10 | 2 | positional |
| ppe_compliance_cv | safety_compliance | 6a10 | 2 | event_stream |
| fall_detection_wearable | safety_compliance | 6a10 | 2 | event_stream |
| ph_electrode | process_chemical | P3i | 2 | electrochemical |
| conductivity_toroidal | process_chemical | P3i | 2 | steady_state |
| viscosity_inline_vibrating | process_chemical | P3i | 2 | duty_cycled |
| flow_coriolis | process_chemical | P3i | 4 | duty_cycled, counter |
| flow_magnetic | process_chemical | P3i | 3 | duty_cycled, counter |
| level_radar_fmcw | process_chemical | P3i | 2 | level_gauge |
| level_ultrasonic | process_chemical | P3i | 2 | level_gauge |
| dissolved_oxygen_optical | process_chemical | P3i | 2 | electrochemical |
| corrosion_er_probe | process_chemical | P3i | 2 | counter |
| pressure_transmitter_gauge | process_chemical | P3i | 1 | duty_cycled |
| building_strain_gauge | structural | 6a10 | 2 | load_bridge |
| crack_propagation_gauge | structural | 6a10 | 2 | counter, event_stream |
| roof_load_cell | structural | 6a10 | 1 | load_bridge |
| tilt_inclinometer_structural | structural | 6a10 | 2 | steady_state |
| seismic_accelerometer | structural | 6a10 | 3 | rotating_machine_vibration |
Channel counts sum to 191 across the 80 entries, which is the number open question 9.6 asks
about. Adding a type that reuses a kind is YAML only, and a test enforces it literally:
test_catalog_entry_needs_no_code writes a new entry at runtime into a temp catalog, runs the
pipeline, and asserts telemetry arrives on the expected UNS topic.
The README's design note about scaling to the full 500-type industrial landscape is a
deliverable of this section, not a slogan. It lives at docs/design/catalog-scaling-note.md,
the README links it, and test_catalog_scales_to_500 generates 500 synthetic entries spread
across the twelve kinds, loads them, binds them, and asserts that no new module was imported,
that load time stays under catalog.load_budget_s, and that resident memory stays under
catalog.memory_budget_mb. The schema is the proof only if the proof runs.
3.3 EdgeNodeSpec, DeviceSpec, and DeviceRecord¶
Sparkplug has three identifier levels and this section maps each one to a distinct object, because conflating the middle two is how a fleet quarantines itself.
A Sparkplug edge node is a process holding exactly one MQTT client session. It publishes
NBIRTH, registers its NDEATH as the last will, owns the bdSeq and seq counters and the
alias table, and hosts one or more Sparkplug devices. A Sparkplug device is a logical child of
that node, addressed as {edge_node_id}/{device_id}, and it is not an MQTT client. It has no
session, no bdSeq, and no last will of its own.
EdgeNodeSpec is the process, and DeviceSpec is the thing attached to it.
| Field | Type | Invariant |
|---|---|---|
edge_node_id |
str, ^[a-z0-9][a-z0-9-]{2,63}$ |
unique across the fleet; one MQTT client session (INV-SPB-1) |
area, line |
str |
render the Sparkplug group_id and edge_node_id templates |
agent |
python \ |
rust |
gateway_id |
str \ |
null |
mqtt_client_id |
str |
derived from edge_node_id; a duplicate is a config error |
devices[] |
list[device_id] |
non-empty; every id resolves in devices.instances |
| Field | Type | Invariant |
|---|---|---|
device_id |
str, ^[a-z0-9][a-z0-9-]{2,63}$ |
unique in the fleet; no /, +, # (Sparkplug and MQTT topic safety) |
edge_node_id |
str |
resolves in edge_nodes; the node that hosts this device |
type_id |
str |
resolves in the catalog |
serial |
str |
unique; present on the manufacturing allowlist for ZTP |
hardware_rev |
str |
|
uns_path |
UnsPath |
unique in the fleet |
edge_tier |
0..3 |
at most the tier of its gateway |
physical |
bool |
true only for a HIL device (5.26) |
firmware_version |
semver | |
firmware_hash |
sha256 hex | matches the signed image the OTA server holds |
ota_slot |
a \ |
b |
cert_fingerprint |
sha256 hex | present once enrolled |
cert_not_after |
SimInstant | renewal scheduled at 2/3 of lifetime |
lifecycle_state |
enum | see 3.4 |
health_score |
0..100 float |
see 5.13 |
desired_config_hash |
sha256 | |
reported_config_hash |
sha256 | drift when it differs from desired |
last_seen |
SimInstant | |
boot_count, crash_loop_count, brownout_count |
int, monotone non-decreasing | |
sf_dropped_records |
int, monotone non-decreasing | the admitted-loss counter (5.6) |
DeviceSpec is what config declares. DeviceRecord is what the registry projects from events
and carries every field above plus the projection metadata. Firmware, certificate, and OTA slot
fields live on the device rather than the node because a HIL device is one physical board
hosting one Sparkplug device, and because the OTA campaign selector in 5.20 targets devices.
When an edge node hosts several simulated devices in one process, the campaign engine groups
them by edge_node_id and applies one image per process; test_ota_groups_by_edge_node
asserts it.
test_two_agents_on_one_edge_node_is_rejected writes a facility.yaml declaring two agent
processes claiming the same edge_node_id and asserts the loader refuses it with the offending
line and column. The failure is caught at validation, not discovered at runtime by the
duplicate_client_id quarantine path in 5.9.
3.4 Lifecycle states¶
Registry-side lifecycle, distinct from the agent's connection state machine in 5.1:
unprovisioned -> enrolled -> healthy -> degraded -> failed -> quarantined -> retired, plus the
orthogonal flags updating and held.
Transition rules:
healthy -> degradedwhenhealth_score < fleet.health.degraded_belowsustained forfleet.health.dwell_s.degraded -> healthywhenhealth_score >= fleet.health.recovered_abovesustained for the same dwell. The 5-point hysteresis band plus dwell is what stops flapping, and INV-HS-6 asserts a bounded transition count under a constant fault.* -> failedwhenhealth_score < fleet.health.failed_belowor a mode withseverity >= 9is active.* -> quarantinedon a compliance violation withaction: quarantine, on a revoked certificate, or on aduplicate_client_iddetection. A quarantined device's publishes are refused by broker ACL, not merely ignored.-> retiredonly by explicit operator action, which revokes the certificate.
All four thresholds read from 6.7 and all reference the score defined in 5.13.
3.5 DeviceTwin (E44)¶
class DeviceTwin(BaseModel):
device_id: str
desired: dict[str, Any] # config document the fleet wants
reported: dict[str, Any] # config document the device says it has
desired_version: int # monotone; increments on every desired write
reported_version: int # echoed back by the device
last_desired_at: SimInstant
last_reported_at: SimInstant
drift: list[DriftItem] # sorted by path, never a set (D-03)
class DriftItem(BaseModel):
path: str # RFC 6901 JSON pointer
desired: Any
reported: Any
kind: Literal["missing", "extra", "mismatch"]
severity: int # 1..10, from the compliance policy rule covering this path
first_seen: SimInstant
age_s: float
action: Literal["push", "accept", "quarantine", "unreconcilable"]
authority_tier: AuthorityTier | None # required when action == "accept" (5.19)
3.6 FMEA rating tables¶
Five data files, all versioned, all replaceable by an adopting organization.
fleet/severity.yaml maps mode_id to S. It is sourced from the catalog entry's severity and
mirrored here so a deployment can override without editing the catalog.
fleet/occurrence_table.yaml holds ten bands mapping a measured incident rate (incidents per
device-hour, or per opportunity for event-driven modes) to O in 1 to 10. It is a deterministic
function of measurement, not of judgement.
fleet/detection_table.yaml maps MTTD_p90 / pf_interval to D in 1 to 10. Detecting inside 1%
of the P-F interval rates D=1; detecting only after functional failure rates D=10.
fleet/occurrence_prior.yaml and fleet/detection_prior.yaml hold the cold-start values used
before a device has enough history for the measured tables, with the confidence penalty each
prior carries. 5.13 defines when they apply and when they are replaced.
Grounding D in the P-F interval is what turns the detection rating from a workshop guess into a measured ratio, and the simulation is the only place where the P-F interval is known exactly. The P-F interval concept is cited to Nowlan and Heap, "Reliability-Centered Maintenance", United States Department of Defense, 1978, DTIC accession AD-A066579. The DTIC record refused automated retrieval on 2026-08-09 (HTTP 403), so this citation is carried at single-source confidence with the attribution stated in text, and open question 9.16 tracks getting an accessible primary copy.
3.7 RF domain objects (E46)¶
class Antenna(BaseModel):
antenna_id: str
position_m: Vec3 # facility frame
boresight: Orientation # azimuth_deg, elevation_deg
pattern: AntennaPattern # "dipole" | "patch_cos_n" | tabulated gain grid
peak_gain_dbi: float
polarization: Literal["rhcp", "lhcp", "linear_h", "linear_v"]
tx_power_dbm: float # conducted power at the antenna port
cable_loss_db: float
region: Literal["fcc", "etsi"]
band: Literal["us_902_928", "eu_865_868", "eu_915_921"]
class TagModel(BaseModel):
tag_type_id: str
sensitivity_dbm: float = -20.0 # activation threshold at the IC
backscatter_loss_db: float = 8.0
pattern: AntennaPattern = "dipole"
polarization: Literal["linear"] = "linear"
class TagPose(BaseModel):
position_m: Vec3
orientation: Orientation # tag axis in the facility frame
occluding_material: Literal["none", "cardboard", "liquid", "metal"]
depth_in_pallet: int # cases of attenuating material between tag and antenna
class ReadZone(BaseModel):
portal_id: str
antennas: list[Antenna]
zone_length_m: float # along travel
inventory_round_rate_hz: float # reader singulation rounds per second
dense_reader_mode: bool
noise_floor_dbm: float # raised by local drives and motors
Invariants: the radiated power computed from tx_power_dbm, peak_gain_dbi, and cable_loss_db
must not exceed the ceiling for the declared (region, band) pair, and config validation refuses
otherwise. zone_length_m > 0. sensitivity_dbm < 0. VG-IOT-04 is the gate over the ceilings.
The ceilings are read from the two regulations, not chosen here. Each carries its own quantity,
and RegulatoryLimits stores the quantity and the number, because comparing a conducted
limit against an effective radiated power is the mistake that makes a compliance check useless.
(region, band) |
Ceiling | Quantity | Source clause |
|---|---|---|---|
fcc, us_902_928 |
1 W, with antenna directional gain at most 6 dBi | peak conducted output power | 47 CFR 15.247(b)(2) and 15.247(b)(4) |
etsi, eu_865_868 |
33 dBm in a 200 kHz bandwidth | effective radiated power | ETSI EN 302 208 V3.3.1 clause 4.3.3.3.1 |
etsi, eu_915_921 |
36 dBm in a 400 kHz bandwidth | effective radiated power | ETSI EN 302 208 V3.3.1 clause 4.3.3.3.2 |
47 CFR 15.247 was retrieved 2026-08-09 from the eCFR renderer API for title 47, section 15.247,
HTTP 200. Paragraph (b)(2) reads "For frequency hopping systems operating in the 902-928 MHz
band: 1 watt for systems employing at least 50 hopping channels", and paragraph (b)(4) bases that
limit "on the use of antennas with directional gains that do not exceed 6 dBi", requiring a
dB-for-dB conducted-power reduction above 6 dBi. PlacementProblem treats FCC transmit
power and antenna gain as one coupled constraint rather than two independent ceilings.
ETSI EN 302 208 V3.3.1 (2020-08) was retrieved 2026-08-09 from the ETSI deliverable server, HTTP 200. Its title states power levels up to 2 W in 865 MHz to 868 MHz and up to 4 W in 915 MHz to 921 MHz, and clauses 4.3.3.3.1 and 4.3.3.3.2 give those as 33 dBm and 36 dBm effective radiated power in the stated bandwidths.
Clause 4.3.4.3 of the same standard couples beam-width to radiated power, and the model carries
that coupling because an antenna configuration can satisfy the power ceiling and still be
illegal. In the lower band there is no beam-width restriction at or below 500 mW e.r.p.,
beam-width must be at most 180 degrees above 500 mW up to 1000 mW, and at most 90 degrees above
1000 mW up to 2000 mW. In the upper band the same three bands are at or below 1000 mW, above 1000
mW up to 2000 mW, and above 2000 mW up to 4000 mW. Antenna.pattern yields a horizontal
half-power beam-width, config validation refuses a combination that violates the coupling, and
VG-IOT-04 asserts both the power ceiling and the beam-width coupling against a table transcribed
from those clauses.
Every numeric physical constant in twinflow-rf is a PhysicalConstant carrying a mandatory
source string, in the same style the catalog requires limit_source. INV-RF-6 asserts that no
PhysicalConstant ships with an empty source, and the permitted source kinds are a named
published reference, a named regulation, or the literal string
"repo-defined modeling assumption, not a measurement".
3.8 Runbook (E48)¶
class Runbook(BaseModel):
signature_id: str # stable hash over (mode_id, device_type, detector_id)
version: int
symptom: str # what an operator sees
root_cause: str # what actually happened, from the ground-truth injection
fix: str # steps
prevent: str # design or policy change that stops recurrence
mttd_p50_s: float
mttd_p90_s: float
detection_rate: float # fraction of injections detected; censoring is stated, not hidden
severity: int
occurrence: int
detection: int
rpn: int
action_priority: Literal["H", "M", "L"]
first_seen: SimInstant
occurrences: int
affected_types: list[str] # sorted, never a set (D-03)
evidence_refs: list[str] # event ids and query-result ids the agent can cite (E26f)
3.9 ResourceSelector, consumption, and the capability registry¶
attaches_to is a selector over the twin's resource tree, and its grammar is frozen at Phase 0
alongside UnsPath because catalog entries are written from Phase 1 onward and changing the
grammar later rewrites 80 files.
The grammar is a dot-delimited path of segments matching ^[a-z0-9][a-z0-9_-]{0,31}$, where a
segment may be the literal * matching exactly one level, or the literal ** matching one or
more levels, and only as a whole segment:
The mapping to UnsPath is fixed and total. A resource path area.line.equipment.subsystem
resolves to the UNS path {enterprise}/{site}/{area}/{line}/{equipment}/{parameter}, where
enterprise and site come from the uns: block, the first three resource segments supply
area, line, and equipment, and any deeper resource segments are joined with . into the
Sparkplug metric name below parameter. UnsPath.from_resource(path, parameter) and
UnsPath.to_resource() are the two directions, and INV-UNS-1 covers the round trip.
The grammar is co-owned with the twin section, which owns the resource tree itself. This
section owns the selector syntax, the mapping, and the resolution test. INV-CAT-5 asserts that
every attaches_to in the shipped catalog resolves to at least one resource in each of the
three worked profiles from A2, and just run --dry-run fails with the unresolved selector
named.
Cross-category consumption lives here rather than in the category files, so that a type stays in exactly one category:
| Entry | Owning category | Also consumed by |
|---|---|---|
rtd_pt100 |
industrial_equipment | warehouse_logistics (dock and staging ambient probes) |
load_cell_shear_beam |
industrial_equipment | warehouse_logistics (in-motion checkweigh) |
motor_current_signature |
industrial_equipment | electrical_power (energy per pallet, E7) |
uwb_asset_tag |
warehouse_logistics | safety_compliance (worker proximity, 6a10) |
ppe_compliance_cv |
safety_compliance | warehouse_logistics (the shared camera device, req 4) |
catalog/capabilities.yaml is the registry every capability_unlocks id resolves against:
capabilities:
- id: rfid_read_counting
realised_by: "twinflow.rf.simulate_pass"
proven_by: "tests/rf/test_simulate_pass.py::test_pass_produces_read_events"
- id: rf_read_zone_diagnosis
realised_by: "twinflow.rf.diagnose"
proven_by: "tests/rf/test_diagnose.py::test_physics_versus_device"
A capability with neither realised_by nor proven_by is a load error. That is what turns the
anti-padding rule from an assertion into a check: an entry can only claim a capability that
some module realizes and some test proves.
4. Events¶
All schemas live in /schemas/iot/ and /schemas/fleet/ as JSON Schema 2020-12, registered per
C3, and additive-only within a major version.
Every event carries the envelope the schema registry defines. Per D-07 the envelope is
event_id, schema, version, sim_ts, run_id, producer_id, and seq, where seq is
dense per (run_id, producer_id) and the canonical total order over the log is
(sim_ts, producer_id, seq). This section's producers are the edge node agents (one
producer_id per edge_node_id), the gateways (one per gateway_id), the fleet service, the
PdM service, and the fault injector. No producer in this section allocates a globally dense
sequence number, because no allocator for one exists.
The Sparkplug wire payload is protobuf, not JSON Schema. It is registered as
/schemas/iot/sparkplug/payload.v3_0_0.proto with /schemas/iot/sparkplug/conformance.yaml
alongside it, which enumerates the normative rule ids the codec claims to satisfy (see 7.4).
4.1 Sparkplug messages published (E3)¶
| Topic | Payload | Cadence |
|---|---|---|
spBv1.0/{group}/NBIRTH/{edge_node} |
seq=0, bdSeq, every node-level metric with datatype and alias |
on every MQTT session establishment |
spBv1.0/{group}/NDATA/{edge_node} |
changed node metrics by alias, seq incrementing modulo 256 |
on change or heartbeat |
spBv1.0/{group}/NDEATH/{edge_node} |
bdSeq matching the session's NBIRTH |
registered as the MQTT last will at CONNECT |
spBv1.0/{group}/DBIRTH/{edge_node}/{device} |
every metric the device will ever publish, with alias assignments | after NBIRTH, and on rebirth |
spBv1.0/{group}/DDATA/{edge_node}/{device} |
changed device metrics by alias | on change or heartbeat |
spBv1.0/{group}/DDEATH/{edge_node}/{device} |
device offline | on device loss below a live edge node |
spBv1.0/{group}/NCMD/{edge_node} |
consumed: Node Control/Rebirth, Node Control/Next Server |
inbound |
spBv1.0/{group}/DCMD/{edge_node}/{device} |
consumed: restricted set, see 5.26 | inbound |
spBv1.0/STATE/{host_id} |
host application state | published by the primary host application |
Identifier mapping, defined once in UnsPath:
group_id = "{enterprise}:{site}:{area}"edge_node_id = "{line}"device_id = "{equipment}"
The parameter segment becomes the Sparkplug metric name, dot-delimited where nested.
Colons are the ISA-95 delimiter inside group_id because Sparkplug identifiers must not contain
/, +, or #. The full ISA-95 path is recoverable from the three identifiers, which INV-UNS-1
proves by round trip.
The default edge_node_id template renders the line, which means one agent process per line
hosts every device on that line. That default is only correct because 3.3 makes the edge node a
process and the device a logical child. Two devices on line conv-02 are two Sparkplug devices
under one edge node and one MQTT client session, not two clients both claiming conv-02.
INV-SPB-1 asserts that no two MQTT client sessions in a run share an edge_node_id, and
test_two_agents_on_one_edge_node_is_rejected (3.3) catches the config that would break it
before the run starts.
A deployment that genuinely wants one process per device sets
uns.sparkplug.edge_node_id_template to "{line}-{equipment}", and the validator then requires
devices[] on each edge node to have length one. Both shapes are legal; the shape that produced
a self-inflicted duplicate_client_id quarantine is not.
4.2 UNS mirror¶
{root}/{enterprise}/{site}/{area}/{line}/{equipment}/{parameter}, default root twinflow,
JSON payload, retain=true, QoS 1. Published by the tier-2 site gateway by default, because
publishing it at tier 1 doubles traffic over the constrained link. It is configurable to tier 1
for deployments that want the mirror visible on the OT segment during a WAN outage.
iot.uns.mirror.v1:
{
"uns_path": "acme/dc-01/receiving/dock-3/portal-a/ant_vswr",
"value": 1.31,
"unit": "1",
"quality": "good",
"device_ts": 1730000000.123,
"ingest_ts": 1730000000.161,
"clock_epoch_id": "portal-dock3-a:3",
"device_id": "portal-dock3-a",
"edge_node_id": "dock-3",
"stale_after_s": 300,
"seq": 187
}
On DDEATH the gateway republishes the retained mirror topic with "quality": "bad" and
"value": null. A stale retained value that outlives its device is a classic UNS failure and
this is the mitigation.
The namespace itself is a documentation deliverable, not only a code one. Component 2 asks for
the namespace to be documented in ARCHITECTURE.md, so ARCHITECTURE.md carries a UNS section
holding the six-level hierarchy, the Sparkplug identifier mapping above, the mirror rendering,
and one worked example per shipped profile. test_architecture_uns_examples_round_trip parses
the fenced examples out of that section and asserts each one round-trips through UnsPath, so
the document cannot drift from the code. The section lands in Phase 0 with the value object.
4.3 Device events¶
| Schema | Key fields |
|---|---|
iot.device.boot.v1 |
device_id, reset_reason, uptime_before_reset_s, boot_count, crash_loop_count, brownout_count, free_heap_min_bytes, firmware_version, firmware_hash, ota_slot, ota_pending_verify |
iot.device.timegate.v1 |
device_id, observed_epoch_s, floor_epoch_s, floor_origin, source (rtc\ |
iot.device.connect.attempted.v1 |
device_id, edge_node_id, attempt_index, backoff_ms, base_ms, cap_ms, outcome |
iot.device.diag.ladder.v1 |
device_id, broker_host, broker_port, rungs[] each {name, ok, latency_ms, detail}, classification, peer_issuer |
iot.device.state.v1 |
device_id, from_state, to_state, reason |
iot.device.hold.v1 |
device_id, consecutive_failures, hold_until_sim_ts, diag_bundle_ref |
iot.device.buffer.v1 |
device_id, buffered_records, buffered_bytes, cap_bytes, dropped_records_total, deadbanded_records_total, drop_policy, oldest_record_age_s |
iot.device.clock.v1 |
device_id, clock_epoch_id, drift_ppm, step_applied_s, source |
iot.device.config.reported.v1 |
device_id, reported_version, config_hash, config |
The classification enum has thirteen failure values plus ok: ok, dns_nxdomain,
dns_timeout, tcp_refused, tcp_timeout, tls_unknown_ca, tls_hostname_mismatch,
tls_notbefore, tls_expired, tls_handshake_timeout, mqtt_not_authorized,
mqtt_bad_protocol, mqtt_identifier_rejected, unknown.
floor_origin records which of the two floor sources 5.7 used, and it exists so a reader of a
recorded log can tell a seeded run from a production run without opening the manifest.
4.4 Telemetry and the ground-truth pair¶
iot.telemetry.reading.v1 is the canonical internal reading. It is what the historian stores
and what every downstream package consumes. Sparkplug is the wire format; this is the semantic
record.
{
"device_id": "conv-02-motor-temp",
"edge_node_id": "conv-02",
"channel_id": "winding_temp",
"uns_path": "acme/dc-01/sortation/conv-02/motor/winding_temp",
"observed_value": 71.4,
"unit": "Cel",
"quality": "good",
"device_ts": 1730000000.5,
"ingest_ts": 1730000000.56,
"clock_epoch_id": "conv-02-motor-temp:7",
"seq": 41,
"sparkplug_alias": 12,
"replayed": false,
"buffer_latency_s": 0.0
}
The ground-truth companion carries the answer key and never travels with the reading:
{
"reading_event_id": "01J8Z...",
"device_id": "conv-02-motor-temp",
"channel_id": "winding_temp",
"clean_value": 68.9,
"fault_tags": ["drift:thermocouple_junction_degradation"],
"fault_instance_ids": ["F-DEV-DRIFT.0007"],
"device_ts": 1730000000.5
}
iot.telemetry.groundtruth.v1 is written only to the ground-truth Delta root and is joined to
the reading by reading_event_id. Separating the record rather than firewalling a column is
what makes 5.12's guarantee enforceable with the storage stack this repo actually locks in. The
clean value is never discarded, which is what makes MTTD exact, makes E25's labeled datasets
free, and makes the PdM bakeoff scoreable.
Three timestamps, three jobs, and the split exists to stop them being mixed:
device_ts is the device's own clock, which drifts. ingest_ts is the gateway's clock when the
record was accepted, which does not drift; it is arrival accounting and it feeds the ingest-lag
SLO. clock_epoch_id names the interval between two clock resyncs on one device, so a resync
step ends an epoch instead of violating monotonicity.
Both are sim-time (C2). device_ts and ingest_ts are seconds since the scenario epoch declared
in simulation.epoch, read from the injected Clock, and the values printed in the examples
above are sim-time seconds rather than a wall-clock reading that happens to look like one. D-02
permits a wall-clock read in exactly four places and none of them is a telemetry payload, so a
device or gateway that read the host clock to fill either field would be a determinism defect
rather than a modeling choice. Clock.wall_mapping() records the one sim-to-wall mapping per
run, and it lands in the provenance sidecar (D-01), never in an event. The nondeterminism gate in
scripts/checks/nondeterminism-gate.sh enforces the rule outside the kernel package, and
INV-CLK-4 asserts that no schema under /schemas/iot/ or /schemas/fleet/ marks a timestamp
field x-wallclock: true except the two the provenance sidecar owns.
The historian's ordering key within a channel is (device_id, clock_epoch_id, device_ts).
ingest_ts is never an ordering key. That correction matters because 5.6 replays buffered
records with a fresh ingest_ts, so ordering by arrival would place hour-old outage data after
fresh data and an SPC chart over the outage window would be wrong. Ordering across producers is
the D-07 canonical total order (sim_ts, producer_id, seq). INV-CLK-3 asserts that a record
buffered through an outage lands at its true position in its channel's series, and S-IOT-02
asserts it end to end by building an I-MR chart across the outage window and comparing the point
order against the ground-truth sample order.
buffer_latency_s is ingest_ts - device_ts for a replayed record, published so the ingest-lag
chart can separate a slow network from a recovering buffer.
4.5 RFID and RF¶
| Schema | Key fields |
|---|---|
iot.rfid.read.v1 |
epc, portal_id, antenna_port, rssi_dbm, phase_deg, doppler_hz, read_count_in_window, first_seen_ts, last_seen_ts |
iot.rfid.read.groundtruth.v1 |
epc, true_portal_id, is_cross_read, tag_orientation_deg, pallet_speed_mps, tags_in_zone, p_read, miss_cause |
iot.rfid.pass.summary.v1 |
portal_id, pallet_id, tags_expected, tags_read, read_rate, cross_reads_in, cross_reads_out, dwell_s |
iot.rf.placement.evaluated.v1 |
portal_id, candidate_id, antenna_config, read_rate, cross_read_rate, throughput_delta, cost, search_stream_name |
rf.whatif.evaluated.v1 |
whatif_id, portal_id, before_sample_ref, after_sample_ref, lss_request_id, read_rate_delta, cross_read_delta |
4.6 Fleet events¶
| Schema | Key fields |
|---|---|
fleet.device.enrolled.v1 |
device_id, serial, cert_fingerprint, uns_path, edge_node_id, assigned_by_policy |
fleet.enrollment.rejected.v1 |
serial, reason (serial_not_allowlisted\ |
fleet.cert.issued.v1 |
device_id, fingerprint, not_before, not_after |
fleet.cert.renewed.v1 |
device_id, old_fingerprint, fingerprint, not_before, not_after |
fleet.cert.revoked.v1 |
device_id, fingerprint, reason, crl_version |
fleet.health.scored.v1 |
device_id, health_score, method, active_modes[] each {mode_id, s, o, d, rpn, action_priority, confidence, evidence}, lifecycle_state, changed, prior_used |
fleet.fault.detected.v1 |
detector_id, device_id, channel_id, mode_id_guess, evidence_window, detect_sim_ts, confidence |
fleet.fault.matched.v1 |
detected_event_id, injected_fault_id, latency_s, match_kind (true_positive\ |
fleet.mttd.v1 |
fault_class, n_injected, n_detected, detection_rate, mttd_mean_s, p50_s, p90_s, max_s, censored_n |
fleet.twin.drift.v1 |
device_id, drift_items[], oldest_age_s, action_taken, authority_tier |
fleet.compliance.violation.v1 |
device_id, rule_id, path, desired, reported, severity, remediation |
fleet.runbook.published.v1 |
signature_id, version, path, changed_fields[] |
iot.fault.injected.v1 |
fault_id, instance_id, device_id, channel_id, mode_id, catalog_id, params, inject_sim_ts, detectable_from_sim_ts (ground truth) |
iot.fault.cleared.v1 |
fault_id, instance_id, clear_sim_ts, cleared_by |
fleet.cert.issued.v1, .renewed.v1, and .revoked.v1 are three registered subjects, not one,
because their field sets differ and C3's contract tests are per subject.
detectable_from_sim_ts is copied from the ground-truth record defined in
docs/design/variability-and-faults.md C.3, so MTTD is comparable across faults of different
severities and a detection before that instant is scored as leakage rather than as a triumph.
4.7 OTA events¶
ota.campaign.created.v1, ota.ring.started.v1, ota.device.update.started.v1,
ota.device.update.chunk.v1 (sampled, not per chunk), ota.device.update.applied.v1,
ota.device.update.verified.v1, ota.device.rollback.v1, ota.ring.gate.requested.v1,
ota.ring.gate.evaluated.v1, ota.ring.promoted.v1, ota.campaign.halted.v1,
ota.campaign.completed.v1.
ota.ring.gate.requested.v1 carries {campaign_id, ring, metric, before_sample_ref,
after_sample_ref, alpha, request_id} and is the outbound half of the statistical seam in 5.30.
ota.ring.gate.evaluated.v1 carries the statistical decision, not a flag:
{ring, n_devices, health_before_mean, health_after_mean, test, statistic, p_value, effect_size,
crash_loops, publish_rate_ratio, lss_request_id, decision}.
4.8 Edge events¶
edge.tier.decision.v1 carries {function_id, tier, latency_ms, budget_ms, within_budget,
latency_source} where latency_source is model or benchmark, so a reader can tell a
modeled number from a measured one without reading 5.16.
edge.link.stats.v1 carries {from_tier, to_tier, wire_bytes, messages, window_s,
raw_wire_bytes_equivalent, deadband_suppressed, aggregation_reduced, compression_ratio}.
edge.degraded.v1 carries {gateway_id, lost_capabilities[], retained_capabilities[], since},
with both lists sorted (D-03).
4.9 Emitted to and consumed from other sections¶
maintenance.workorder.requested.v1 carries {device_id, asset_id, mode_id,
predicted_crossing_sim_ts, ci_low, ci_high, runbook_ref, priority, deferral_cost_estimate}. The
PdM layer fills everything but deferral_cost_estimate, which the twin section computes by
running the run-to-failure counterfactual.
pdm.prediction.v1, pdm.trend.v1, and pdm.feature.window.v1 are defined in 5.14.
lss.test.completed.v1 is owned and registered by the LSS engine section. This section consumes
it and registers a consumer contract test against it (C3), which is what keeps the seam in 5.30
from becoming an import.
4.10 Schema inventory¶
| Group | Subjects registered here | Subsection |
|---|---|---|
| UNS mirror | 1 | 4.2 |
| Device events | 9 | 4.3 |
| Telemetry and ground truth | 2 | 4.4 |
| RFID and RF | 5 | 4.5 |
| Fleet events | 14 | 4.6 |
| OTA events | 12 | 4.7 |
| Edge events | 3 | 4.8 |
| Emitted to other sections | 4 | 4.9 |
| Total registered under this section | 50 |
Consumed but owned elsewhere: lss.test.completed.v1. Registered separately as protobuf, not
JSON Schema: payload.v3_0_0.proto.
INV-SCH-1 asserts that the count of subjects the registry resolves under /schemas/iot/ and
/schemas/fleet/ equals 50, that every subject named in 4.2 to 4.9 exists in the registry, and
that every registry subject under those prefixes appears in this table. The count and the
registry cannot drift, because the same test reads both.
5. Behavior¶
5.1 Device agent state machine (component 2)¶
The state machine belongs to the edge node agent, because the states it names are session states and the session is the node's. A Sparkplug device below a live node has two states, present and absent, and the node publishes DBIRTH or DDEATH accordingly.
FACTORY_NEW --(has birth cred)--> TIME_SYNC_WAIT
TIME_SYNC_WAIT --(clock >= epoch_floor)--> PROVISIONING | CONNECTING
PROVISIONING --(operational cert issued)--> CONNECTING
CONNECTING --(CONNACK + NBIRTH sent)--> ONLINE
CONNECTING --(failure)--> CONNECTING (backoff) | HELD (after N)
ONLINE --(link loss)--> BUFFERING
ONLINE --(health signal)--> DEGRADED (still publishing)
BUFFERING --(reconnect)--> CONNECTING
BUFFERING --(buffer full)--> BUFFERING (drop per policy, counter increments)
* --(N consecutive failures)--> HELD
HELD --(hold expiry or link-up event)--> CONNECTING
ONLINE --(OTA command)--> UPDATING --(applied + verified)--> TIME_SYNC_WAIT (reboot)
* --(operator)--> DECOMMISSIONED
Every transition publishes iot.device.state.v1. The state machine is a pure function of
(state, event, config) and is the object the cross-language parity check compares
(IC-IOT-03): the Python and Rust agents must produce identical transition traces for the same
input trace.
5.2 Sampling and fault application¶
Each channel is a coroutine on the sim clock at its publish_policy cadence. Per sample:
- Resolve
SubsystemStatefrom the twin throughattaches_to(running, idle, faulted, speed, load) andAmbientState(facility temperature, humidity). - Call the registered
SignalModel.sample(ctx)to produceclean_value. - Apply the ordered chain of
FaultTransformfor every active fault on this channel, producingobserved_valueand accumulatingfault_tags. The chain order is the catalog declaration order, which is a list, never a set (D-03). - Apply quantization (
resolution) and clipping (range), settingquality = uncertainon clip. - Apply the tier-0 deadband policy. A suppressed sample increments
deadbandedonBufferCountersand is still written to the ground-truth stream, so INV-SF-1 balances and the suppression rate becomes a measured quantity rather than a config restatement.
RNG discipline follows docs/design/variability-and-faults.md rule R6: a fault transforms a
sample and never consumes a subsystem draw. The clean signal draws from
sensor.<measurand>.<device_id>.noise; a fault instance draws from
fault.<fault_id>.<instance_id>.progression. Two separate streams, so turning a fault on does
not shift the noise sequence of the clean signal and the with-fault and without-fault runs stay
comparable. INV-DET-2 asserts it for channel-level modes and INV-DET-3 asserts the weaker,
correct claim for device-level modes.
5.3 Device-type abstraction¶
The chain is catalog YAML, then CatalogEntry, then a SignalModel selected by kind from the
plugin registry, then a Channel bound to a UnsPath, then a Sparkplug metric with an alias.
Nothing in that chain is type-specific code. Adding type 300 touches one YAML file.
Signal models register two ways: the @register_signal_model("kind") decorator for in-repo
models, and the twinflow.signal_models entry-point group for out-of-repo models. Both
populate the same registry, and a duplicate kind is a hard load error naming both providers.
twinflow-rf registers rf_read_zone through the entry-point group like any third party
would, which is what keeps twinflow-sensors free of an RF import (2.5).
5.4 Sparkplug session management (E3)¶
On connect the edge node agent does the following, in order.
- Increment
bdSeqin NVS, which persists across reboots. A duplicatebdSeqafter a reboot is a defect the boot record makes visible. - Register NDEATH as the MQTT last will, carrying that
bdSeq. - CONNECT with clean session (MQTT 3.1.1) or clean start with session expiry 0 (MQTT 5.0). Sparkplug needs a fresh session; a persistent session would deliver stale queued messages after a birth certificate, breaking the state model.
- Publish NBIRTH with
seq = 0, carrying every node metric with itsDataTypeand its alias assignment, plusNode Control/Rebirth,bdSeq, and the node properties (firmware version, hardware rev, boot record summary,sf_dropped_records). - Publish one DBIRTH per attached device, carrying every metric that device will ever publish.
A metric absent from DBIRTH may never appear in DDATA.
ConformanceMatrixenforces this at encode time, raising rather than publishing an invalid stream. - Thereafter publish NDATA and DDATA using aliases only, with
seqincrementing modulo 256.
On Node Control/Rebirth = true through NCMD, republish NBIRTH and all DBIRTHs with the current
bdSeq and seq reset to 0.
sf_dropped_records is a Sparkplug metric on every NBIRTH and on change. The device tells the
fleet how much it lost, in band, on the same channel as the data. Loss is admitted, not hidden.
QoS and retain are configured per topic class, not fleet-wide. Component 6c asks for QoS levels to be exercised, so the matrix is a real config surface. Every Sparkplug row is normative and its value is read from the specification rather than chosen here:
| Topic class | QoS | Retain | Normative identifier | Chosen here |
|---|---|---|---|---|
| NBIRTH | 0 | false | tck-id-payloads-nbirth-qos, tck-id-payloads-nbirth-retain |
no |
| DBIRTH | 0 | false | tck-id-payloads-dbirth-qos, tck-id-payloads-dbirth-retain |
no |
| NDATA | 0 | false | tck-id-payloads-ndata-qos, tck-id-payloads-ndata-retain |
no |
| DDATA | 0 | false | tck-id-payloads-ddata-qos, tck-id-payloads-ddata-retain |
no |
| NCMD | 0 | false | tck-id-payloads-ncmd-qos, tck-id-payloads-ncmd-retain |
no |
| DCMD | 0 | false | tck-id-payloads-dcmd-qos, tck-id-payloads-dcmd-retain |
no |
| DDEATH | 0 | false | none: v3.0.0 states no QoS or retain rule for DDEATH | yes |
| NDEATH as will | 1 | false | tck-id-payloads-ndeath-will-message-qos, -will-message-retain |
no |
| STATE birth | 1 | true | tck-id-host-topic-phid-birth-qos, tck-id-host-topic-phid-birth-retain |
no |
| STATE as will | 1 | true | tck-id-payloads-state-will-message-qos, -state-will-message-retain |
no |
| UNS mirror | 1 | true | none: the mirror is not a Sparkplug topic | yes |
Every value in the table was read from the Sparkplug B v3.0.0 specification text, retrieved 2026-08-09 (2.4). The four normative sentences that carry the surprises read, verbatim: "NBIRTH messages MUST be published with the MQTT QoS set to 0"; "The NDEATH message MUST set the MQTT Will QoS to 1 in the MQTT CONNECT packet"; "The NDEATH message MUST set the MQTT Will Retained flag to false in the MQTT CONNECT packet"; and, for the host application, "The MQTT retain flag for the Birth Certificate MUST be set to TRUE".
NDEATH is the one that surprises a reader who assumes data and death travel alike: the death certificate is registered as the MQTT will at CONNECT with QoS 1 and retain false, while every data and command message is QoS 0. NCMD and DCMD are QoS 0 by the specification, so a lost command is recovered by the command's own acknowledgement in the payload, not by the transport.
DDEATH is the row with Chosen here: yes, and it is the only Sparkplug row that carries no rule
id. The v3.0.0 rule-id set contains QoS and retain rules for NBIRTH, DBIRTH, NDATA, DDATA, NCMD,
DCMD, the NDEATH will, and the STATE will, and none for DDEATH. Absence of a rule is not
permission to leave the value undeclared, so twinflow publishes DDEATH at QoS 0 with retain
false, matching its sibling device messages, and conformance.yaml records the row with
rule_id: null and basis: repo choice, no v3.0.0 rule. INV-QOS-2 asserts that every row whose
rule_id is null carries a written basis, which stops a future reader mistaking a repo choice
for a specification requirement.
The normative values per rule id live in schemas/iot/sparkplug/conformance.yaml as
{rule_id, topic_class, qos, retain} rows rather than scattered in code, and the config table
above is checked against them at load. INV-QOS-1 asserts that every configured
transport.qos_by_topic_class entry matches its conformance.yaml row, or names in
transport.conformance_departures the rule id it departs from. A departure with no named rule id
is a config error. The TCK is the arbiter for the normative half (7.4). See open question 9.1.
QoS 1 behavior is exercised in two places rather than by breaking conformance silently. S-IOT-15
runs the same fleet and seed with the UNS mirror at QoS 0 and at QoS 1, asserts the business event
stream is identical under the documented normalization, and reports the measured redelivery count
at QoS 1, which is the number the mqtt_redelivery fault mode inflates. A second arm of the same
scenario declares a departure on tck-id-payloads-ddata-qos, publishes DDATA at QoS 1, and
asserts that ConformanceMatrix marks the run non-conformant and names the rule. Exercising QoS 1
and claiming Sparkplug conformance at the same time is the mistake that arm exists to catch.
5.5 UNS path algebra¶
One value object, two renderings, proven equal by round trip. UnsPath validates each segment
against ^[a-z0-9][a-z0-9-]{0,31}$ at construction, which is what makes both renderings safe:
no segment can contain a topic wildcard or a delimiter. Constructing a path with an illegal
segment raises with the offending segment named.
UnsPath.from_resource and UnsPath.to_resource connect it to the ResourceSelector grammar
in 3.9, so the dotted resource tree and the slash-delimited namespace are two views of one
hierarchy rather than two hierarchies. INV-UNS-1 covers all four directions.
5.6 Store-and-forward (component 6c)¶
A fixed-capacity ring buffer in RAM, sized in bytes, holding serialized records. One
implementation in twinflow-buffer, two capacities.
cap_bytes default 65536 for a constrained device, 4 MiB for a gateway
drop_policy oldest | newest default oldest
high_water_pct 80 -> publishes a warning metric
replay_rate_msgs_s 200 -> paced replay, so a recovering fleet does not flood the broker
While ONLINE, records are published directly. A publish that fails locally (no link, TCP
error, broker refused) pushes the record into the buffer and moves the node to BUFFERING. On
overflow the buffer drops per policy and increments sf_dropped_records, and the counter is
published, so downstream analysis knows exactly how many records are missing and over what
interval. On reconnect, replay is paced at replay_rate_msgs_s and interleaved with live data.
Per-channel FIFO order is preserved (INV-SF-3). Replayed records keep their original device_ts
and their original clock_epoch_id, carry replayed: true, and take a fresh ingest_ts with
buffer_latency_s filled in.
Global order across channels is not preserved and is not claimed. Ordering within a channel is
(device_id, clock_epoch_id, device_ts) per 4.4, and ordering across producers is the D-07
canonical total order, so a replayed record lands in its true position rather than after the
fresh data that arrived while it was buffered.
A gateway (tier 1) uses SpillingStoreAndForward, which spills to disk beyond RAM, because a
gateway has a filesystem and a constrained device does not. Same interface, different
cap_bytes, plus a spill_path.
The disagreement between "Sparkplug data is QoS 0" and "kill the broker and lose nothing" is resolved here, not by transport. When the broker dies the TCP connection breaks, the local publish fails, and the record goes to the buffer. The guarantee comes from the buffer plus session sequence numbers, not from QoS. ARCHITECTURE.md states that plainly beside the QoS matrix in 5.4, which is worth more than raising the QoS would be.
5.7 The time gate¶
A device with no RTC boots at the Unix epoch. Two things break: TLS certificate validation fails
notBefore, and any token with an iat or exp is nonsense. Both failures are misdiagnosed as
certificate or credential problems by everyone who has not seen it before.
Rule: while clock.now() < security.epoch_floor, the agent will not start a TLS handshake, will
not mint or present a bearer token, and will not publish. It sits in TIME_SYNC_WAIT, runs SNTP
with its own backoff, and publishes iot.device.timegate.v1 on entry and exit.
The shipped default floor is a fixed ISO8601 date recorded in facility.yaml, not the firmware
build timestamp. D-02 permits a wall-clock read in exactly four places, and none of them is a
config default that reaches an event payload; iot.device.timegate.v1 carries floor_epoch_s
and is in the hashed tape, so a build-time wall-clock value there would make two runs of the
same seed differ. A fixed date also has a plainer virtue: 499 of 500 simulated Python devices
have no firmware build timestamp at all, so a build-timestamp default is undefined for almost the
whole fleet.
security.epoch_floor: build_timestamp remains available and is refused by config validation
unless simulation.mode is production, which covers the HIL path and a real deployment. In
that mode floor_origin is build_timestamp, the value is written to the provenance sidecar
(D-01), and the run manifest already records deterministic: false for any HIL run (5.26).
The gate has a ceiling too. epoch_ceiling_years (default 20) refuses an absurd future clock,
which is what a corrupted RTC produces.
INV-TG-1 asserts no handshake and no token mint below the floor, for any clock trajectory. A mutation that removes the gate fails the test, which is the point.
The determinism carve-out is enumerated rather than described. DETERMINISM_EXCLUDED_FIELDS is
a frozen constant in the determinism harness listing every field excluded from the hash, and
INV-DET-4 asserts that the constant contains no field any schema marks x-simtime: true and
that every field it does contain is marked x-wallclock: true in its schema. A field cannot be
excluded from the hash without saying so in the schema, and a sim-time field cannot be excluded
at all.
5.8 Full-jitter exponential backoff¶
Base 500 ms, cap 60 s. The retry counter resets on a successful CONNACK plus stability_s
(default 30) online, never on CONNACK alone. Resetting on CONNACK alone is the defect that
produces a fast reconnect loop against a broker that accepts and immediately drops.
Full jitter, not equal jitter and not decorrelated jitter. Marc Brooker's AWS Architecture Blog article "Exponential Backoff And Jitter", dated 04 March 2015, retrieved 2026-08-09 from https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/, HTTP 200, compares the three. It reports that equal jitter "does slightly more work than Full Jitter, and takes much longer", and that against decorrelated jitter "The 'Full Jitter' approach uses less work, but slightly more time". Full jitter is chosen for the lower client work, and the article does not claim it is also fastest. The alternative forms are recorded in the module docstring with the reason for the choice, and VG-IOT-14 reproduces the comparison.
The property that matters is the fleet-level one: devices coming back after a broker restart
must not stampede. backoff.stampede_ceiling_per_100ms is a real config key with a derived
default of max(4, ceil(broker.max_inflight / 4)), and INV-BO-2 asserts the maximum connection
attempts in any 100 ms bucket over the reconnect window stays under it. A mutation guard asserts
the same test fails when jitter is removed.
5.9 Reboot forensics and crash loops¶
Every boot publishes iot.device.boot.v1 before anything else, including before the time gate
resolves, buffered if necessary.
ResetReason mirrors the ESP-IDF esp_reset_reason_t enumeration one to one. The enumeration
was retrieved 2026-08-09 from
https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/misc_system_api.html,
HTTP 200, and has sixteen members: ESP_RST_UNKNOWN, ESP_RST_POWERON, ESP_RST_EXT, ESP_RST_SW, ESP_RST_PANIC,
ESP_RST_INT_WDT, ESP_RST_TASK_WDT, ESP_RST_WDT, ESP_RST_DEEPSLEEP, ESP_RST_BROWNOUT,
ESP_RST_SDIO, ESP_RST_USB, ESP_RST_JTAG, ESP_RST_EFUSE, ESP_RST_PWR_GLITCH, and
ESP_RST_CPU_LOCKUP. The Python enum members are the same sixteen, lowercased with the
esp_rst_ prefix removed, so panic, int_wdt, task_wdt, pwr_glitch, cpu_lockup, and
the rest. The simulated devices produce the same enum, so the fleet layer has one code path.
VG-IOT-13 asserts the member sets match exactly, which is how a future ESP-IDF release adding a
cause is caught as a test failure rather than as a silent unknown.
Four derived fleet signals, each with the detector that raises it:
| Signal | Detection rule | Raised mode |
|---|---|---|
| Crash loop | boot_count rises by at least crash_loop_threshold (default 5) within crash_loop_window_s (default 600) with reset_reason in the fault set |
crash_loop, severity floor 8 |
| Brownout correlation | brownout_count increments inside brownout.tx_window_ms of a transmit event above brownout.tx_power_dbm_threshold |
power_supply_sag |
| Heap exhaustion | free_heap_min_bytes trends down across boots with reset_reason = panic |
heap_leak |
| Duplicate client id | bdSeq increments faster than iot.device.boot.v1 events arrive for the same edge_node_id |
duplicate_client_id |
The brownout model is stated as a relation with an assertion behind it, not as a story. The
simulated device draws I_tx from the configured transmit current curve and computes
V_rail = V_supply - I_tx * R_supply, and a brownout fires when V_rail falls below
brownout.threshold_v. All four constants are config keys with defaults labeled
"repo-defined modeling assumption, not a measurement", because no measured I-V curve for a
specific board is shipped here; open question 9.17 tracks getting one. INV-BRN-1 asserts that a
brownout event occurs only within brownout.tx_window_ms of a qualifying transmit event and
never in the absence of one, and test_brownout_needs_tx drives a device at zero transmit power
and asserts zero brownouts across a full sim day. The runbook's fix is supply decoupling, not
firmware, and it says so because the model says so.
Heap exhaustion earns its own signal because the distinction is what makes the runbook useful.
A panic with a flat free_heap_min_bytes trend is a code defect. A panic with a falling trend
across boots is a leak, and the fix is different.
Duplicate client id is the Sparkplug-native detection. Two edge nodes flashed with the same id
take the MQTT session from each other in a loop, and each takeover forces a new session without
a reboot, so bdSeq outruns the boot events. The fleet layer records this as
bdseq_without_boot and raises duplicate_client_id, which quarantines both. It is a real
failure that costs days to diagnose in the field, and 3.3 plus INV-SPB-1 make sure the repo's
own default config cannot cause it.
5.10 Hold-after-N¶
After hold_after_consecutive_failures (default 5) consecutive connect or publish failures, the
agent enters HELD for hold_duration_s (default 900) rather than rebooting. It runs the
diagnostic ladder once on entry, writes the result to NVS, buffers telemetry, and stays
reachable to the local network so an engineer can pull the bundle. It leaves HELD early on a
link-layer up event.
Rebooting on repeated failure is the default that produces a fleet of devices that are permanently rebooting and so permanently undiagnosable, and it also destroys the RAM buffer on every cycle. Holding preserves both the buffer and the evidence.
5.11 The diagnostic ladder¶
On any connect failure, run four rungs in order and record each with its latency. Each rung maps
to one call on the Network port (2.1), which is why that port exposes four calls rather than
one.
- DNS. Resolve the broker hostname through
Network.resolve. Failure classifies todns_nxdomainordns_timeout. - TCP. Connect to the broker port through
Network.tcp_connect. Atcp_refusedresult means something answered and said no. Atcp_timeoutresult means a middlebox silently dropped the SYN, which is the signature of an egress filter. - TLS. Complete the handshake through
Network.tls_handshake. Atls_unknown_caresult with a peer certificate whose issuer is not our CA means a TLS-inspecting middlebox is terminating the session. Atls_notbeforeresult means our clock, not our certificate. Atls_expiredresult means our certificate, not our clock. Atls_hostname_mismatchresult means config. Atls_handshake_timeoutresult means interception without a valid substitute chain. - MQTT. Read CONNACK through
Network.mqtt_connect. Amqtt_not_authorizedresult means the broker ACL refused our identity. Amqtt_identifier_rejectedresult points at the duplicate-client-id case.
The classifier stores the observed peer certificate issuer on a tls_unknown_ca result in the
peer_issuer field, because the issuer string names the middlebox vendor and turns a week of
argument into one screenshot.
Each classification maps to exactly one runbook signature (E48), so the ladder result is
directly actionable. 7.1 holds one unit test per classification value, each driven by a
Network fake configured to fail at that rung.
5.12 Fault injection and the ground-truth firewall¶
FaultInjector reads the materialised fault schedule that
docs/design/variability-and-faults.md C.2 defines, and never samples fault arrivals inside the
run loop. Activation publishes iot.fault.injected.v1 with the exact sim instant and the
detectable_from_sim_ts the schedule computed.
The full field-realistic fault set, mapped both to the source's list and to the settled fault catalog so the two documents use one vocabulary:
| Source phrase | Modes in this section | Catalog ids |
|---|---|---|
| dropped connections | link_flap, dhcp_lease_loss, wifi_roam, broker_restart, middlebox_idle_kill |
F-DEV-DROP, F-NET-BROKER, F-IT-CHANGE |
| clock drift | clock_drift_ppm, sntp_unreachable, clock_step, rtc_corrupt |
F-DEV-CLOCKDRIFT |
| duplicate reads | tag_multiread, mqtt_redelivery, cross_read |
F-DEV-DUPREAD, F-NET-REDELIVER, F-DEV-GHOST |
| crash loops | watchdog_reset, panic_loop, heap_leak, brownout_crash_loop |
F-DEV-CRASHLOOP |
| degraded read rates | degraded_read_rate, plus the E46 physical causes that produce the same symptom |
F-DEV-READRATE |
| sensor drift | linear_drift, random_walk_drift, arrhenius_drift, zero_offset_creep |
F-DEV-DRIFT |
| stuck-at faults | stuck_at_last, stuck_at_rail, stuck_at_nan |
F-DEV-STUCK |
| dropout | sample_dropout (per-sample Bernoulli), burst_dropout (geometric run length), channel_mute (whole channel, timed) |
F-DEV-DROP at channel scope |
| calibration loss | calibration_slope_loss, calibration_offset_step, linearity_loss |
F-DEV-CALIB |
| noise floor rise | connector_noise_up, emi_burst |
F-DEV-NOISEUP |
| lifecycle failures | bad_firmware_image, cert_expiry, provisioning_storm |
F-DEV-FIRMWARE, F-DEV-CERTEXPIRE, F-DEV-PROVISION |
| transport degradation | buffer_overflow, uplink_latency_inflation, out_of_order_delivery |
F-NET-BUFOVERFLOW, F-NET-LATENCY, F-NET-REORDER |
| (added, field-realistic) | nvs_config_corruption, duplicate_client_id, antenna_cable_degradation, power_supply_sag, flash_wear_write_failure |
F-DEV-CRASHLOOP, F-SEC-SPOOF, F-DEV-DRIFT |
Completeness against the settled catalog is a test, not a claim. Every id in
docs/design/variability-and-faults.md C.4 whose Subsystem column names a device, a gateway, a
broker, ingest, a network, or auto-ID is owned by this section and must appear in the table
above. INV-CAT-6 reads C.4 and this table and fails when an owned id is missing from either one.
Three C.4 ids are deliberately not owned here and the test knows their owners: F-SEC-RANSOM
belongs to the IT and security section, and F-CV-OCCLUSION belongs to the computer-vision
section. Both are named in the test's exclusion list with the owning section, so an exclusion is a
recorded decision rather than an omission.
Dropout is a first-class injectable mode, not a parameter buried inside one signal model.
Component 2b lists drift, stuck-at, dropout, and calibration loss as the four failure signatures
every sensor class must carry, so all four are catalog-declarable on any channel, all four have
a runbook template, and INV-CAT-2 asserts every declared mode is reachable by the injector and
produces a runbook. test_every_class_has_four_signatures asserts that each of the twelve
signal-model kinds has at least one shipped catalog entry declaring each of the four.
The ground-truth firewall is specified against the storage stack this repo locks in, which is delta-rs plus DuckDB. Neither has database roles or grants, so a firewall written in terms of grants would be a firewall that never runs.
Two Delta table roots, and the separation is physical:
warehouse/historian/ readings, device events, fleet events, RF reads
warehouse/groundtruth/ iot.telemetry.groundtruth.v1, iot.fault.injected.v1,
iot.rfid.read.groundtruth.v1, rul series
The agent-facing DuckDB session is constructed by twinflow.historian.agent_session() from a
fixed catalog of attached read-only views. That function takes an allowlist of view names,
attaches each one against a table under warehouse/historian/ only, sets
enable_external_access off so no read_parquet or delta_scan call can reach an unattached
path, and never puts the ground-truth root on the search path. There is no code path in the
agent session that names the ground-truth root, and there is no configuration that adds one.
INV-GT-1 is testable against that mechanism. The test opens an agent session, enumerates every
schema, table, view, and column reachable from it through duckdb_tables() and
duckdb_columns(), and asserts that no object resolves under warehouse/groundtruth/, that no
column named clean_value, fault_tags, fault_instance_ids, or ground_truth appears, and
that a direct SELECT * FROM delta_scan('warehouse/groundtruth/...') raises rather than
returning rows. A second assertion walks the agent tool surface and fails on any tool whose SQL
template mentions the ground-truth root.
Without this, every detector and every agent answer is contaminated by the answer key, and every published accuracy number is a lie. The constraint is owned here and must be respected by the historian and agent sections.
5.13 Health scoring and MTTD (component 3)¶
For each active failure mode i on a device, four numbers and a confidence:
S_i comes from fleet/severity.yaml, defaulting to the catalog's severity. O_i comes from
fleet/occurrence_table.yaml, a deterministic band lookup on the measured incident rate for
that mode on that device type over a trailing window. D_i comes from
fleet/detection_table.yaml, a band lookup on MTTD_p90 / pf_interval for the detector that
raised it. RPN_i = S_i * O_i * D_i, in the range 1 to 1000. c_i is the detector's confidence
in the interval (0, 1].
active = sorted(active_modes, key=lambda m: m.mode_id) # D-03
health_score = 100 * PROD over active of (1 - min(1, RPN_i / 1000) * c_i)
The score is always recomputed from the sorted active set and is never updated incrementally.
Floating-point multiplication is not associative, so an unordered product would make the score
depend on iteration order and a set-backed active_modes would make it depend on hash
randomization, which D-03 forbids outright. active_modes is a list sorted by mode_id
ascending in the domain model, in fleet.health.scored.v1, and at the point of computation.
The product form satisfies the properties the fleet layer needs: bounded in [0, 100], monotone
non-increasing in each RPN_i, recoverable to within one unit in the last place when a mode
clears, and multiplicative so two independent problems compound rather than merely add.
INV-HS-1 through INV-HS-7 assert all of them, and two of those invariants are stated with the
tolerance that floating-point arithmetic actually supports rather than with the word "exactly".
Cold start is specified, because the score is needed at the moment it is least supported. At
t=0 of any run, no incident history and no MTTD distribution exist, yet 3.4's lifecycle
transitions read health_score immediately. The rule:
O_ifalls back tofleet/occurrence_prior.yaml, which holds one band per(mode_id, device_type)pair with a documented basis, until the trailing window holds at leastfleet.health.min_occurrence_observationsdevice-hours (default 168).D_ifalls back tofleet/detection_prior.yaml, keyed on(detector_id, mode_id), until at leastfleet.health.min_detection_eventsmatched detections exist (default 10).- While either prior is in use,
c_iis multiplied byfleet.health.prior_confidence_penalty(default 0.5), andfleet.health.scored.v1carriesprior_usednaming which of the two. - The tables are computed within-run by default.
fleet.health.carry_history(default false) carries them across runs, and config validation refuses it whenever the run is part of a hash comparison, because a carried table is state outside the seed.
INV-HS-7 asserts that a device with zero history has a defined health_score in [0, 100] and
that prior_used is populated, and S-IOT-16 asserts it end to end at t=0 across a whole fleet.
Two rating methods ship, selected by fleet.fmea.method: rpn | action_priority. The reason for
the second one is arithmetic and needs no citation: the product S * O * D maps 10 x 2 x 5 and
2 x 10 x 5 to the same 100, and a severity-10 mode is not the same risk as an occurrence-10 mode.
test_rpn_collides_on_ordered_triples enumerates the 1000 rating triples, reports how many
distinct triples share each RPN value, and is the demonstration rather than an assertion.
The Action Priority alternative is attributed rather than asserted. The AIAG and VDA FMEA
Handbook, first edition, 2019, is the reference commonly named for replacing the risk priority
number with an Action Priority lookup on the ordered triple (S, O, D) that yields High, Medium,
or Low. That handbook is a paid publication, the AIAG page for it returned HTTP 404 on
2026-08-09, and no copy of its table was retrieved for this spec, so the attribution is stated in
text and no published AP value is reproduced here. fleet/ap_table.yaml ships as a repo-defined
table with its own documented derivation rule, is labeled as repo-defined in the file header and
in every report that prints it, and is replaceable by an adopting organization with its own
licensed copy. Open question 9.3 tracks whether the published table can be reproduced lawfully,
and until it is answered the repo makes no claim that its table matches the handbook's.
MttdAccountant pairs iot.fault.injected.v1 with fleet.fault.detected.v1 using a matching
window and a mode compatibility rule. The outcomes are true positive (latency recorded), miss
(an injection with no match by end of run, right-censored), and false positive (a detection with
no match). A detection earlier than detectable_from_sim_ts is recorded as leakage and fails
the run, per docs/design/variability-and-faults.md C.3.
fleet.mttd.v1 always carries detection_rate and censored_n alongside mttd_mean_s.
Reporting a mean over detected faults only, without the detection rate, is the standard way this
metric lies, and INV-MTTD-1 asserts both fields are present and non-null in every emission. Mean
MTTD per fault class goes on an I-MR chart through the LSS engine; the distribution (p50, p90,
max) is what the runbook carries.
Degraded flagging follows 3.4: a threshold with a 5-point hysteresis band and a dwell time.
5.14 Predictive maintenance¶
Runs on any channel with a degradation block. The conveyor motor temperature, vibration, and
current channels are the phase-3 target named in the source.
- Duty-cycle correction. Raw motor temperature is dominated by whether the motor is running.
DutyCycleRegressionfits an ordinary least squares model of the reading against a design matrix of twin-state indicators (running, idle, faulted, speed band, load band) and subtracts the fitted value, so the residual is the health signal rather than the schedule. Without this step the trend test measures shift patterns. This is the estimator VG-IOT-09 gates. - Feature extraction per window: mean, RMS, kurtosis, and crest factor for vibration; band energies at 1x, 2x, and 3x running speed and at the four bearing defect frequencies computed from the bearing geometry:
FTF = (fr/2) * (1 - (Bd/Pd) cos a)
BPFO = (Nb/2) * fr * (1 - (Bd/Pd) cos a)
BPFI = (Nb/2) * fr * (1 + (Bd/Pd) cos a)
BSF = (Pd/(2*Bd)) * fr * (1 - ((Bd/Pd) cos a)^2)
with Nb ball count, Bd ball diameter, Pd pitch diameter, a contact angle, and fr
shaft rate, all catalog fields on the attached bearing.
- Trend detection with Mann-Kendall for the presence of a monotone trend. It is nonparametric,
makes no distributional assumption, uses the exact null for small n, and uses the normal
approximation with tie correction for large n. Theil-Sen supplies the slope and its
confidence interval, and it resists the outliers vibration data always has.
OrdinaryLeastSquaresruns on the same series as the parametric comparator, and the bakeoff publishes both slopes so robustness is shown earning its place. - Time to threshold:
tt = (limit - level_now) / slope, with the interval propagated from the Theil-Sen slope CI. Because the slope CI can straddle zero, the estimate is reported as an interval that can be unbounded, and the published event says so rather than printing a false point estimate. The demo sentence the source asks for becomes: "motor 2 bearing trend crosses the alarm limit in about 9 days, 95% interval 6 to 14 days." - EWMA on the duty-cycle-corrected residual for change detection on the same series, giving a second, faster detector with a different failure profile.
- Escalation to Isolation Forest on the multivariate feature vector, then to a learned RUL regressor, each entered only if it beats the statistical baseline on the labeled synthetic incident corpus. The comparison table is published either way, per the locked decision. Scoring is on PR-AUC, MTTD p50 and p90, false alarms per device-week, and prognostic horizon (how early a true positive fires relative to the P-F interval).
- Emission of
pdm.prediction.v1, and whenttfalls inside the maintenance planning horizon,maintenance.workorder.requested.v1with the runbook reference attached.
Every learned component is deterministic or it does not steer the run (D-04). The Isolation
Forest and the RUL regressor take random_state derived from the model.train.<model_id>.seed
stream in 5.29, run single-threaded with a pinned model artifact hash, and reach the run through
the kernel Inference port so simulation mode can bind a recorded-response adapter. A model
that cannot meet that contract becomes an advisory output written to the provenance sidecar and
does not enter the tape.
5.15 mTLS and device identity (component 6c)¶
An internal two-level CA. twinflow-root-ca is offline and long-lived and signs
twinflow-device-ca (which issues device certificates) and twinflow-infra-ca (broker and
gateway server certificates). A separate twinflow-manufacturing-ca signs birth credentials.
The operational device certificate profile:
- Subject
CN={device_id}, SAN URIurn:twinflow:device:{device_id}. basicConstraints: CA:FALSE,keyUsage: digitalSignature,extendedKeyUsage: clientAuth.- Validity 90 days, renewal at 60 days (2/3 of lifetime), with an overlap window in which both certificates validate.
- The key is generated on the device. The private key never crosses the network, at enrollment or at renewal. The device sends a CSR.
Broker ACL is derived from the certificate, not from IP and not from username. A client
authenticated as CN=portal-dock3-a may publish only under the Sparkplug identifiers and mirror
topics that resolve from that device's registered UnsPath, and may subscribe only to its own
NCMD and DCMD topics. The ACL file is generated from the registry, so it cannot drift from the
fleet. A test asserts that a client presenting device A's certificate is refused when publishing
to device B's topic.
Revocation uses a CRL published by the CA and reloaded by the broker on a schedule. The revocation test revokes, reloads, and asserts the connection is refused.
The reason identity is certificate-based rather than IP-based or username-based is demonstrated
rather than argued: the dhcp_lease_loss fault changes a device's address mid-run, and the
certificate-based ACL is unaffected while an IP-based ACL would break. That is S-IOT-11, a
two-line chaos scenario that makes the design argument better than a paragraph would.
5.16 Edge tiers (E36)¶
| Tier | Location | Runs | Latency budget |
|---|---|---|---|
| 0 | on device | deadband filtering, debounce, on-device RMS aggregation, threshold alarms | 10 ms |
| 1 | gateway per area (NanoMQ + twinflow-edge) |
protocol translation, store-and-forward, aggregation, local inference (EWMA residual, safety interlocks), UNS bridging, later the E32 SLM | safety interlock 100 ms, anomaly flag 1 s |
| 2 | site (EMQX, historian, twin sync) | historian writes, twin recalibration, SPC evaluation, UNS mirror publication | 10 s |
| 3 | network (planning, MEIO, hosted LLM) | forecasting, optimization, agent | no budget |
The 100 ms and 1 s numbers are the source's own and are config keys (edge.latency_budget_ms).
Two different things are checked against them, and conflating them would produce a gate that
cannot fail.
The simulation-mode check is a model consistency check, named IC-IOT-05, not a performance gate.
In simulation mode the latency of a decision is the sum of the modeled stage costs the config
supplies, so comparing it against a budget from the same config proves only that the arithmetic
is right. IC-IOT-05 asserts exactly that and no more: the modeled p99 for each function class
equals the sum of its declared stage costs to within 1e-9, and edge.tier.decision.v1 carries
latency_source: model. What would falsify it is a placement table whose declared stage costs
do not add up to the latency the model reports, which is a real defect and a real failure mode
of a hand-maintained placement table.
The performance gate is BM-IOT-01, and it measures. It runs the actual tier-0 and tier-1 code
paths, with no simulated stage costs, over a fixed workload of 10,000 samples per function
class, on stated hardware named in the artifact. It writes artifacts/edge/bench_{date}.json
with the p50, p95, and p99 per function class, the hardware description, the git SHA, and the
dependency lockfile hash, the same way 5.26 commits the HIL self-test. It fails when a p99
exceeds its budget on the reference runner, and it reports rather than fails on any other
runner, because a loaded shared runner cannot falsify a latency claim. The committed artifact is
what the README cites.
BM-IOT-01 reads a real monotonic clock, and D-02 permits that only because the benchmark harness
is not the simulation. The harness runs outside any run, its readings never enter an event
payload or the hashed tape, and no simulation control decision consults them. The one crossing
point is edge.tier.decision.v1, which carries latency_source: model inside a run and
latency_source: benchmark only in the artifact the harness writes, never in a recorded run's
event stream. test_benchmark_timings_never_enter_the_tape runs the determinism scenario with
the benchmark harness enabled and disabled and asserts the two event-log hashes are equal.
The production-mode number is evidence, not a gate. OpenTelemetry spans in production mode
produce the same three percentiles, and just edge-report writes them to
artifacts/edge/otel_{date}.json with the deployment described. edge.tier.decision.v1 carries
latency_source: benchmark for those.
facility.yaml carries an edge.placement table assigning every function to a tier with a
written rationale. PlacementReport renders it to artifacts/edge/placement_report.md.
E36 asks for the compute-placement table to sit in ARCHITECTURE.md beside the ISA-95 map, so it
does. ARCHITECTURE.md carries the table between two marker comments, just edge-report
regenerates the block from artifacts/edge/placement_report.md, and
test_architecture_placement_block_is_current asserts the sha256 of the block in
ARCHITECTURE.md equals the sha256 of the generated report. Generating the artifact is the better
engineering choice and transcluding it into ARCHITECTURE.md is what the requirement asks for;
doing both, with a checksum test, is the way to have neither drift.
5.17 Measured edge economics¶
Three quantities, and each one states whether it is measured or computed, because two of them were presented as measurements when they are arithmetic.
Bandwidth has a measured half and a computed half. The measured half is wire_bytes: the actual
serialized length of every Sparkplug frame LinkAccountant sees crossing a tier boundary,
summed per window, plus raw_wire_bytes_equivalent, which is the actual serialized length of
the same window's records encoded one per frame with no deadband and no aggregation. Both
numbers come from the codec, both are bytes a packet capture would agree with, and their ratio
is the compression ratio the README cites. The computed half is deadband_suppressed and
aggregation_reduced, which are counts derived from the same config that produced the
filtering; they explain the ratio and they are labeled as bookkeeping, not as measurement.
BM-IOT-02 is the gate over the measured half, and it fails when the wire-byte total disagrees
with the sum of the frame lengths the transport adapter recorded.
bytes_per_finding divides wire_bytes crossing tier 1 to tier 2 by the findings produced from
that window. It is a ratio of two measured counts.
Latency reports p50, p95, and p99 per function class per tier, from IC-IOT-05 in simulation mode and from BM-IOT-01 and the production OpenTelemetry spans as evidence, exactly as 5.16 splits them.
Degradation is exercised, not described. The gateway_partition scenario cuts tier 1 from tier
2 and produces edge.degraded.v1 listing retained capabilities (safety interlocks, local
anomaly flagging, local buffering, the line keeps running) and lost capabilities (SPC
evaluation, twin sync, agent answers, cross-area correlation). DegradationReport generates the
lists from the placement table rather than from prose, so they stay true as the system grows.
The tier-0 deadband and tier-1 aggregation policies are the levers. Raw high-rate vibration is
retained locally in a ring for local_retention_hours, only window statistics cross the
boundary, and the raw window is fetchable on demand when a finding needs it. That is what real
historians do and it is why the bandwidth number is large. S-IOT-13 measures the suppression
rate and the wire-byte ratio on a fixed workload and writes both to the artifact.
5.18 Zero-touch provisioning (E44)¶
- The device boots with a birth credential in NVS: an X.509 certificate signed by
twinflow-manufacturing-cawithCN={serial}. For the ESP32 this is written at flash time byjust hil-flash. - The time gate passes.
- The device connects to the provisioning endpoint over mTLS presenting the birth certificate.
ProvisioningAuthoritychecks that the chain validates to the manufacturing CA, thatserialis onfleet/allowlist.yaml, that the serial is not already enrolled with a different key, that the enrollment nonce is unused, and that the birth certificate is not revoked.- The device generates a keypair locally and sends a CSR.
- The authority issues the operational certificate, assigns a
UnsPathand anedge_node_idfromfleet/placement_policy.yaml(a rule set mapping serial ranges and device types to areas and lines), and returns the desired config document. - The device stores the certificate and config in NVS, reconnects to the operational broker, and publishes NBIRTH.
- The registry publishes
fleet.device.enrolled.v1. Zero human steps.
Every rejection path publishes fleet.enrollment.rejected.v1 with a distinct reason value and
a distinct runbook reference: serial_not_allowlisted, nonce_reused, birth_cert_revoked,
serial_key_mismatch (a cloned device), and clock_outside_gate. The enrollment nonce draws
from the fleet.provisioning.nonce stream (5.29), so a replayed run reproduces the same nonces
and the nonce_reused path is reproducible rather than incidental.
5.19 Desired versus reported reconciliation (E44)¶
The reconciler runs on every DBIRTH and on fleet.reconcile_interval_s. It diffs desired
against reported by JSON pointer, producing DriftItem records sorted by path. Each item is
aged, and items older than fleet.drift.grace_s become fleet.compliance.violation.v1 findings.
Four actions:
push is the default. It resends the desired document, increments desired_version, and waits
for the echo.
accept adopts the reported value as desired. It needs an authority tier and is written to the
decision register. Both exist before reconciliation is built: AuthorityTier lands in
twinflow-contracts at P1 with E5a's autonomy metadata, the append-only decision register lands
at the ECON phase with E21a, and E44's reconciler lands at P6-W1 after both. Config validation
refuses accept in any compliance policy while either is absent, and the reconciler treats a
policy that names it as a load error, so the action is never silently unavailable at runtime.
Section 8 holds the phase trace.
quarantine applies to paths flagged security_critical in the compliance policy: the
certificate fingerprint, the broker host, and the firmware hash.
unreconcilable applies after max_push_attempts. It stops pushing and raises a severity-8
finding. Pushing forever against a device that cannot apply the config is a loop that hides the
problem.
INV-DRIFT-1: reconciliation is convergent. Under repeated push with no external change, the
drift item count is non-increasing and reaches either zero or a stable unreconcilable set.
5.20 OTA campaigns (E44)¶
Campaign := {
campaign_id, target_firmware {version, sha256, signature},
selector, # device query: type, area, current version, health floor
rings: [Ring],
health_gate, rollback_policy,
created_by, authority_tier
}
Ring := {name, size_pct | device_ids, soak_duration_s, promote_criteria}
Default rings: canary (1 device or 2%, whichever is larger), early (10%), broad (50%),
fleet (100%).
Transfer is chunked with a per-chunk sha256 and a resume offset persisted in NVS, so a device that loses the link mid-download resumes rather than restarting. The signature is checked over the whole image before it is written to the inactive slot.
Apply writes to the inactive slot (A/B partitions), sets the boot partition, and reboots. The
new image boots with ota_pending_verify = true. It must reach ONLINE, publish NBIRTH, and
stay online for verify_soak_s before the agent marks the image valid. If it does not, the
bootloader rolls back to the previous slot on the next boot. This is the ESP-IDF rollback
mechanism and it is what makes a botched push survivable without a truck roll.
The promotion gate promotes a ring only if, over the soak window, the paired before-and-after
comparison of ring device health shows no regression beyond tolerance, the crash_loop_count
delta is zero, and the publish-rate ratio is within publish_rate_tolerance. The health
comparison is a paired statistical test, and 5.30 defines how the campaign engine gets one
without importing the LSS engine. ota.ring.gate.evaluated.v1 carries the statistic, the
p-value, and the effect size. The promote decision is a test result, not a threshold on an
eyeballed dashboard.
Rollback is automatic on gate failure. The campaign halts, the ring rolls back, and
ota.campaign.halted.v1 names the failing criterion.
KPIs: the adoption curve (percent on target version against sim-time), per-ring failure rate, time-to-fleet-consistency, rollback count, bytes transferred, and bytes transferred per successfully updated device, which exposes the cost of retries.
INV-OTA-1: at every instant, every device's active firmware is either the campaign target or a version in the campaign's declared rollback set, never an unknown version.
INV-OTA-2: a device is in at most one active campaign.
5.21 RF link budget (E46)¶
Forward link (reader to tag), the one that usually limits:
P_tag(dBm) = P_tx + G_ant(theta, phi) - L_cable + G_tag(theta_t, phi_t)
- PL(d) - L_pol - L_occlusion + fading
The tag activates when P_tag >= tag.sensitivity_dbm. Reverse link (tag backscatter to reader):
A read succeeds on a round when both margins are non-negative and the round is not lost to collision.
Path loss uses an exponent model PL(d) = PL(d0) + 10 n log10(d/d0), with n = 2 reducing to
free space. The free-space case is checked against the closed form (IC-IOT-02), which is an
internal consistency check and is listed as one, because a model compared with its own analytic
solution is not external validation. A rack aisle uses n in the 2.5 to 3.5 range from config,
and the configured n carries the source string
"repo-defined modeling assumption, not a measurement".
L_pol is 3 dB for a circularly polarized reader antenna against a linearly polarized tag, and
-20 log10(abs(cos(psi))) for linear against linear at relative angle psi, saturating at
pol_null_floor_db so a perfect null does not produce infinity. The 3 dB figure is the
polarization loss factor of 0.5 that Balanis gives for a circularly polarized wave incident on a
linearly polarized antenna, in "Antenna Theory: Analysis and Design", 4th edition, 2016, section
2.12. That text is a paid book and was not retrieved for this spec, so the citation is carried
at single-source confidence with the attribution stated in text, and VG-IOT-15 checks the two
limiting cases against it rather than against hand arithmetic.
L_occlusion comes from TagPose.occluding_material and depth_in_pallet. The shipped
defaults are none: 0.0, cardboard: 0.5, liquid: 6.0, and metal: 20.0 dB per layer, and
every one of them carries the source string
"repo-defined modeling assumption, not a measurement", because no published measurement
series for these materials at 900 MHz was retrieved for this spec. Open question 9.15 tracks
replacing them with measured values and naming the source. What the model does claim is
ordering, not magnitude: INV-RF-7 asserts none < cardboard < liquid < metal and that read rate
falls monotonically in depth_in_pallet, which is the part a reader can believe and the part
a test can prove. A pallet of bottled product reading worse than a pallet of empty totes is an
ordering claim, and ordering is what is asserted.
Fading is a Rician draw per inventory round, with the K factor from config. Redrawing per round is what makes multiple rounds help, which is the physical reason dwell time matters.
5.22 Read probability¶
For a tag passing a portal:
dwell_s = zone_length_m / pallet_speed_mps
rounds = floor(dwell_s * effective_round_rate_hz)
p_round = P(both margins >= 0 under the fading draw)
p_read = 1 - (1 - p_round) ** rounds
effective_round_rate_hz is reduced by singulation collision. The singulation model implements
the slot counter the EPC Gen2 air interface defines, so its parameters are the specification's
parameters and not invented ones.
The primary text is the GS1 EPC Radio-Frequency Identity Generation-2 UHF RFID Standard, Release
3.0.1, Ratified, February 2026, retrieved 2026-08-09 from https://ref.gs1.org/standards/gen2/,
HTTP 200. Three facts from it fix the model. Annex J.1 states that each tag carries a 15-bit slot
counter, that on a Query or QueryAdjust a tag loads a value between 0 and 2^Q - 1 drawn from
the tag's own random number generator, and that Q is an integer in the range 0 to 15. Clause
6.3.2.10 repeats the same range rule at each Query variant. Annex D, Figure D-1, gives the
interrogator-side adaptation loop as informative guidance: start at Qfp = 4.0, issue
Query(round(Qfp)), then set Qfp = max(0, Qfp - delta) on zero replies, leave Qfp unchanged
on exactly one reply, and set Qfp = min(15, Qfp + delta) on more than one reply, with typical
delta in the open interval 0.1 to 0.5 and smaller delta at larger Q.
The frame is 2^Q slots, never an arbitrary integer, which is why this section says
"one Q step" rather than "a factor of two" and means the same thing. With T tags in the zone
the expected slots per successful singulation grows with T, so the per-tag round rate falls
roughly as 1/T once T exceeds 2^Q. That is the reason a 40-case pallet reads worse than a
5-case pallet at the same speed, and it is the effect most read-rate models omit.
The claim carries two gates because it has two halves and they have different evidence.
VG-IOT-11 covers the half with primary text. It drives the implemented interrogator adaptation
loop against the Annex D transition rules over a scripted reply sequence and asserts the Q
trajectory matches the figure exactly, that Q = round(Qfp) is clamped to 0 through 15, and that
a tag's loaded slot value never leaves 0 through 2^Q - 1 over 10^6 seeded draws. It is
falsified by any Q outside the range, any slot value outside the range, or any transition that
moves Q in the direction the figure does not name.
VG-IOT-12 covers the half whose reference is blocked. Schoute reports the maximum normalized
throughput of framed slotted ALOHA as 1/e, about 0.368, attained when the frame size equals the
number of contending tags, in "Dynamic Frame Length ALOHA", IEEE Transactions on Communications,
volume 31, number 4, 1983, pages 565 to 568. The paper is behind a publisher paywall and the DOI
resolver returned HTTP 202 with an empty body on 2026-08-09, so the citation is carried at
single-source confidence with the attribution stated in text. The gate sweeps Q from 0 to 9,
which is frame sizes 1 to 512, at tag counts 1 to 512, and requires a maximum normalized
throughput within 0.010 of 0.368 attained at a Q whose 2^Q is within one Q step of the tag
count. The noise floor is measured, not assumed: the same sweep runs at 200 replications, the
standard error of the maximum is written to the artifact, and the gate fails its own
preconditions if that standard error exceeds 0.010. What falsifies the gate is a measured maximum
outside 0.358 to 0.378, or a maximum attained more than one Q step from the tag count, either of
which means the slot-selection model is wrong. INV-RF-4 (monotone in tag count) remains, and it
is a sanity property rather than the evidence for this claim.
Interference reduces p_round two ways. A raised noise floor from local drives lifts the
required receive margin. Reader-to-reader interference applies when two portals' zones overlap
in time and frequency, and dense_reader_mode mitigates it. The model represents dense-reader
mode as a round-rate reduction rather than a probability reduction, because dense-reader time
and frequency division costs rounds.
Miss cause attribution records the binding constraint when a pass produces no read. MissCause
is chosen by which term of the budget failed first: OUT_OF_RANGE, ORIENTATION_NULL,
OCCLUSION, COLLISION, SPEED, or INTERFERENCE. Without the cause, "4% missed" is a
number. With it, it is a fix.
5.23 Cross-reads¶
A cross-read is a tag physically at portal A that is read by portal B. It is generated from the
geometry: for every tag pose, the model evaluates p_read against every portal whose read
volume contains the pose, not only the intended one. iot.rfid.read.v1 carries the reading
portal, and iot.rfid.read.groundtruth.v1 carries the true portal and the is_cross_read flag.
Cross-reads are a false-positive class, not a duplicate class, and deduplication cannot fix them
because the read is genuine and simply attributed to the wrong location. The modeled
mitigations are all available as what-ifs: lower transmit power, re-aim the antenna, add
shielding between adjacent doors, or gate acceptance on a presence signal such as a photo-eye or
the trailer_presence_loop channel so reads are accepted only while a pallet is physically in
the zone. The last one is the fix that works in the field, and having it in the model means the
what-if can prove it.
5.24 Antenna placement as a what-if¶
PlacementProblem exposes antenna position, boresight azimuth and elevation, transmit power,
and conveyor speed as a search space, with regulatory ceilings as hard constraints. The
objective is multi-objective: maximize read rate, minimize cross-read rate, minimize throughput
cost from slowing the line, subject to a cost budget for added antennas.
The search is seeded (D-04). The sampler takes its seed from the
search.placement.<portal_id>.sampler stream in 5.29, runs with a fixed number of workers, and
is bounded by a deterministic trial budget rather than by wall-clock seconds. Without that,
whatif.rf.reaim_antenna would return a different answer every run and S-IOT-06's
statistically-significant-delta assertion would be flaky. iot.rf.placement.evaluated.v1
carries search_stream_name so the search that produced a candidate is reproducible from the
log alone.
The what-if surface the agent calls:
whatif.rf.add_antenna(portal_id, position, boresight, power)whatif.rf.reaim_antenna(antenna_id, azimuth, elevation)whatif.rf.set_power(antenna_id, dbm)whatif.rf.set_conveyor_speed(line_id, mps)
Each returns read-rate delta, cross-read delta, downstream throughput delta from the twin, and cost, with a hypothesis test on the before-and-after read-rate samples obtained through the seam in 5.30. This is the source's own example, "dock 3 misses 4% of reads: add an antenna, re-aim the existing one, or slow the conveyor", answered with numbers.
5.25 Physics problem versus device problem¶
The diagnosis the source names as the field skill. Read rate by portal goes on a p-chart, which is the correct chart for a proportion and is chosen by the LSS engine's data-type selector. When a portal drops below its LCL, the classifier decides between two causes:
| Signal | Physics problem | Device problem |
|---|---|---|
| Read rate conditioned on tag orientation | drops only in specific orientation bands | drops uniformly |
| RSSI distribution of successful reads | shifts (the marginal tags are the ones lost) | unchanged (fewer reads, same strength) |
| Per-antenna-port read share | shifts toward one port | uniform decline across ports |
| Antenna VSWR channel | unchanged | rising (cable or connector) |
| Correlation with pallet build or conveyor speed | strong | none |
| Reader temperature, reboot count | unchanged | elevated |
| Neighboring portal read rates | may rise (cross-reads absorbed) | unchanged |
The classifier is a documented decision rule over those seven signals, published as code in
twinflow.rf.diagnose, with its outputs scored against the ground-truth fault injections.
Shipping the rule and its measured accuracy is the differentiator; asserting that the diagnosis
is possible would not be.
Adding antenna_vswr as a first-class channel on the portal reader type is what makes the
fourth row possible. Real readers report per-port return loss, and a rising VSWR is a cable or
connector problem, not an RF-environment problem.
5.26 Hardware in the loop (E47)¶
The physical device is the same Rust crate built with --features esp32. It enrolls through the
ZTP flow in 5.18, publishes into the same UNS beside the simulated peers, receives OTA campaigns
from the same engine, and appears on the dashboard with a physical: true badge and no other
difference.
Four rules make this honest.
- HIL forces production mode. A physical device cannot obey a virtual clock.
hil.enabled: trueimpliesmode: production, and the run manifest recordsdeterministic: false. No eval, no README number, and no golden file may come from a HIL run. This is stated in the run manifest so it cannot be forgotten. - The physical device publishes only. Its inbound command surface is restricted to
Node Control/Rebirthand OTA. It actuates nothing. A test asserts that any other DCMD to a device withphysical: trueis refused at the gateway. - CI cannot prove the hardware path. A nightly job builds the
esp32target and boots it under the Espressif QEMU fork, asserting it reachesTIME_SYNC_WAITand publishes a valid boot record. That proves the build and the early state machine, not the radio. The full hardware self-test (just hil-selftest) is run by a human against real hardware, and its output, with the date, the firmware hash, and the git SHA, is committed toartifacts/hil/. The README says exactly this. Claiming CI coverage of a physical device would be the kind of overclaim the rest of the repo exists to avoid. - The time gate earns its keep here. A real ESP32 with no RTC boots at 1970, and the gate is
what turns that from a confusing TLS failure into a logged, expected
TIME_SYNC_WAIT.
The CI cost of the esp32 target is priced in section 8 rather than assumed, because the
esp-idf toolchain is not free to provision on a hosted runner.
5.27 Runbook generation (E48)¶
A failure signature is sha256(mode_id, device_type, detector_id), truncated. On the first
occurrence of a signature the generator publishes a runbook. On later occurrences it updates the
statistics and bumps the version only when the change is material, where material is decided by
a hypothesis test on the MTTD shift rather than by any change at all.
Fields are composed from four sources. The symptom comes from the catalog's
detection_signature, rendered with the observed evidence window. The root cause comes from the
ground-truth fault mode, which only the simulation knows, and that is the single reason these
runbooks are better than hand-written ones: the root cause is recorded, not inferred. The fix
and prevent sections come from a per-mode template in catalog/runbook_templates/{mode_id}.md,
filled with device and config specifics. MTTD, S, O, D, RPN, and Action Priority come from the
fleet layer.
Output is artifacts/runbooks/{signature_id}.md in the author's field format (Symptom, Root
cause, Fix, Prevent, MTTD, S-O-D and RPN), plus a record in the CMMS analog, plus
fleet.runbook.published.v1. The agent cites the runbook by signature_id when the failure
recurs, which makes it a valid grounding source under E26(f).
The coverage test is the one that keeps the library complete as the catalog grows: for every failure mode declared anywhere in the catalog, injecting it produces exactly one runbook with all required sections non-empty. A new catalog entry with a new mode and no template fails CI.
5.28 Fleet configuration compliance audit (E48)¶
fleet/policy.yaml holds rules over reported device config:
rules:
- rule_id: fw_min_version
kind: version_at_least
path: /firmware/version
value: "1.4.0"
applies_to: { type_id: rfid_portal_reader_uhf }
severity: 6
remediation: schedule_ota
- rule_id: cert_expiry_window
kind: cert_expiry_gt_days
value: 21
severity: 8
remediation: renew_cert
- rule_id: keepalive_below_middlebox
kind: value_range
path: /transport/keepalive_s
max: 240
severity: 5
remediation: push_config
rationale: "Middleboxes commonly drop idle TCP at 300 s; keepalive must be shorter."
Rule kinds: required_key, forbidden_key, value_equals, value_in, value_range,
version_at_least, cert_expiry_gt_days, hash_matches, and cross_field.
The audit runs on a sim-time schedule and as a CI job (just fleet-audit), writing
artifacts/fleet/compliance_{run_id}.json and a markdown summary. Every violation becomes a
finding carrying device, rule, desired, reported, severity, and remediation.
The policy file is itself versioned, and changing it produces a diff report showing how many devices newly fall out of compliance before the change is applied. Tightening a policy and discovering afterward that 300 devices now alarm is a known way to cause an alarm flood, and the diff report is the mitigation.
5.29 RNG stream names¶
The mechanism, the derivation, and the naming grammar are fixed in
docs/design/variability-and-faults.md A.1 and A.2, and this section does not restate them. It
declares the stream names it owns, against that grammar, and registers them in the stream
registry described in A.4.
| Stream name template | What it draws |
|---|---|
sensor.<measurand>.<device_id>.noise |
fast noise on the clean signal |
sensor.<measurand>.<device_id>.drift |
the Wiener drift increment |
sensor.<measurand>.<device_id>.dropout |
per-sample dropout Bernoulli |
sensor.<measurand>.<device_id>.interval_jitter |
sample interval jitter |
provision.device.<device_id>.unit_offset |
gauge bias, drawn once before the clock starts |
provision.device.<device_id>.clock_skew_ppm |
per-device clock skew, drawn once |
provision.device.<device_id>.repeatability |
the MSA within-device variance component |
provision.appraiser.<appraiser_id>.effect |
the MSA appraiser variance component |
device.link.<device_id>.up_down |
link flap up and down durations |
device.backoff.<device_id>.jitter |
the full-jitter uniform draw |
device.clock.<device_id>.step |
the resync jump that opens a new clock_epoch_id |
device.ota.<device_id>.transfer |
chunk loss and resume offsets |
rf.fading.<portal_id>.rician |
per-round Rician fading |
rf.collision.<portal_id>.slot |
slot selection in the Q algorithm |
rf.tagpose.<pallet_id>.orientation |
tag pose on the pallet |
fleet.provisioning.nonce |
enrollment nonces (a fixed stream) |
search.placement.<portal_id>.sampler |
the placement search sampler seed (D-04) |
model.train.<model_id>.seed |
learned-model training and inference seed (D-04) |
fault.<fault_id>.<instance_id>.progression |
fault-owned randomness, per R6 |
schedule.faults |
the one stream that materialises the fault schedule |
The last two rows are owned by docs/design/variability-and-faults.md and are listed here only
so a reader of this section sees the full set a device run touches.
Renaming a stream is not a free edit and is not silently forbidden either. A.3 fixes the
procedure: the name gets an explicit @v2 suffix, the retired name stays in the registry marked
retired_at, golden files are regenerated in the same commit, and CHANGELOG records which
recorded runs no longer reproduce. That is what makes an unavoidable break loud and dated rather
than a silent hash change.
5.30 The statistical test seam¶
Two places in this section need a hypothesis test: the OTA promotion gate in 5.20 and the RF
what-if delta in 5.24. Neither twinflow-fleet nor twinflow-rf may import the LSS engine,
because the LSS engine is not in this section's DAG and pulling it in would put a statistics
stack inside the fleet server and inside the RF model.
The seam is an event round trip, which is what the locked architecture constraint asks for.
The requester publishes ota.ring.gate.requested.v1 or rf.whatif.evaluated.v1 on the
EventBus port (D-08), carrying a request_id, the metric name, and two sample references
resolvable in the historian. The LSS engine replies with lss.test.completed.v1 carrying the
same request_id, the chosen test, the statistic, the p-value, the effect size, and the
assumption-check trace that chose the test. The requester correlates by request_id and writes
the result into ota.ring.gate.evaluated.v1.
Three behaviors make the seam safe rather than merely decoupled.
The reply has a deadline. fleet.stats.reply_timeout_s (default 300 sim-seconds) bounds the
wait. On timeout the campaign engine halts the ring and publishes ota.campaign.halted.v1 with
reason: stats_timeout. It never promotes on a missing test, because promoting on a missing
test is exactly the eyeballed-dashboard behavior the gate exists to replace.
The reply is part of the tape. Both events carry the D-07 envelope and both are ordered by the canonical total order, so a replay reproduces the decision rather than recomputing it (D-04).
The contract is tested from both sides. C3's producer and consumer contract tests cover
ota.ring.gate.requested.v1 here and lss.test.completed.v1 in the LSS engine section, and
test_fleet_imports_no_lss_module asserts the import never appears.
6. Configuration¶
Every key below is a real key with a real default and a real validator. The blocks live in
facility.yaml except where a standalone file is named, and every block validates at load
against a published JSON Schema under /schemas/config/ (C5). Nothing in this section reads a
numeric parameter from application code; the lint rule that enforces that for stochastic
parameters is R8 in docs/design/variability-and-faults.md, and 6.13 extends it to the
non-stochastic keys this section owns.
6.1 simulation: and uns: blocks¶
simulation:
mode: simulation # simulation | production selects the port family (D-12)
epoch: "2026-01-01T00:00:00Z" # sim-time origin; every device_ts and ingest_ts counts from here
seed: 20260809 # uint64, the one run seed (C1)
replication_index: 0 # int >= 0, folded into root entropy per variability A.1
horizon_s: 86400 # int > 0
warmup_s: 3600 # int >= 0, excluded from every reported statistic
deployment:
adapters: # rejected at validation when simulation.mode is simulation (D-12)
network: mosquitto # mosquitto | emqx | nanomq
event_bus: inmemory # inmemory | partitioned_log
storage: duckdb # duckdb | delta
uns:
enterprise: acme # ^[a-z0-9][a-z0-9-]{0,31}$
site: dc-01
mirror:
enabled: true
root: twinflow
publish_tier: 2 # 1 | 2 see 4.2
stale_after_s: 300 # int > 0, written into every mirror payload
sparkplug:
group_id_template: "{enterprise}:{site}:{area}"
edge_node_id_template: "{line}" # see 4.1 for the one-process-per-device variant
device_id_template: "{equipment}"
namespace: spBv1.0 # fixed by the specification; validation refuses any other value
simulation.epoch is the origin every timestamp in section 4 counts from. It is a config value
rather than a wall-clock read, which is what lets two runs of the same seed produce the same
device_ts (D-02).
deployment.adapters exists so a tier change is a config change and never a rewrite (A3), and
config validation refuses the whole block when simulation.mode is simulation, because the
simulation port family has no adapter choice to make. That refusal is what stops the
tier-portability proof from comparing two runtimes that the binding rules already made identical
(D-12).
6.2 catalog: block and the sensor catalog files¶
catalog:
root: catalog/sensors/ # directory of one YAML file per category
capabilities: catalog/capabilities.yaml
runbook_templates: catalog/runbook_templates/
ucum_version_file: catalog/UCUM_VERSION # pinned edition, 6.13 rule 9
allow_status_planned: false # true only outside a release build
load_budget_s: 2.0 # float > 0, asserted by test_catalog_scales_to_500
memory_budget_mb: 256 # int > 0, asserted by the same test
strict_unknown_keys: true # an unrecognised key is an error, never a warning
The catalog files themselves are the eight category documents described in 3.1. They are config
in every sense that matters: they validate against /schemas/config/catalog_entry.v1.json, they
carry line and column marks for error reporting, and a new sensor type is a change to one of them
and to nothing else.
strict_unknown_keys defaults to true because a typo in a catalog key that silently does nothing
is the failure mode that makes declarative configuration untrustworthy. A misspelled noize:
block would otherwise leave the channel noiseless and the run would look fine.
6.3 transport: block¶
transport:
broker:
host: mosquitto # resolved through the Network port, never through the host resolver
port: 8883
keepalive_s: 120 # int, 10..240; the ceiling is the compliance rule in 5.28
clean_session: true # MQTT 3.1.1; MQTT 5.0 uses clean start with session expiry 0
protocol: mqtt311 # mqtt311 | mqtt50
max_inflight: 64 # int > 0, feeds the derived stampede ceiling in 6.4
qos_by_topic_class: # checked against conformance.yaml, INV-QOS-1
nbirth: { qos: 0, retain: false }
dbirth: { qos: 0, retain: false }
ndata: { qos: 0, retain: false }
ddata: { qos: 0, retain: false }
ncmd: { qos: 0, retain: false }
dcmd: { qos: 0, retain: false }
ddeath: { qos: 0, retain: false }
ndeath_will: { qos: 1, retain: false }
state_birth: { qos: 1, retain: true }
state_will: { qos: 1, retain: true }
uns_mirror: { qos: 1, retain: true }
conformance_departures: [] # each entry names a rule_id and a written reason
store_and_forward:
device:
cap_bytes: 65536 # int > 0
drop_policy: oldest # oldest | newest
high_water_pct: 80 # int, 1..99
gateway:
cap_bytes: 4194304
drop_policy: oldest
high_water_pct: 80
spill_path: var/spill/{gateway_id}/
spill_cap_bytes: 268435456
replay_rate_msgs_s: 200 # int > 0, paced replay so a recovering fleet does not flood
deadband:
default: { kind: percent, value: 0.5, max_publish_interval_s: 60 }
aggregation:
default: { window_s: 10, stats: [mean, rms, min, max, p95] }
A conformance_departures entry has the shape {rule_id, reason, expires_at_phase}. A departure
with no rule_id is a config error, a departure naming a rule id that
schemas/iot/sparkplug/conformance.yaml does not contain is a config error, and a run holding any
departure is marked non-conformant in every artifact it writes. See open question 9.1.
keepalive_s has a ceiling of 240 rather than an arbitrary one because 5.28 ships
keepalive_below_middlebox as a fleet compliance rule with a maximum of 240 seconds, and a
default the compliance auditor would immediately raise a violation on is a self-inflicted alarm.
6.4 devices: block¶
devices:
edge_nodes:
- edge_node_id: dock-3
area: receiving
line: dock-3
agent: rust # python | rust; at least one rust node per shipped profile
gateway_id: gw-receiving
devices: [portal-dock3-a, dock3-ambient]
- edge_node_id: conv-02
area: sortation
line: conv-02
agent: python
gateway_id: gw-sortation
devices: [conv-02-motor-temp]
instances:
- device_id: portal-dock3-a
type_id: rfid_portal_reader_uhf
serial: TWF-P-000117
hardware_rev: "b2"
edge_node_id: dock-3
uns_path: acme/dc-01/receiving/dock-3/portal-a
edge_tier: 0
physical: false
overrides: # per-instance channel overrides, validated against the catalog entry
antenna_vswr: { publish_policy: { kind: periodic, interval_s: 30 } }
agent:
backoff:
base_ms: 500 # int > 0
cap_ms: 60000 # int >= base_ms
stability_s: 30 # int >= 0; the retry counter resets only after this much online time
stampede_ceiling_per_100ms: null # null selects max(4, ceil(broker.max_inflight / 4))
hold:
hold_after_consecutive_failures: 5 # int > 0
hold_duration_s: 900 # int > 0
leave_early_on_link_up: true
crash_loop:
crash_loop_threshold: 5 # int > 0
crash_loop_window_s: 600 # int > 0
fault_reset_reasons: [panic, int_wdt, task_wdt, wdt, brownout, cpu_lockup]
brownout: # every default here is a repo-defined modeling assumption, see 9.17
threshold_v: 2.60
supply_v: 3.30
supply_resistance_ohm: 0.45
tx_window_ms: 5
tx_power_dbm_threshold: 17.0
tx_current_curve: brownout/tx_current_curve.yaml
overrides is a per-instance patch over the catalog entry's channel definitions, and it is the
only place an instance may differ from its type. It validates against the same channel schema the
catalog uses, it may change publish_policy, range, resolution, and the alarm limit triple,
and it may not change measurand, unit, datatype, uns_parameter, or signal_model.kind,
because those five are the fields downstream contracts bind to. The merged document is what
DeviceSpec carries into the run (3.3), and the merge is applied at load so a validation error
names the line in the file the operator wrote rather than a line in the catalog.
stampede_ceiling_per_100ms is null by default so the derived value in 5.8 is used and stays
consistent with broker.max_inflight. Setting it to an integer overrides the derivation, and
setting it above broker.max_inflight is a config error, because a ceiling the broker cannot
honor is not a ceiling.
fault_reset_reasons names members of the ResetReason enum from 5.9. Validation refuses any
value that is not one of the sixteen, which is how a typo in a config file is caught rather than
silently narrowing the crash-loop detector.
6.5 security: block¶
security:
epoch_floor: "2025-01-01T00:00:00Z" # ISO8601 date, or the literal build_timestamp
epoch_ceiling_years: 20 # int > 0; refuses an absurd future clock from a corrupt RTC
sntp:
servers: [sntp-a, sntp-b] # resolved through the Network port
backoff: { base_ms: 1000, cap_ms: 300000 }
ca:
root: pki/twinflow-root-ca.pem # offline, long lived
device_ca: pki/twinflow-device-ca.pem
infra_ca: pki/twinflow-infra-ca.pem
manufacturing_ca: pki/twinflow-manufacturing-ca.pem
crl_path: pki/crl.pem
crl_reload_interval_s: 3600
device_cert:
validity_days: 90 # int > 0
renew_at_fraction: 0.667 # 0 < x < 1; 2/3 of lifetime, with an overlap window
overlap_days: 7 # int >= 0; both certificates validate during the overlap
key_type: ec_p256 # ec_p256 | rsa_2048
provisioning:
endpoint: provisioning
allowlist: fleet/allowlist.yaml
placement_policy: fleet/placement_policy.yaml
nonce_ttl_s: 300
acl:
generated_from: registry # registry | file; a file value needs an explicit path
output: pki/broker_acl.conf
epoch_floor accepts the literal build_timestamp, and config validation refuses that value
unless simulation.mode is production. 5.7 gives the reason: a build timestamp is a wall-clock
value that would reach iot.device.timegate.v1, which is in the hashed tape, so two runs of the
same seed would differ (D-02). floor_origin on that event records which of the two sources was
used, so a recorded log says which it was without opening the manifest.
6.6 edge: block¶
edge:
tiers:
"0": { latency_budget_ms: 10 }
"1": { latency_budget_ms: { safety_interlock: 100, anomaly_flag: 1000 } }
"2": { latency_budget_ms: 10000 }
"3": { latency_budget_ms: null } # no budget, and null is the way to say so
gateways:
- gateway_id: gw-receiving
area: receiving
broker: nanomq
local_retention_hours: 72 # raw high-rate windows kept locally, fetchable on demand
local_inference: [ewma_residual, safety_interlock]
placement: # every function assigned to a tier with a written rationale
- function_id: deadband_filter
tier: 0
latency_budget_ms: 10
stage_costs_ms: [2.0, 1.5]
rationale: "A filter that runs anywhere but the device saves no bandwidth."
- function_id: safety_interlock
tier: 1
latency_budget_ms: 100
stage_costs_ms: [4.0, 12.0, 20.0]
rationale: "A round trip to the site cannot meet the interlock budget on a lossy link."
- function_id: spc_evaluation
tier: 2
latency_budget_ms: 10000
stage_costs_ms: [120.0, 400.0]
rationale: "Needs the historian's full window, which the gateway does not hold."
accounting:
window_s: 300
capture_wire_bytes: true # BM-IOT-02 fails when this is false and a bandwidth claim is made
stage_costs_ms is the list IC-IOT-05 sums. Declaring it in config rather than deriving it in
code is what makes the model-consistency check falsifiable: a placement table whose stage costs do
not add up to the modeled latency is a defect the check names.
latency_budget_ms at tier 1 is a mapping rather than a scalar because the source states two
different budgets at that tier, 100 ms for safety interlocks and 1 s for anomaly flagging, and
collapsing them to one number would lose the requirement.
6.7 fleet: block¶
fleet:
registry:
projection_checkpoint_interval_s: 300
health:
degraded_below: 70.0 # float, 0..100
recovered_above: 75.0 # float; must exceed degraded_below by hysteresis_band
failed_below: 40.0 # float; must be below degraded_below
hysteresis_band: 5.0 # float > 0; the gap 3.4 relies on
dwell_s: 300 # int > 0; sustained time before a lifecycle transition
min_occurrence_observations: 168 # device-hours before the measured O table replaces the prior
min_detection_events: 10 # matched detections before the measured D table replaces the prior
prior_confidence_penalty: 0.5 # 0 < x <= 1, applied to c_i while a prior is in use
carry_history: false # true is refused whenever the run is part of a hash comparison
fmea:
method: rpn # rpn | action_priority
severity: fleet/severity.yaml
occurrence_table: fleet/occurrence_table.yaml
detection_table: fleet/detection_table.yaml
occurrence_prior: fleet/occurrence_prior.yaml
detection_prior: fleet/detection_prior.yaml
ap_table: fleet/ap_table.yaml # repo-defined, see 9.3
mttd:
match_window_s: 3600 # int > 0
grace_s: 300 # int >= 0, matching the scoring.grace_s of variability C.6
leakage_is_fatal: true # a detection before detectable_from_sim_ts fails the run
drift:
reconcile_interval_s: 900
grace_s: 3600 # a drift item older than this becomes a compliance violation
max_push_attempts: 5 # after this the item becomes unreconcilable
security_critical_paths:
- /security/cert_fingerprint
- /transport/broker/host
- /firmware/hash
compliance:
policy: fleet/policy.yaml
schedule_sim_cron: "0 2 * * *"
diff_report_before_apply: true # tightening a policy prints the newly failing device count
runbooks:
templates: catalog/runbook_templates/
output: artifacts/runbooks/
material_change_alpha: 0.05 # the version bumps only on a significant MTTD shift
stats:
reply_timeout_s: 300 # sim-seconds; on timeout the campaign halts, never promotes
recovered_above is not an independent number. Validation requires
recovered_above == degraded_below + hysteresis_band, so the band 3.4 depends on cannot be
edited away by changing one of the two thresholds and forgetting the other.
carry_history: true carries the measured occurrence and detection tables across runs, which is
state outside the seed. Validation refuses it whenever the run is part of a hash comparison, and
the run manifest records the value so a reader of a recorded run can tell which regime produced
its health scores.
6.8 pdm: block¶
pdm:
window:
length_s: 3600 # int > 0
stride_s: 900 # int > 0, must divide length_s
min_samples: 120 # int > 0; a window below this emits no features
duty_cycle_correction:
enabled: true
design_matrix: [running, idle, faulted, speed_band, load_band]
refit_interval_s: 86400
trend:
mann_kendall: { alpha: 0.05, exact_below_n: 20, tie_correction: true }
theil_sen: { ci_level: 0.95 }
ewma: { lambda: 0.2, L: 3.0 }
time_to_threshold:
limit_source: catalog # catalog | override
report_unbounded_interval: true # never print a point estimate the CI does not support
planning_horizon_days: 30 # a crossing inside this raises a work order
bakeoff:
detectors: [theil_sen, ewma, ordinary_least_squares, isolation_forest, rul_regressor]
corpus: scenarios/SCN-PDM
metrics: [pr_auc, mttd_p50, mttd_p90, fp_per_device_week, prognostic_horizon]
publish_table_always: true # the comparison ships whether or not the learned model wins
learned:
inference_port: recorded # recorded | live; simulation mode requires recorded (D-04)
model_artifact_hash: null # sha256 of the pinned artifact; null forbids a learned detector
threads: 1
deterministic_kernels: true
publish_table_always: true is not decoration. The locked decision says a learned model enters
only if it beats the statistical baseline, and that the comparison is reported either way. Making
the publication unconditional in config means the honest outcome is the default outcome.
model_artifact_hash: null forbids a learned detector rather than silently allowing an unpinned
one, which is how D-04 is enforced at load rather than discovered at replay.
6.9 rf: block¶
rf:
regulatory:
region: fcc # fcc | etsi
band: us_902_928 # us_902_928 | eu_865_868 | eu_915_921
limits_file: rf/regulatory_limits.yaml # transcribed clauses, see 3.7 and VG-IOT-04
propagation:
path_loss_exponent: 2.6 # float; 2.0 is free space, 2.5 to 3.5 is a rack aisle
reference_distance_m: 1.0
fading: { kind: rician, k_factor_db: 6.0, redraw: per_round }
pol_null_floor_db: 25.0 # saturates -20 log10(abs(cos psi)) so a null is finite
occlusion_db_per_layer: # every value is a repo-defined modeling assumption, see 9.15
none: 0.0
cardboard: 0.5
liquid: 6.0
metal: 20.0
singulation:
q_initial_fp: 4.0 # Annex D of the Gen2 standard, Figure D-1
q_delta: 0.2 # typical range 0.1 to 0.5 per the same figure
q_min: 0
q_max: 15 # the specification's range for Q
slot_counter_bits: 15 # the specification's slot-counter width
portals:
- portal_id: dock3-portal-a
zone_length_m: 2.4
inventory_round_rate_hz: 40.0
dense_reader_mode: true
noise_floor_dbm: -95.0
antennas:
- antenna_id: dock3-a-ant1
position_m: [12.4, 3.1, 2.2]
boresight: { azimuth_deg: 180.0, elevation_deg: -15.0 }
pattern: patch_cos_n
peak_gain_dbi: 6.0
polarization: rhcp
tx_power_dbm: 30.0
cable_loss_db: 1.2
search:
sampler: tpe # the Optuna sampler; twinflow-rf[search] extra
trial_budget: 200 # a deterministic trial count, never a wall-clock budget (D-04)
workers: 1
q_initial_fp, q_delta, q_min, q_max, and slot_counter_bits are the Gen2 parameters
quoted in 5.22, held as config so a deployment can model a reader that departs from the
informative Annex D loop without editing code. Validation refuses q_max > 15 and
slot_counter_bits != 15, because those two are normative rather than informative.
trial_budget is a trial count and never a time limit, which is what keeps
whatif.rf.reaim_antenna reproducible across machines of different speeds (D-04).
6.10 ota: block¶
ota:
server:
image_root: artifacts/firmware/
chunk_bytes: 4096
resume_offset_in_nvs: true
signing:
verify_key: pki/firmware-signing.pub
algorithm: ed25519
verify_before_write: true # the whole-image signature is checked before the inactive slot
campaign_defaults:
rings:
- { name: canary, size_pct: 2, min_devices: 1, soak_duration_s: 3600 }
- { name: early, size_pct: 10, soak_duration_s: 7200 }
- { name: broad, size_pct: 50, soak_duration_s: 14400 }
- { name: fleet, size_pct: 100, soak_duration_s: 14400 }
health_gate:
metric: health_score
alpha: 0.05
max_health_regression: 2.0 # points, the tolerance the paired test is judged against
crash_loop_delta_max: 0 # any new crash loop in the ring halts the campaign
publish_rate_tolerance: 0.10 # ratio, after versus before
verify_soak_s: 600 # online time before the agent marks the new image valid
rollback_policy: { automatic: true, on: [gate_failure, verify_timeout] }
authority_tier: L2 # L1 advise | L2 recommend with approval | L3 auto-apply
min_devices: 1 on the canary ring is what makes "1 device or 2%, whichever is larger" a config
fact rather than a sentence in prose. A fleet of 20 devices produces a canary of one, and a fleet
of 500 produces ten.
max_health_regression is a tolerance in health-score points and is the quantity the paired test
in 5.20 is judged against. A gate that only asked for statistical significance would halt a
campaign on a regression of a tenth of a point in a large ring, which is significance without
consequence.
6.11 hil: block (E47)¶
hil:
enabled: false # true implies simulation.mode production and deterministic false
device_id: hil-esp32-01
serial: TWF-H-000001
edge_node_id: bench-1
allowed_inbound: [node_control_rebirth, ota] # every other DCMD is refused at the gateway
selftest_output: artifacts/hil/
flash_command: just hil-flash
Setting hil.enabled: true while simulation.mode is simulation is a config error naming both
keys, because a physical board cannot obey a virtual clock. The run manifest records
deterministic: false for the whole run, and the golden-file, eval, and README-number paths all
refuse a run whose manifest carries that value (5.26).
6.12 Standalone files¶
| File | Schema | Notes |
|---|---|---|
catalog/sensors/{category}.yaml |
/schemas/config/catalog_entry.v1.json |
eight files, one per category; the eight README tables |
catalog/capabilities.yaml |
/schemas/config/capabilities.v1.json |
one row per capability with realised_by or proven_by |
catalog/runbook_templates/{mode}.md |
none: markdown with a required heading set | a mode with no template fails the 5.27 coverage test |
catalog/UCUM_VERSION |
none: a single pinned version string | read by VG-IOT-05 |
schemas/iot/sparkplug/conformance.yaml |
/schemas/config/sparkplug_conformance.v1.json |
{rule_id, topic_class, qos, retain, basis} rows |
fleet/severity.yaml |
/schemas/config/fmea_severity.v1.json |
mode_id to S, overridable without editing the catalog |
fleet/occurrence_table.yaml |
/schemas/config/fmea_band_table.v1.json |
ten bands over measured incident rate |
fleet/detection_table.yaml |
/schemas/config/fmea_band_table.v1.json |
ten bands over MTTD_p90 / pf_interval |
fleet/occurrence_prior.yaml |
/schemas/config/fmea_prior.v1.json |
cold-start O per (mode_id, device_type) with a documented basis |
fleet/detection_prior.yaml |
/schemas/config/fmea_prior.v1.json |
cold-start D per (detector_id, mode_id) |
fleet/ap_table.yaml |
/schemas/config/fmea_ap.v1.json |
repo-defined Action Priority lookup, labeled as such |
fleet/allowlist.yaml |
/schemas/config/allowlist.v1.json |
manufacturing serials eligible for enrollment |
fleet/placement_policy.yaml |
/schemas/config/placement_policy.v1.json |
serial range and type to area, line, and UNS path |
fleet/policy.yaml |
/schemas/config/fleet_policy.v1.json |
the compliance rules of 5.28 |
rf/regulatory_limits.yaml |
/schemas/config/regulatory_limits.v1.json |
transcribed clause values with source strings |
brownout/tx_current_curve.yaml |
/schemas/config/tx_current_curve.v1.json |
transmit current against transmit power, repo-defined, see 9.17 |
Every file whose rows carry a physical or regulatory number also carries a source string per
row, on the same rule 3.7 applies to PhysicalConstant. The permitted values are a named
published reference, a named regulation, or the literal
"repo-defined modeling assumption, not a measurement". A row with an empty source is a load
error naming the row.
6.13 Cross-file validation rules¶
Beyond per-field types, the loader enforces the following, and each rule names the invariant or test that also asserts it at runtime.
- Every
devices.instances[].type_idresolves in the catalog, and everyedge_node_idon an instance resolves indevices.edge_nodes(INV-SPB-2). - Every
edge_nodes[].devices[]id resolves indevices.instances, and the two directions agree, so a device cannot be hosted by a node that does not list it. - No two edge nodes share an
edge_node_id, and no two agent processes claim one (INV-SPB-1). - Every
(attaches_to, uns_parameter)pair across the bound fleet is unique, so no two channels collide on a UNS topic (INV-UNS-1). - Every
attaches_toselector resolves to at least one resource in each of the three worked profiles from A2 (INV-CAT-5). - Every
capability_unlocksid resolves incatalog/capabilities.yaml, and every capability there names arealised_bymodule or aproven_bytest (INV-CAT-4). - Every
failure_modes[].catalog_idresolves indocs/design/variability-and-faults.mdC.4 or C.5, and every C.4 id this section owns appears in the 5.12 mapping (INV-CAT-6). - Every
signal_model.kindresolves in the plugin registry, and the set of distinct kinds across the shipped catalog has cardinality at most twelve (INV-CAT-3). - Every
unitresolves in the UCUM edition named incatalog/UCUM_VERSION(VG-IOT-05). warn_limit < alarm_limit < trip_limit, all insiderange, on every channel that declares them, and every channel with numeric limits carries alimit_source.fleet.health.recovered_above == fleet.health.degraded_below + fleet.health.hysteresis_band, andfleet.health.failed_below < fleet.health.degraded_below.- Every
transport.qos_by_topic_classentry matches itsconformance.yamlrow, or names a departure rule id intransport.conformance_departures(INV-QOS-1), and everyconformance.yamlrow with a nullrule_idcarries a written basis (INV-QOS-2). edge.placement[].tieris at most the tier of the gateway serving the device, and every function'sstage_costs_mssums to at most itslatency_budget_ms(IC-IOT-05).rf.regulatoryradiated power is within the ceiling for the declared(region, band)pair, and the antenna beam-width satisfies the coupling in 3.7 (VG-IOT-04).rf.singulation.q_max <= 15andrf.singulation.slot_counter_bits == 15.deployment.adaptersis absent wheneversimulation.modeissimulation(D-12).security.epoch_floor: build_timestampandhil.enabled: trueeach needsimulation.mode: production.pdm.window.stride_sdividespdm.window.length_s, andpdm.learned.inference_portisrecordedwheneversimulation.modeissimulation(D-04).pdm.learned.model_artifact_hashis non-null whenever a learned detector appears inpdm.bakeoff.detectorsand the run is not a bakeoff-only run.ota.campaign_defaults.ringsis non-empty, ringsize_pctvalues are non-decreasing, and the last ring is 100.- Every path in
fleet.drift.security_critical_pathsis a valid RFC 6901 JSON pointer and resolves in the desired config document of at least one device type. transport.broker.keepalive_ssatisfies everyvalue_rangerule infleet/policy.yamlthat applies to/transport/keepalive_s, so the shipped default cannot raise a violation against the shipped policy.
Rules 11, 12, 16, 17, 18, and 22 are the ones that exist because the alternative is a run that starts and then behaves wrongly. The rest are the ordinary kind that catch a typo.
6.14 Error reporting (C5)¶
Config is parsed with ruamel.yaml in round-trip mode so every node keeps its line and column,
which is why 2.3 lists ruamel.yaml as a dependency of twinflow-sensors rather than a plain
YAML loader. Errors are formatted with the file, the line, the column, the offending value, the
valid set, a suggestion, and a documentation link:
catalog/sensors/warehouse_logistics.yaml:214:11: error: channels[1].signal_model.kind:
"rf_readzone" is not a registered signal model kind
registered kinds: steady_state, first_order_lag, duty_cycled,
rotating_machine_vibration, counter, event_stream, rf_read_zone, positional,
binary_state, electrochemical, level_gauge, load_bridge
did you mean: rf_read_zone
see: docs/CONFIGURING.md#signal-models
facility.yaml:88:7: error: fleet.health.recovered_above: 72.0 does not equal
fleet.health.degraded_below (70.0) plus fleet.health.hysteresis_band (5.0)
the hysteresis band in section 3.4 is what stops lifecycle flapping
set recovered_above to 75.0, or change hysteresis_band to 2.0
see: docs/CONFIGURING.md#fleet-health
facility.yaml:12:3: error: deployment.adapters is not permitted when simulation.mode is
"simulation"
the simulation port family has no adapter choice to make (doctrine D-12)
remove the block, or set simulation.mode to "production"
see: docs/design/DOCTRINE.md#d-12
Suggestions come from difflib.get_close_matches over the valid key or value set. All errors in
a file are collected and reported together, never one per run. twinflow-fleet validate-config
exits non-zero on any error and is wired into just validate and the pre-commit hook. A
--dry-run mode loads every config, binds every device, resolves every attaches_to selector,
builds every Sparkplug alias table, and reports what would run without opening a socket or
touching the historian, which is what makes just run --dry-run able to name an unresolved
selector before a run rather than during one.
7. Testing¶
Five tiers with runtime budgets (C4), plus two benchmark jobs that measure rather than assert.
All tiers run on just test; CI runs them as separate path-filtered jobs (C10). Every test in
this section states the observation that would fail it, per D-12, and a test whose failure
condition cannot be described does not appear here.
7.1 Unit tests (budget: 90 s)¶
Per-function tests over hand-computed fixtures. The ones that are easy to get wrong are named here rather than left to a coverage number:
- One test per
classificationvalue iniot.device.diag.ladder.v1, thirteen failure values plusok, each driven by aNetworkfake configured to fail at exactly one rung (5.11). The fake fails at the rung, not at the call, which is why the port exposesresolve,tcp_connect,tls_handshake, andmqtt_connectseparately (2.1). peer_issueris populated ontls_unknown_caand absent on every other classification.- Sparkplug alias assignment: a metric absent from DBIRTH raises at encode time rather than publishing an invalid stream, and the raised message names the metric and the DBIRTH.
seqwraps 255 to 0 and never skips; NBIRTH carriesseq = 0; a rebirth resetsseqto 0 and keepsbdSeq.bdSeqincrements in NVS across a simulated reboot, and the NDEATH will registered at CONNECT carries the same value the following NBIRTH carries.UnsPathconstruction refuses a segment containing/,+,#, or an uppercase letter, and the error names the offending segment.UnsPath.from_resourceandto_resourceon a five-segment resource path, where the two deepest segments join with a dot into the Sparkplug metric name.- Full-jitter delay is drawn from the
device.backoff.<device_id>.jitterstream and lands in[0, min(cap_ms, base_ms * 2 ** attempt_index)]at everyattempt_indexfrom 0 to 20. - The retry index resets after
stability_sonline and not on CONNACK alone; the test drives a broker that accepts and drops after 1 s and asserts the delay keeps growing. - Time gate: no TLS handshake and no token mint below
security.epoch_floor, and the ceiling refuses a clock beyondepoch_ceiling_years. StoreAndForwarddrop policy:oldestandnewesteach drop exactly the record the policy names, anddropped_records_totalincrements by exactly one per drop.- Deadband: a suppressed sample increments
deadbanded, is written to the ground-truth stream, and is published anyway oncemax_publish_interval_selapses. - Health score on a hand-computed two-mode case, checked against the product form to 1e-12 relative, and the same case with the modes supplied in the other order, checked for exact equality after sorting.
- Occurrence and detection band lookup at every band boundary, including both open ends.
- Cold start: a device with no history yields a defined score and a populated
prior_used. MttdAccountanton a constructed pairing set containing one true positive, one false positive, one miss, and one detection earlier thandetectable_from_sim_ts, asserting the last one is recorded as leakage and fails the run.- Bearing defect frequencies on a hand-computed geometry, including the identity
BPFO == Nb * FTF, which holds for any geometry and is the cheapest arithmetic check on the four formulas. TimeToThresholdreturns an unbounded interval exactly when the Theil-Sen slope confidence interval straddles zero, and the event says so rather than printing a point estimate.MissCauseselection: six constructed passes, one per cause, each failing exactly one budget term first.- Config error formatting: file, line, column, offending value, valid set, suggestion, and documentation link all present, on one example per rule class in 6.13.
7.2 Property-based invariants (Hypothesis, budget: 240 s)¶
Fifty-three named invariants, each a @given test running under a seeded derandomize profile
in CI so a failure is reproducible from the printed seed alone.
| Id | Invariant |
|---|---|
| INV-DET-1 | Same seed, config, platform, and pinned lockfile produce a byte-identical events.ndjson for any device count from 1 to 500. |
| INV-DET-2 | Activating a channel-scope fault leaves every draw from that channel's sensor.*.noise stream unchanged, so with-fault and without-fault arms stay paired. |
| INV-DET-3 | Activating a device-scope fault changes which records exist but leaves the clean-signal draw sequence within each surviving clock_epoch_id unchanged. |
| INV-DET-4 | DETERMINISM_EXCLUDED_FIELDS contains no field any schema marks x-simtime: true, and every field it does contain is marked x-wallclock: true in its schema. |
| INV-CLK-1 | device_ts is non-decreasing within a clock_epoch_id, for any drift and resync trajectory. |
| INV-CLK-2 | Every clock step opens a new clock_epoch_id, and no step occurs inside an epoch. |
| INV-CLK-3 | A record buffered through an outage lands at its true position in its channel's series under the ordering key (device_id, clock_epoch_id, device_ts). |
| INV-CLK-4 | No schema under /schemas/iot/ or /schemas/fleet/ marks a timestamp field x-wallclock: true. |
| INV-SPB-1 | No two MQTT client sessions in a run share an edge_node_id. |
| INV-SPB-2 | Every device resolves to exactly one hosting edge node, and the node's devices[] list and the device's edge_node_id agree in both directions. |
| INV-SPB-3 | A metric absent from DBIRTH never appears in DDATA; seq increments modulo 256 with no gap inside a session; NBIRTH carries seq = 0. |
| INV-SPB-4 | The bdSeq in an NDEATH equals the bdSeq of that session's NBIRTH, and bdSeq is strictly increasing across sessions for one edge node. |
| INV-UNS-1 | UnsPath round-trips in all four directions between the resource path, the UNS mirror topic, the Sparkplug identifier triple, and back, and no rendered segment contains a wildcard. |
| INV-CAT-1 | Every channel carries unit, baseline, noise, and drift, and a drift of {kind: none} carries a why string. |
| INV-CAT-2 | Every failure mode declared anywhere in the catalog is reachable by the injector and produces exactly one runbook with every required section non-empty. |
| INV-CAT-3 | The set of distinct signal_model.kind values across the shipped catalog has cardinality at most twelve and is a subset of the twelve named in 3.2. |
| INV-CAT-4 | Every capability_unlocks id resolves in catalog/capabilities.yaml, and every capability there names a realised_by module or a proven_by test. |
| INV-CAT-5 | Every attaches_to selector resolves to at least one resource in each of the three worked profiles from A2. |
| INV-CAT-6 | Every device, gateway, broker, ingest, network, and auto-ID fault id in variability C.4 appears in the 5.12 mapping, except the two on the recorded exclusion list with their owners. |
| INV-CAT-7 | Loading the shipped catalog in two processes under different PYTHONHASHSEED values yields identical binding order and identical Sparkplug alias tables. |
| INV-SF-1 | generated == published + dropped + buffered + deadbanded at every instant, for any outage and overflow trajectory. |
| INV-SF-2 | Buffered bytes never exceed cap_bytes, and each overflow drops exactly one record chosen by the declared policy. |
| INV-SF-3 | Per-channel FIFO order survives replay, for any interleaving of replayed and live records. |
| INV-TG-1 | No TLS handshake and no token mint occurs while clock.now() < security.epoch_floor, for any clock trajectory including a corrupted RTC. |
| INV-BO-1 | Every backoff delay lies in [0, min(cap_ms, base_ms * 2 ** attempt_index)], and attempt_index resets only after stability_s of continuous online time. |
| INV-BO-2 | The maximum connection attempts in any 100 ms bucket over a fleet-wide reconnect window stays below backoff.stampede_ceiling_per_100ms. |
| INV-BRN-1 | A brownout occurs only within brownout.tx_window_ms of a transmit event above the power threshold, and never in the absence of one. |
| INV-HS-1 | health_score lies in [0, 100] for any set of active modes and confidences. |
| INV-HS-2 | health_score is monotone non-increasing in each RPN_i and in each c_i. |
| INV-HS-3 | The score computed from the sorted active set is invariant to the order the modes were raised in, exactly, because the sort makes the product order fixed. |
| INV-HS-4 | Clearing a mode restores the score the device had before that mode was raised, to within one unit in the last place. |
| INV-HS-5 | Two simultaneously active modes give a score no higher than either mode alone would, so independent problems compound rather than add. |
| INV-HS-6 | Under a constant fault the lifecycle transition count over a run is bounded, which is the hysteresis band and dwell doing their job. |
| INV-HS-7 | A device with zero history has a defined health_score in [0, 100] and a populated prior_used. |
| INV-MTTD-1 | Every fleet.mttd.v1 emission carries a non-null detection_rate and a non-null censored_n alongside mttd_mean_s. |
| INV-PDM-1 | The time-to-threshold interval contains its point estimate, and is unbounded exactly when the Theil-Sen slope confidence interval straddles zero. |
| INV-PDM-2 | On a fault-free series the duty-cycle-corrected residual has a sample mean of zero to 1e-9 relative, whatever the twin-state schedule. |
| INV-PDM-3 | For any physical bearing geometry the four defect frequencies are positive, BPFI > BPFO, FTF < fr / 2, and BPFO == Nb * FTF exactly. |
| INV-RF-1 | p_read lies in [0, 1] and is non-decreasing in dwell time. |
| INV-RF-2 | p_read is non-increasing in pallet speed at fixed zone length and round rate. |
| INV-RF-3 | Received power is strictly decreasing in distance for any path-loss exponent above zero. |
| INV-RF-4 | Read rate is monotone non-increasing in tag count at fixed Q. |
| INV-RF-5 | Every miss carries exactly one MissCause, and it names the first budget term that failed. |
| INV-RF-6 | No PhysicalConstant ships with an empty source, and every source is a named published reference, a named regulation, or the declared modeling-assumption string. |
| INV-RF-7 | Occlusion loss is ordered none < cardboard < liquid < metal, and read rate falls monotonically in depth_in_pallet. |
| INV-OTA-1 | At every instant every device's active firmware is the campaign target or a version in the campaign's declared rollback set, never an unknown version. |
| INV-OTA-2 | A device is in at most one active campaign. |
| INV-DRIFT-1 | Under repeated publication of the desired document with no external change, the drift item count is non-increasing and reaches zero or a stable unreconcilable set. |
| INV-EDGE-1 | edge.link.stats.v1 wire_bytes equals the sum of the serialized frame lengths the transport adapter recorded over the same window, exactly. |
| INV-QOS-1 | Every configured transport.qos_by_topic_class entry matches its conformance.yaml row, or names a departure rule id that exists in that file. |
| INV-QOS-2 | Every conformance.yaml row with a null rule_id carries a written basis, so a repo choice can never be read as a specification requirement. |
| INV-GT-1 | No object reachable from an agent DuckDB session resolves under warehouse/groundtruth/, no column named clean_value, fault_tags, fault_instance_ids, or ground_truth appears, and a direct scan of the ground-truth root raises. |
| INV-SCH-1 | The registry resolves exactly 50 subjects under /schemas/iot/ and /schemas/fleet/, every subject named in 4.2 to 4.9 exists, and every registry subject under those prefixes appears in the 4.10 table. |
INV-HS-3 and INV-HS-4 are the two that 5.13 says are stated with the tolerance floating-point arithmetic supports. INV-HS-3 is exact because sorting fixes the multiplication order, and INV-HS-4 is stated to one unit in the last place because division is not the exact inverse of multiplication in binary floating point.
Mutation guards accompany four of these, because an invariant that passes against a broken implementation is not evidence. Removing the jitter term fails INV-BO-2, removing the sort in the health scorer fails INV-CAT-7 and INV-HS-3, removing the time gate fails INV-TG-1, and replacing the ground-truth root separation with a column filter fails INV-GT-1. Each guard is a test that applies the mutation to a copy of the module and asserts the named invariant fails.
7.3 Seeded end-to-end scenarios (budget: 900 s)¶
Seventeen scenarios, each running in simulation mode at a fixed seed and asserting an outcome chain rather than a single number.
S-IOT-01, kill the broker and lose nothing. Run a 200-device fleet, kill the broker for 20
simulated minutes, restart it. Assert: every node moves ONLINE to BUFFERING; buffered bytes
stay under cap_bytes; no record is dropped because the outage fits the buffer; replay is paced
at replay_rate_msgs_s; INV-SF-1 balances at every instant; the historian holds the same record
count as the ground-truth stream; and the reconnect histogram satisfies INV-BO-2. This is
the demo component 6c asks for, and the assertion that makes it real is the record-count equality,
not the absence of an error log.
S-IOT-02, ordering across an outage. Same outage, but the assertion is placement rather than
count. Build an I-MR chart over the window spanning the outage from the historian and assert the
point order equals the ground-truth sample order. Ordering by ingest_ts puts hour-old data after
fresh data and the chart is wrong, so the failing observation is a chart whose point order differs
from the truth order by even one position.
S-IOT-03, overflow is bounded and admitted. Extend the outage past buffer capacity. Assert:
sf_dropped_records is non-zero and equals the ground-truth count of suppressed records exactly;
the counter reaches the fleet as a Sparkplug metric on the next NBIRTH; the historian's gap
accounting matches the counter; and no downstream statistic is computed over the gap without the
gap being declared.
S-IOT-04, the clock breaks the certificate, not the certificate. Drive clock_drift_ppm until
the device clock crosses notBefore. Assert: the ladder classifies tls_notbefore rather than
tls_expired; the agent enters TIME_SYNC_WAIT rather than looping on handshake failure; the
resync step opens a new clock_epoch_id; and the runbook the signature resolves to names the
clock, not the certificate.
S-IOT-05, crash loop and brownout. Inject brownout_crash_loop on a device transmitting above
the power threshold. Assert: boot_count rises inside crash_loop_window_s; crash_loop is
raised with its severity floor of 8; the brownout correlation signal fires inside
brownout.tx_window_ms of a qualifying transmit; the heap signal does not fire, because
free_heap_min_bytes is flat; and the generated runbook's fix names supply decoupling rather than
firmware.
S-IOT-06, the antenna what-if with a verdict. Configure dock3-portal-a to miss about 4% of
reads through an orientation-dependent cause. Run whatif.rf.add_antenna,
whatif.rf.reaim_antenna, and whatif.rf.set_conveyor_speed on the same seed family with common
random numbers. Assert: each candidate returns a read-rate delta, a cross-read delta, a downstream
throughput delta, and a cost; the statistical seam in 5.30 returns a lss.test.completed.v1 for
each; the winning candidate's effect-size interval excludes zero; and a deliberately tiny variant
is reported as statistically significant and not practically significant. The search is seeded, so
the ranking is identical across runs, and a differing ranking is the failing observation.
S-IOT-07, cross-reads at adjacent docks. Place two portals with overlapping read volumes.
Assert: iot.rfid.read.groundtruth.v1 records is_cross_read on the reads the geometry produced
at the wrong portal; deduplication does not remove them, because they are genuine reads;
twinflow.rf.diagnose attributes the pattern to physics rather than to the device; and gating
acceptance on trailer_presence_loop reduces the cross-read rate while leaving the true read rate
unchanged.
S-IOT-08, the bearing trend to a work order. Inject F-MECH-BEARING on a conveyor motor.
Assert: the duty-cycle correction removes the schedule signal, so the trend test is not measuring
shift patterns; Mann-Kendall raises a monotone trend before the raw series does; the Theil-Sen
slope interval is finite; pdm.prediction.v1 carries a time-to-threshold interval that contains
the true crossing time recorded in rul.parquet; and maintenance.workorder.requested.v1 is
published with a runbook reference once the crossing falls inside the planning horizon.
S-IOT-09, the canary halts the campaign. Run an OTA campaign whose image degrades health on
the ring it lands on. Assert: the canary ring soaks; ota.ring.gate.requested.v1 is published; the
reply arrives inside fleet.stats.reply_timeout_s; the gate decides against promotion on the
paired test rather than on a threshold; the ring rolls back automatically;
ota.campaign.halted.v1 names the failing criterion; and INV-OTA-1 holds at every instant during
the rollback.
S-IOT-10, the enrollment storm. Present 300 devices to the provisioning endpoint at once,
including one whose serial is absent from the allowlist, one replaying a used nonce, one with a
revoked birth certificate, one presenting a serial already enrolled under a different key, and one
whose clock sits below the gate. Assert: each rejection publishes
fleet.enrollment.rejected.v1 with its own reason value and its own runbook reference; the
remaining devices enroll with no human step; and the nonces are reproducible across a replay,
because they draw from fleet.provisioning.nonce.
S-IOT-11, the address changes and the identity does not. Inject dhcp_lease_loss mid-run.
Assert: the device's address changes; the certificate-based broker ACL continues to authorize it;
and a control arm configured with an address-based ACL is refused. Two lines of scenario make the
design argument that a paragraph would only assert.
S-IOT-12, two devices with one identity. Flash two edge nodes with the same edge_node_id and
start both. Assert: bdSeq outruns iot.device.boot.v1 arrivals; the fleet layer records
bdseq_without_boot; duplicate_client_id is raised; both devices are quarantined and their
publishes are refused by the broker ACL rather than merely ignored; and the config that produced
it is refused at validation in the ordinary path, so this scenario has to construct it
deliberately.
S-IOT-13, what the edge actually buys. Run a fixed workload of high-rate vibration channels
through a gateway. Assert: wire_bytes equals the summed frame lengths exactly (INV-EDGE-1);
raw_wire_bytes_equivalent is computed by encoding the same window one record per frame; the
ratio is written to artifacts/edge/ with the workload described; the deadband suppression rate
is reported as a measurement rather than as a config restatement; and the raw window is still
fetchable on demand from local retention.
S-IOT-14, repeated measures for the gauge study. Run the measurement-system scenario: ten
parts, three appraisers, three replicates, with provision.device.<device_id>.repeatability and
provision.appraiser.<appraiser_id>.effect supplying the two variance components declared in the
catalog. Assert: the emitted readings carry the declared component structure; the study is
handed to the LSS engine through the ordinary event path; and the components the engine recovers
are scored against the declared ones. This section owns the generator and the declaration; the
study itself belongs to the LSS engine.
S-IOT-15, QoS 1 without lying about conformance. Run the same fleet and seed with the UNS
mirror at QoS 0 and at QoS 1. Assert: the business event stream is identical under the documented
normalization; the measured redelivery count at QoS 1 is reported, and it is the number the
mqtt_redelivery fault mode inflates. A second arm declares a departure on
tck-id-payloads-ddata-qos, publishes DDATA at QoS 1, and asserts ConformanceMatrix marks the
run non-conformant and names the rule.
S-IOT-16, health at t=0. Start a 500-device fleet with no history. Assert: every device has a
defined health_score inside [0, 100]; every fleet.health.scored.v1 carries prior_used
naming which prior was applied; c_i is multiplied by prior_confidence_penalty while a prior is
in use; and the first lifecycle transition happens no earlier than dwell_s after the score
crosses its threshold.
S-IOT-17, the gateway goes dark. Partition tier 1 from tier 2 for 30 simulated minutes.
Assert: edge.degraded.v1 lists retained capabilities (safety interlocks, local anomaly flagging,
local store and forward, the line keeps running) and lost capabilities (SPC evaluation, twin sync,
agent answers, cross-area correlation); both lists are generated from the placement table rather
than from prose; the safety interlock latency stays inside its budget throughout; and on
reconnection the spilled records replay in per-channel FIFO order.
7.4 External conformance evidence¶
Two bodies of external evidence carry the two claims a reader is most likely to test: that the Sparkplug codec is conformant, and that the RF model is more than a curve fit.
7.4.1 Sparkplug B v3.0.0 conformance (E3)¶
schemas/iot/sparkplug/conformance.yaml enumerates every normative rule id the codec claims to
satisfy, with the topic class, the QoS, the retain flag, and a basis string. The file is the
single home for those values, and 5.4's table is checked against it at load rather than restated
in code.
The Eclipse Sparkplug Technology Compatibility Kit is the arbiter for the normative half. The
repo runs it against the reference edge node and host application built from twinflow-sparkplug,
commits the TCK result file to artifacts/tck/ with the date, the TCK version, the git SHA, and
the dependency lockfile hash, and the README cites the committed artifact rather than a claim.
test_conformance_yaml_covers_tck_result asserts that every rule id the TCK reports on appears in
conformance.yaml, and that no rule id in conformance.yaml is absent from the TCK report, so
the two cannot drift. A run holding any entry in transport.conformance_departures writes
conformant: false into every artifact it produces. See open question 9.1 on the TCK's own
licensing and on whether its results may be redistributed.
7.4.2 The VG-IOT gate registry (E46 and the rest)¶
Every value this section publishes as an external fact carries a gate record in
validation/valgates.yaml, the same registry and the same schema the LSS engine section defines,
so the two sections produce one docs/VALIDATION.md. A gate that names no valid external
reference is recorded as an open question in section 9 and never as a passing gate (D-11).
A value this section does not publish as an external fact carries the literal string
"repo-defined modeling assumption, not a measurement" in its source field instead. Three
checks together leave no third state: INV-RF-6 covers every PhysicalConstant in twinflow-rf,
the limit_source rule of 3.1 covers every catalog channel with numeric alarm limits, and the
per-row source rule of 6.12 covers every standalone file holding a physical or regulatory
number. The path-loss exponent, the occlusion losses per material, the brownout constants, and
the fleet/ap_table.yaml rows are where that applies, and each has an open question in section 9
naming what would ground it. A number with a gate is evidence, a number with the assumption string
is a declared modeling choice, and no number in this section is permitted to be neither.
| Gate | Statistic or constant | External reference, edition, locator | Tolerance and falsification |
|---|---|---|---|
| VG-IOT-01 | BLAKE2b-512 digest used by the spawn-key derivation | RFC 7693, November 2015, Appendix A, worked computation of the unkeyed BLAKE2b-512 hash of the ASCII bytes "abc", retrieved 2026-08-09, HTTP 200 | Exact byte equality on the final digest and on the internal state at every round the appendix prints. Falsified by any differing byte. |
| VG-IOT-02 | Cross-language RNG parity, Python and Rust (D-06) | RFC 7693 as above, for the hash half; the numpy SeedSequence and PCG64DXSM reference behavior pinned by the lockfile, for the bit-generator half |
Integer draws byte-identical. Continuous values agree within the tolerance measured across the supported platform set, reported rather than assumed (D-05). Falsified by an integer mismatch or a divergence above the recorded tolerance. |
| VG-IOT-03 | Sparkplug payload encode and decode | Eclipse Sparkplug specification 3.0.0, revision 3.0 dated 2022-10-21, payload definition and the normative rule ids of 7.4.1 | The committed TCK result reports no failure on any claimed rule id. Falsified by a TCK failure or by a rule id present in one file and absent from the other. |
| VG-IOT-04 | RFID regulatory ceilings and the beam-width coupling | 47 CFR 15.247(b)(2) and (b)(4), and ETSI EN 302 208 V3.3.1 (2020-08) clauses 4.3.3.3.1, 4.3.3.3.2, and 4.3.4.3, both retrieved 2026-08-09, HTTP 200 | Ceilings compared at the printed precision of the clause, 1 W and 6 dBi for FCC, 33 dBm and 36 dBm for ETSI. Falsified by any accepted antenna configuration exceeding a clause value, or by any refused configuration inside every clause value. |
| VG-IOT-05 | Unit codes on every catalog channel | Unified Code for Units of Measure, version 2.2, https://ucum.org/ucum, retrieved 2026-08-09, HTTP 200 | Every unit resolves in the pinned edition. Falsified by an unresolvable code, or by a pinned edition that no longer publishes a code the catalog uses. |
| VG-IOT-06 | Detection rating grounded in the P-F interval | Nowlan and Heap, "Reliability-Centered Maintenance", United States Department of Defense, 1978, DTIC accession AD-A066579 (single-source, see 9.16) | The D band is a deterministic function of MTTD_p90 / pf_interval with no free parameter. Falsified by two devices with the same ratio receiving different D. Carried at single-source confidence: DTIC refused automated retrieval, HTTP 403, 2026-08-09. |
| VG-IOT-07 | Mann-Kendall test statistic and p-value | Helsel, Hirsch, Ryberg, Archfield, and Gilroy, "Statistical Methods in Water Resources", USGS Techniques and Methods 4-A3, 2020, Example 12.1, retrieved 2026-08-09 from https://pubs.usgs.gov/tm/04/a03/tm4a3.pdf, HTTP 200 | The reported p-value agrees to the three decimals the example prints (0.017 for Mann-Kendall, 0.091 for the ordinary least squares comparator). Falsified by a third-decimal disagreement. Dataset sourcing is open question 9.12. |
| VG-IOT-08 | Theil-Sen slope | The same report, Example 12.2, Big Sioux River annual mean discharge, 1928 to 2014 | The slope agrees to the two decimals the example prints (0.45 cubic meters per second per year, against 0.67 for ordinary least squares). Falsified by a second-decimal disagreement. Dataset sourcing is open question 9.12. |
| VG-IOT-09 | Ordinary least squares used by the duty-cycle correction | NIST Statistical Reference Datasets, linear least squares regression, Norris and Longley, certified values, US Government work in the public domain, retrieved 2026-08-09 | Scored with the log relative error NIST itself uses, LRE = -log10(abs(computed - certified) / abs(certified)) clamped at 15. LRE >= 10 on both datasets, the floor the LSS engine's VG-NUM-03 sets for every StRD regression dataset outside its one stress case. Falsified by an LRE below 10 on either. |
| VG-IOT-10 | Bearing defect frequency formulas | None retrieved. Recorded as open question 9.18 rather than as a passing gate. | The internal arm asserts the algebraic identity BPFO == Nb * FTF and the ordering BPFI > BPFO, which are consequences of the formulas rather than evidence for them. |
| VG-IOT-11 | Gen2 slot counter and the Q adaptation loop | GS1 EPC Radio-Frequency Identity Generation-2 UHF RFID Standard, Release 3.0.1, Ratified February 2026, Annex J.1 and Annex D Figure D-1, retrieved 2026-08-09, HTTP 200 | Q trajectory matches the figure's transitions exactly; Q stays in 0 to 15; every loaded slot value stays in 0 to 2^Q - 1 over 10^6 seeded draws. Falsified by any out-of-range value or any transition the figure does not name. |
| VG-IOT-12 | Framed slotted ALOHA maximum normalized throughput | Schoute, "Dynamic Frame Length ALOHA", IEEE Transactions on Communications, volume 31, number 4, 1983, pages 565 to 568 (single-source, see 9.11) | Maximum within 0.010 of 0.368, attained within one Q step of the tag count, over Q from 0 to 9 and tag counts from 1 to 512. The noise floor is measured over 200 replications and the gate fails its own preconditions if the standard error exceeds 0.010. Falsified by a maximum outside 0.358 to 0.378. Publisher paywall; DOI resolver returned HTTP 202 with an empty body on 2026-08-09. |
| VG-IOT-13 | ResetReason member set |
ESP-IDF stable ESP32 API reference, esp_reset_reason_t, https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/misc_system_api.html, retrieved 2026-08-09, HTTP 200 |
The Python enum member set equals the sixteen documented members after the documented name transform. Falsified by a member in one set and not the other, which is how a future ESP-IDF release adding a cause becomes a test failure rather than a silent unknown. |
| VG-IOT-14 | Backoff strategy comparison | Marc Brooker, "Exponential Backoff And Jitter", AWS Architecture Blog, 04 March 2015, retrieved 2026-08-09, HTTP 200 | Reproduces the article's reported ordering: equal jitter does more client work than full jitter and takes longer; decorrelated jitter does more client work and less time. Falsified by a reproduction that reverses either ordering. |
| VG-IOT-15 | Polarization loss, circular against linear | Balanis, "Antenna Theory: Analysis and Design", 4th edition, 2016, section 2.12 (single-source, paid book, not retrieved for this spec) | The two limiting cases, 3 dB for circular against linear and 0 dB for aligned linear against linear, agree to the one decimal the citation is carried at. Falsified by a limiting case outside 0.1 dB. |
| VG-IOT-16 | Device certificate profile | RFC 5280, May 2008, sections 4.2.1.3 (key usage), 4.2.1.9 (basic constraints), and 4.2.1.12 (extended key usage), retrieved 2026-08-09, HTTP 200 | Every issued operational certificate carries CA:FALSE, digitalSignature, and clientAuth, and nothing else in those three extensions. Falsified by an issued certificate carrying an extension the profile does not name. |
Three of the sixteen gates are carried at single-source confidence with the attribution stated in
the table and in the text that uses them, and one, VG-IOT-10, has no external reference at all and
is recorded as an open question rather than as a gate that passes. That is D-11 rule 5 applied
literally: the honest count in docs/VALIDATION.md is twelve gates with retrievable primary
references, three at single-source confidence, and one open.
7.5 Fault coverage and the resilience gates¶
Coverage over the fault catalog is a test rather than a table that ages.
test_every_owned_fault_is_injectable reads docs/design/variability-and-faults.md C.4, filters
to the ids this section owns, and asserts that each one has an injector, a detection signature in
at least one catalog entry, a runbook template, and at least one scenario in 7.3 that exercises
it. test_every_class_has_four_signatures asserts that each of the twelve signal-model kinds has
at least one shipped catalog entry declaring drift, stuck-at, dropout, and calibration loss, which
is component 2b's requirement that every sensor class carries all four.
Detector scoring uses the matching rule and the metrics in variability C.6 without restating
them. This section adds one constraint on top: mttd.leakage_is_fatal defaults to true, so a
detection earlier than detectable_from_sim_ts fails the run rather than improving the reported
MTTD. A leakage failure is investigated as a windowing or labeling defect, never accepted.
The SCN-DEVICE labeled scenario pack in variability C.7 is the fixed evaluation set for this
section's detectors, and the published precision, recall, and MTTD distribution come from it.
7.6 Benchmarks and the artifacts they write¶
Two jobs measure. Neither is a unit test, both write dated artifacts, and both name the hardware, because a latency or bandwidth number with no hardware is not a measurement.
BM-IOT-01, edge latency. Runs the actual tier-0 and tier-1 code paths over 10,000 samples per
function class with no simulated stage costs, and writes artifacts/edge/bench_{date}.json with
p50, p95, p99 per function class, the hardware description, the git SHA, and the lockfile hash. It
fails when a p99 exceeds its budget on the reference runner and reports rather than fails on any
other runner, because a loaded shared runner cannot falsify a latency claim.
BM-IOT-02, wire bytes. Captures the serialized length of every frame crossing a tier boundary
and fails when the total disagrees with the sum of the frame lengths the transport adapter
recorded. It is the gate over the measured half of 5.17, and it exists because the computed half,
deadband_suppressed and aggregation_reduced, is bookkeeping derived from the same config that
produced the filtering and cannot corroborate itself.
The hardware-in-the-loop path has no benchmark, and 5.26 says why. The nightly CI job builds the
esp32 target and boots it under the Espressif QEMU fork, asserting it reaches TIME_SYNC_WAIT
and publishes a valid boot record, which proves the build and the early state machine and not the
radio. just hil-selftest is run by a human against real hardware and its output, with the date,
the firmware hash, and the git SHA, is committed to artifacts/hil/. The README says exactly
that.
7.7 Golden files¶
Under tests/golden/, regenerated only by an explicit just golden-update, with the diff
reviewed in the pull request:
| File | Produced by | Normalization applied before comparison |
|---|---|---|
runbook-F-DEV-DRIFT.md |
5.27 | none: the runbook is deterministic given the signature and the statistics |
runbook-F-NET-BROKER.md |
5.27 | none |
edge-placement-report.md |
5.16 | none |
fleet-compliance-report.md |
5.28 | run id and sim-time window replaced with fixed tokens |
catalog-summary.md |
3.2 | none: the eight category tables the README embeds |
sparkplug-nbirth.bin |
5.4 | none: the encoded frame, byte for byte |
sparkplug-dbirth.bin |
5.4 | none |
parity-corpus/ |
IC-IOT-03 | none: the shared Python and Rust transition traces and encoded payloads |
test_architecture_placement_block_is_current compares the sha256 of the placement block in
ARCHITECTURE.md against the sha256 of the generated report, and
test_architecture_uns_examples_round_trip parses the fenced UNS examples out of ARCHITECTURE.md
and asserts each round-trips through UnsPath. Both exist so a generated artifact transcluded
into a hand-maintained document cannot drift from it.
7.8 Internal consistency and determinism checks¶
These are labeled internal because they compare the implementation against itself or against its own analytic solution. They are useful and they are not external validation, and separating them from 7.4 is what stops a reader counting them as evidence.
| Id | What it checks |
|---|---|
| IC-IOT-01 | The import graph over the nine Python bricks is acyclic, every name in each __all__ is defined in that package, and the forbidden edges (fleet to device, sensors to rf) are absent (D-09). |
| IC-IOT-02 | The path-loss exponent model at n = 2 agrees with the free-space closed form to 1e-12 relative. |
| IC-IOT-03 | The Python and Rust agents produce identical state-machine transition traces and identical encoded payloads over the shared parity corpus (D-06). |
| IC-IOT-04 | Every schema under the two prefixes evolves additively within a major version; a schema diff test fails any field removal or type narrowing (C3). |
| IC-IOT-05 | The modeled p99 for each function class equals the sum of its declared stage_costs_ms to 1e-9, and edge.tier.decision.v1 carries latency_source: model. |
| IC-IOT-06 | Each of the nine bricks installs alone in a clean environment and imports, with no optional extra present (D-10). |
| IC-IOT-07 | scripts/checks/spec-xref.py regenerates the "Where covered" column of 1.1 from the headings that exist in this file, and fails when a cited subsection is absent. |
Determinism is asserted three ways, and the claim is the two-tier one D-05 fixes.
VG-DET-IOT-01 runs the S-IOT-06 scenario twice in one process and once in a fresh process on the
same platform and pinned lockfile, and asserts byte-identical events.ndjson, byte-identical
runbooks, and a byte-identical placement report. That is the byte-identical tier.
VG-DET-IOT-02 runs the same scenario under two different PYTHONHASHSEED values and compares
event-log hashes, which is the D-03 check that no set iteration order reaches the tape.
VG-DET-IOT-03 runs the same scenario across the supported platform set and asserts that the
business event stream is identical and that continuous fields agree within the tolerance derived
from the measured divergence. It reports the observed maximum divergence into
artifacts/determinism/crossplatform_{date}.json rather than asserting a number chosen in
advance, and when the observed maximum exceeds the recorded tolerance it names which of the two
explanations applies, a wrong tolerance or a real defect. That is the value-equivalent tier, and
this section claims nothing stronger across platforms.
test_benchmark_timings_never_enter_the_tape and
test_run_started_carries_no_wall_clock_or_platform_field are the two D-01 and D-02 guards this
section is responsible for on its own producers.
7.9 Runtime budgets¶
| Tier | Budget | Notes |
|---|---|---|
| unit | 90 s | the thirteen ladder classifications and the Sparkplug codec cases dominate |
| property | 240 s | Hypothesis derandomize profile in CI, max_examples tuned per invariant; INV-RF-1 to INV-RF-5 are the expensive ones |
| end-to-end | 900 s | seventeen scenarios; S-IOT-16 at 500 devices and S-IOT-01 at 200 devices are the two that set the ceiling |
| validation gates | 420 s | VG-IOT-12's 200-replication sweep dominates; every other gate is a fixture comparison |
| conformance and parity | 300 s | the TCK run and the IC-IOT-03 corpus, both container jobs |
| benchmarks | 240 s | BM-IOT-01 and BM-IOT-02, reference runner only, reporting rather than failing elsewhere |
| nightly QEMU boot | 600 s | the esp32 build plus the emulated boot; not on the pull-request path |
The budget arithmetic is itself a test (D-13). test_scenario_budget_arithmetic sums the declared
per-scenario device counts and horizons, multiplies by the measured per-device-second cost from
the most recent benchmark artifact, and fails when the projection exceeds the tier budget. A
scenario that grows past its job budget fails as a defect with a named cause rather than as a
timeout with none.
8. Phase placement¶
Phases and their contents are owned by docs/design/roadmap.md, and this section places its own
pieces inside them rather than proposing a sequence of its own. Where a piece of this section
lands earlier than the source's own ordering would put it, the roadmap already carries the
resequencing with its id and its evidence, and 8.2 names each one.
8.1 Where each piece lands¶
| Piece | Phase | Why here |
|---|---|---|
UnsPath, SparkplugIds, ResourceSelector grammar, the eight-key CatalogEntry schema, the 50 event schemas |
P0 | C3 contracts cannot be retrofitted, and 3.9 says changing the selector grammar later rewrites 80 catalog files. D-07 also fixes producer_id and seq before the first schema freeze. |
twinflow-contracts in full, including AuthorityTier, RunbookRef, SignatureId, Quality, HealthScore |
P0 | D-09 gives every public symbol one owning package, and a leaf package of shared value types is what stops a later brick importing a heavier sibling to name a type. |
The five kernel port signatures this section raises on the core section (2.1), including the four-call Network port |
P0 | The core section freezes its ports at P0, and the thirteen-value ladder classification of 5.11 is unimplementable behind a single connect. |
twinflow-sensors loader, the plugin registry, FaultInjector, and the three signal-model kinds the P1 entries need |
P1 | Component 2's walking skeleton needs one RFID portal and one temperature sensor, and both are catalog entries from the first commit. The other nine kinds arrive with the first category that needs them. |
Catalog entries rfid_portal_reader_uhf, rfid_tag_passive_uhf, rtd_pt100 |
P1 | The three types the walking skeleton names. The other 77 arrive with their subsystems. See 8.2 on the read channel's P1 signal model. |
twinflow-sparkplug codec, EdgeNodeSession, alias tables, the UNS mirror, the ARCHITECTURE.md UNS section |
P1 | Roadmap R02 puts E3 at P1 because it is the wire format. The evidence is in 8.2. |
twinflow-device Python agent: state machine, time gate, backoff, hold-after-N, boot record, diagnostic ladder |
P1 | The agent is the thing that publishes, and every later phase's telemetry travels through it. |
twinflow-buffer with DeadbandPolicy and BufferCounters |
P1 | Tier-0 deadband is a P1 behavior because edge_tier_default is a P1 catalog field. StoreAndForward durability lands at P4. |
ComputeTier, FunctionPlacement, PlacementTable, the edge.placement config block, the ARCHITECTURE.md placement table |
P1 | Roadmap R51 splits E36 and puts E36a, the tier taxonomy and the latency-budget column, at P1 so every subsystem is annotated as it lands. |
AuthorityTier populated with E5a's autonomy metadata |
P1 | 2.2 and 5.19 need the enum to exist before any action carries a tier. The register it writes to arrives at ECON. |
twinflow-rf read-zone geometry, link budget, read_probability, MissCause, PhysicalConstant |
P2 | Roadmap R49 splits E46 and puts E46a at P2, because P2 runs a Gage R&R on the read process and a study over a process with no physical model measures an abstraction. |
| VG-IOT-04, VG-IOT-11, VG-IOT-12, VG-IOT-15, IC-IOT-02 | P2 | The RF gates land with the RF model. P2 cannot close until each is passing or recorded as an open question. |
Catalog categories industrial_equipment and environmental_facility, 23 entries; the full twelve-kind set; test_catalog_scales_to_500; INV-CAT-3 |
P3 | The roadmap puts the first two catalog categories at P3, and the author's constraint is that categories grow with their subsystems. The twelve-kind claim of 3.2 is testable once more than three kinds exist. |
twinflow-device-agent (Rust), --features host, VG-IOT-02, IC-IOT-03, VG-IOT-13 |
P3 | Component 2's Rust agent is a P3 deliverable. The RNG parity gate lands with it because D-06 is a claim about the pair, not about either alone. |
twinflow-fleet: registry, HealthScorer, FMEA tables and priors, MttdAccountant, degraded flagging |
P3 | Component 3 is a P3 deliverable, and it is the first consumer of the fault ground truth the catalog declares. |
twinflow-pdm in full, VG-IOT-07, VG-IOT-08, VG-IOT-09, VG-IOT-10 as an open question |
P3 | Predictive maintenance is P3 and consumes the trend estimators. VG-IOT-10 has no retrievable reference and is recorded in section 9 rather than passed. |
maintenance.workorder.requested.v1 with a nullable runbook_ref |
P3 | The CMMS loop is P3. The reference is nullable until the generator exists, and the schema field is additive-safe because it is present from P0. |
Antenna placement what-ifs, read-rate-by-portal charts, twinflow.rf.diagnose, cross-read generation at fleet scale |
P3 | Roadmap R49 puts E46b at P3, in the fleet-health layer that the read-rate charts belong to. |
Catalog categories warehouse_logistics (15 entries) and electrical_power (8 entries) |
P3b | The automation and robotics layer is their subsystem, and it lands at P3b. |
Catalog category transportation_fleet, 10 entries |
P3h | The transport network and the cold-chain capability are their subsystem. |
Catalog category process_chemical, 10 entries |
P3i | Upstream production is their subsystem. |
The append-only decision register accept writes to |
ECON | E21a lands at ECON per roadmap R15. accept is refused at config validation until it exists. |
Catalog categories safety_compliance (9 entries) and structural (5 entries) |
6a10 | The worker-safety and ergonomics layer is their subsystem, and safety findings carry their own severity floor once it exists. |
camera_area_scan_synthetic catalog entry and its device-agent behavior |
P4 | Component 4 CV auditing is P4. This section owns the entry and the UNS path; frame generation and the audit logic belong to the CV section. |
StoreAndForward, SpillingStoreAndForward, the QoS matrix as a live config surface, retained-message handling |
P4 | Component 6c is a P4 deliverable. INV-SF-1, INV-SF-2, INV-SF-3, S-IOT-01, S-IOT-02, S-IOT-03, and S-IOT-15 activate here. |
| mTLS, the internal two-level CA, certificate-derived broker ACL, CRL reload, VG-IOT-16, S-IOT-11 | P5 | The source places mTLS at Phase 5, and the roadmap's P5 row carries it. |
Sparkplug TCK run and the committed artifacts/tck/ evidence |
P5 | P5 is where the README makes its conformance claim, and a claim with no committed artifact is the thing 7.4.1 exists to prevent. |
twinflow-edge gateway process, LinkAccountant, PlacementReport, DegradationReport, BM-IOT-01, BM-IOT-02, S-IOT-13, S-IOT-17 |
P6-W1 | Roadmap R51 puts E36b, the measured half, at P6-W1. The tier annotations it measures against exist from P1. |
ProvisioningAuthority, ProvisioningClient, DeviceTwin, Reconciler, OtaCampaign, CampaignEngine |
P6-W1 | E44 lands at P6-W1. INV-OTA-1, INV-OTA-2, INV-DRIFT-1, S-IOT-09, and S-IOT-10 activate here. |
Hardware in the loop: --features esp32, just hil-flash, just hil-selftest, the QEMU nightly |
P6-W1 | E47 lands at P6-W1, and roadmap R41 already requires E44 ahead of it because the physical device enrolls through the same zero-touch flow. |
RunbookGenerator, CompliancePolicy, ComplianceAuditor, INV-CAT-2, the 5.27 coverage test |
P6-W5 | E48 lands at P6-W5. The types are in twinflow-contracts from P0 so no schema changes when the generator arrives. |
Seven invariants and nine scenarios activate later than P3, and each one is named above rather
than left to a reader to notice. INV-SF-1, INV-SF-2, and INV-SF-3 activate at P4 with
store-and-forward, together with S-IOT-01, S-IOT-02, S-IOT-03, and S-IOT-15. S-IOT-11 activates at
P5 with mTLS. INV-OTA-1, INV-OTA-2, and INV-DRIFT-1 activate at P6-W1 with device lifecycle,
together with S-IOT-09, S-IOT-10, S-IOT-13, and S-IOT-17. INV-CAT-2 activates at P6-W5 with the
runbook generator. Until its phase, each
is registered in the suite as PENDING with its phase recorded, and just phase-gate refuses to
close a phase that still holds a PENDING invariant assigned to it. An invariant is never
deleted to make a phase close.
8.2 Resequencings this section depends on¶
Each of these moves a Phase 6 item earlier. None is proposed here; each is already recorded in
docs/design/roadmap.md with the evidence line from the source, and this section cites the id so
a reader can check the reasoning at its home.
| Roadmap id | Move | The requirement that forces it |
|---|---|---|
| R02 | E3 Sparkplug to P1 | The payload is the wire format. Arriving at P6 means every recorded run before it is in a different format, which breaks E1's replay bundles, E4's counterfactual replay, E25's dataset exports, and C6's compatibility table. |
| R49 | E46 split, E46a to P2 and E46b to P3 | P2 runs a Gage R&R on the read process, and a measurement-system study over a process with no physical model measures an abstraction. Read-rate-by-portal control charts are a fleet-health question, and fleet health is P3. |
| R51 | E36 split, E36a to P1 and E36b to P6-W1 | Every subsystem carries a compute-tier annotation as it lands, so the taxonomy is a P1 shape constraint. The bandwidth-saved measurement needs a gateway to measure. |
| R41 | E44 ahead of E47 | The physical device enrolls through the same zero-touch provisioning flow, so lifecycle management must exist before hardware in the loop can use it. |
| R15 | E21a decision register to ECON | 5.19's accept action writes to the register, and E44's reconciler lands at P6-W1, after ECON. |
One forward dependency remains after those five, and it is resolved by restating the P1
capability rather than by moving anything. rfid_portal_reader_uhf is a P1 device and 3.2 assigns
its read channel the rf_read_zone kind, but twinflow-rf lands at P2 under R49. At P1 the read
channel uses the event_stream kind, driven directly by twin pallet passes with a configured read
probability, which is enough for the walking skeleton to count reads end to end. At P2 the entry's
signal_model.kind changes to rf_read_zone and nothing else about the entry changes, because
the channel id, the measurand, the unit, the datatype, and the UNS parameter are all fixed at P0
and 6.4 forbids an override from touching any of them. That is a one-line catalog edit and it is
the same edit an adopter would make, which makes it a demonstration of the "type 300 is YAML only"
claim rather than an exception to it. twinflow-device's dependency on twinflow-rf arrives with
the same P2 change, and adding a dependency inside a minor version is additive under C9.
test_read_channel_kind_upgrade_is_yaml_only pins it: it loads the P1 entry, swaps the kind,
reloads, and asserts the bound channel's UNS topic, alias assignment, and event schema are
unchanged.
The AuthorityTier trace 2.2 and 5.19 both refer to reads across three of those rows.
AuthorityTier lands in twinflow-contracts at P1 with E5a's autonomy metadata. The append-only
decision register lands at ECON with E21a. E44's reconciler lands at P6-W1. P1 precedes ECON,
which precedes P6-W1, so both prerequisites of the accept action exist before the action is
written, and no further resequencing is needed. Config validation refuses accept in any
compliance policy while either prerequisite is absent, and the reconciler treats a policy naming
it as a load error, so the action is never silently unavailable at runtime.
8.3 The cost of the Rust and ESP32 targets in CI¶
5.26 says the CI cost of the esp32 target is priced here rather than assumed, because the
esp-idf toolchain is not free to provision on a hosted runner. The three jobs and what each buys:
| Job | Trigger | What it provisions | What it proves |
|---|---|---|---|
rust-host |
every pull request | the stable Rust toolchain, cached | the crate builds and its unit tests pass with --features host |
parity |
every pull request | the same toolchain plus the Python environment | IC-IOT-03 and VG-IOT-02 over the shared corpus |
esp32-qemu |
nightly, and on a tag | the esp-idf toolchain and the Espressif QEMU fork, cached | the esp32 target builds and boots to TIME_SYNC_WAIT with a valid boot record |
The esp-idf toolchain and the QEMU fork are cached by toolchain version, so the provisioning cost
falls on the first nightly after a version bump rather than on every run. The job is off the
pull-request path deliberately: a contributor changing a catalog YAML file must not wait on an
embedded toolchain. C10's stated CI wall-time budget covers rust-host and parity on
the pull-request path, and esp32-qemu is budgeted separately as a nightly.
None of the three proves the radio. just hil-selftest is the only thing that does, it is run by
a human, and its committed output in artifacts/hil/ is what the README cites.
9. Open questions¶
These are genuine ambiguities in the source, genuine conflicts between locked decisions, or claims whose primary evidence could not be retrieved. None has been resolved by invention, and none of them is a requirement that has been dropped.
9.1. What arbitrates a QoS conformance departure, and may the TCK result be redistributed?
5.4 makes every Sparkplug QoS and retain value normative and read from the specification, and
transport.conformance_departures is the only legal way to depart. Two things need the author's
decision. First, whether a departure is permitted at all in a shipped profile, or only in
a test arm such as the second arm of S-IOT-15; the spec currently permits it anywhere with a
named rule id and marks the run non-conformant. Second, whether the Eclipse Sparkplug Technology
Compatibility Kit's result file may be committed to a public repository, which depends on the
TCK's own license and was not settled here. If it may not, 7.4.1's committed artifact becomes a
summary rather than the raw result, and the README claim changes accordingly.
9.2. An EPL-2.0 file vendored into an Apache-2.0 repository with a commercial option.
sparkplug_b.proto is vendored under its own EPL-2.0 license into schemas/iot/sparkplug/,
recorded in THIRD_PARTY_NOTICES.md, and excluded from the Apache-2.0 relicensing sweep. EPL-2.0
is a file-level weak copyleft, so vendoring one file and keeping it under its own license is the
ordinary arrangement, and the generated Python module derived from it inherits the question. This
needs a legal read before release, on the same footing as D-14's note about PM4Py
as a development-only oracle. The alternative is to generate the codec from a hand-written schema
that is not a derivative of the Eclipse file, which costs work and loses the guarantee that the
wire format matches the specification's own definition byte for byte.
9.3. Can the AIAG and VDA Action Priority table be reproduced lawfully? 5.13 ships
fleet/ap_table.yaml as a repo-defined table with its own documented derivation rule, and makes
no claim that it matches the published one. The handbook is a paid publication and its page
returned HTTP 404 on 2026-08-09. Two options. Transcribe the published lookup, which is a factual
table of 1000 rows and is the kind of thing that may or may not be copyrightable depending on
jurisdiction, or keep the repo-defined table and document the difference. The spec assumes the
second. An adopting organization with its own licensed copy replaces the file.
9.4. Do the shipped profiles default to one edge node per line or one per device? 4.1
defaults edge_node_id_template to {line}, so one agent process per line hosts every device on
that line, and offers {line}-{equipment} for a deployment that wants one process per device.
The first is what a real gateway-fronted fleet looks like and it is cheaper in sessions. The
second matches the mental model of most readers, who expect a device to be an MQTT client. The
demo's legibility argues for the second and the architecture argues for the first. The three
worked profiles from A2 could reasonably differ from each other here, and the author picks
which default leads in the README.
9.5. Which broker does CI run, and which one does the README quickstart name? A3 names three
tiers and 2.1's Network port has adapters for Mosquitto, EMQX, and NanoMQ. 6.6 puts NanoMQ at
the gateway and 5.16 puts EMQX at the site, which is a two-broker topology in the compose file.
Running all three in CI triples the container job. Running one leaves the other two adapters
untested against a real broker. The adapter conformance suite splits by port under D-08, so the
question is which of the three is the one CI must always run, and whether the other two run
nightly.
9.6. Is 191 channels across 80 entries the right shape? 3.2 sums to 191 channels, an average of 2.4 per type, ranging from one to six. That number drives the Sparkplug alias table size, the UNS topic count, the historian's column count, and the load-time budget in 6.2. A reader might reasonably expect either a leaner catalog with one channel per type and more types, or a richer one with more channels per type and fewer. The current shape follows the source's instruction that each type unlocks a capability, and the capability registry enforces it, but the author confirms whether 191 is the number the README quotes rather than 80.
9.7. Does the ground-truth Delta root need protection beyond path separation? 5.12 puts the
firewall in the physical separation of two Delta roots plus a DuckDB session with
enable_external_access off, because delta-rs and DuckDB have no roles or grants. That is
enforceable and INV-GT-1 tests it. It is not a security boundary against a process running as the
same user. Whether that matters depends on whether an adopter ever runs the agent against a
historian holding real labels, which twinflow itself never does because all data is synthetic.
The question is whether to document the limit plainly in SECURITY.md or to add a second mechanism.
9.8. The MSA variance components are modeling assumptions, not gauge studies. 3.1 requires
every channel to declare repeatability_sigma and reproducibility_sigma, and the shipped values
carry msa_source: "repo-defined modeling assumption, not a gauge study". That is honest and it
means the LSS engine's Gage R and R has a declared ground truth to recover rather than a measured
one. VG-MSA-05 in the LSS engine section scores recovery against the declared components, which is
a valid test of the estimator and not evidence about any real gauge. If the author wants a
published gauge reference for even one channel, it would have to come from a manufacturer
datasheet with a stated repeatability figure, and none was retrieved for this spec.
9.9. Product or weighted sum for the health score? 5.13 uses
100 * PROD(1 - min(1, RPN_i / 1000) * c_i). The product compounds independent problems, which is
the behavior the fleet layer wants, and it satisfies the seven properties INV-HS-1 through
INV-HS-7 assert. It also drives the score toward zero quickly when many low-severity modes are
active, which can read as alarmist on a large fleet. A weighted sum with a cap behaves better
there and compounds worse. No external reference governs the choice, so it is a design decision
the author confirms rather than a fact this spec can cite.
9.10. The cross-platform float tolerance is not yet a number. D-05 requires the value-equivalent tier to state a tolerance derived from measured divergence. VG-DET-IOT-03 reports the observed maximum into a dated artifact and compares it against a recorded tolerance, and that tolerance cannot be written until the first measurement runs on the supported platform set. Until then the gate records the observation and does not assert a threshold, which is the honest state and is recorded here rather than filled with a plausible number.
9.11. The Schoute paper is behind a paywall. VG-IOT-12's reference is Schoute, "Dynamic Frame
Length ALOHA", IEEE Transactions on Communications, volume 31, number 4, 1983, pages 565 to 568.
The DOI resolver returned HTTP 202 with an empty body on 2026-08-09 and no full text was
retrieved. The gate is carried at single-source confidence with the attribution stated in text. A
retrieved copy, or a second independent published statement of the 1/e maximum, would raise it.
The Gen2 half of the claim is unaffected, because VG-IOT-11's reference was retrieved in full.
9.12. Where do the Mann-Kendall and Theil-Sen reference datasets come from? VG-IOT-07 and
VG-IOT-08 name Examples 12.1 and 12.2 of the USGS report, whose text and printed statistics were
retrieved, but whose underlying data series were not. The report is a US Government work in the
public domain, so redistribution is not the obstacle; sourcing is. The Big Sioux River series in
Example 12.2 is annual mean discharge from a National Water Information System gage and can be
retrieved by gage number, and the Milwaukee River chloride series in Example 12.1 needs the
report's companion data release. Until both are checked into validation/data/ with a provenance
line, the two gates are registered as PENDING rather than passing.
9.13. How exactly do twinflow quality codes map to OPC UA status codes? 2.2 declares
Quality as GOOD | UNCERTAIN | BAD | SUBSTITUTED and calls it OPC UA style. That is four values
against a specification that defines a 32-bit status code with a two-bit severity and a large
sub-code space. The four-value enum is the right shape for this repo, and calling it OPC UA style
rather than OPC UA compliant is deliberate. What is unresolved is whether the RA(e) OPC UA bridge
at P5 needs a documented total mapping in both directions, which would make the bridge honest, or
whether a one-way mapping into the four values is enough for what the bridge demonstrates.
9.14. Is 80 the right count, and is status: planned ever acceptable? The source states a
band of roughly 60 to 80 implemented types and gives the reason: the skill on show is the catalog
architecture, not the count. 3.2 takes the top of the band. catalog.allow_status_planned is
false in a release build, so a planned entry cannot ship, which means the count is the implemented
count and not an aspiration. If the author would rather the README quote a smaller number with
deeper models per type, the change is deleting entries from category files and adjusting the
counts in 3.2, and nothing else moves.
9.15. The occlusion losses are modeling assumptions with no measured source. 5.21 ships
none: 0.0, cardboard: 0.5, liquid: 6.0, and metal: 20.0 dB per layer, each carrying the
literal string "repo-defined modeling assumption, not a measurement", because no published
measurement series for these materials at 900 MHz was retrieved for this spec. INV-RF-7 asserts
the ordering and the monotone decline in depth_in_pallet, which is the part a test can prove.
Replacing the magnitudes with measured values from a named published series would upgrade the
claim from ordering to magnitude, and it is the single highest-value evidence gap in the RF model.
9.16. Nowlan and Heap could not be retrieved. The P-F interval concept that grounds the detection rating in 3.6 and VG-IOT-06 is cited to Nowlan and Heap, "Reliability-Centered Maintenance", United States Department of Defense, 1978, DTIC accession AD-A066579. The DTIC record refused automated retrieval on 2026-08-09 with HTTP 403. The citation is carried at single-source confidence with the attribution stated in text. An accessible primary copy, or a second independent published statement of the interval's definition, would raise it. The gate itself does not depend on a numeric value from the source, only on the definition, which is why it remains a gate rather than becoming an open question outright.
9.17. The brownout model has no measured board. 5.9 states the relation
V_rail = V_supply - I_tx * R_supply with a threshold, and 6.4 holds all four constants plus a
transmit-current curve, every one of them labeled as a repo-defined modeling assumption. The
model produces the right qualitative behavior, INV-BRN-1 asserts the causal link the runbook
depends on, and test_brownout_needs_tx proves the absence case. What is missing is one measured
current curve for one real board, which the HIL device at P6-W1 could supply and which would turn
the whole model from plausible to grounded.
9.18. No retrievable reference for the bearing defect frequency formulas. VG-IOT-10 is
recorded as an open question rather than a passing gate, per D-11 rule 5. The four formulas in
5.14 are standard, and their algebraic consequences (BPFO == Nb * FTF, BPFI > BPFO) are
asserted internally, but no external published statement of them was retrieved for this spec. The
likely candidates are a bearing manufacturer's engineering reference or an ISO vibration standard,
and the same question covers the limit_source for vibration_velocity_iso, whose alarm limits
currently carry no external clause either. Until one is retrieved, the README's validated-statistic
count does not include VG-IOT-10.
9.19. Gen2 Release 3.0.1 against ISO/IEC 18000-63. 5.22 cites the GS1 EPC Gen2 standard, Release 3.0.1 of February 2026, whose full text was retrieved. The corresponding ISO standard, ISO/IEC 18000-63, tracks it at a lag and its page returned HTTP 403 on 2026-08-09, so any divergence between the two at the parameters this model uses is unverified. The spec cites the GS1 document because it is the one that was read. If the author wants the ISO citation in the README for the audience that recognizes it, the divergence needs checking first, and naming both without checking would be the kind of citation added after the sentence was written.
9.20. Does the Rust crate share one version number with the Python bricks? 2.11 says the crate is published to crates.io by the same automated release job as the Python bricks, and C9 requires lockstep versions across bricks. Rust and Python semver conventions differ on what a major bump means for a pre-1.0 crate, and crates.io yanking is not the same operation as a PyPI release deletion. The question is whether the crate genuinely shares the version number, which is simple and occasionally wrong, or carries its own with a compatibility table, which is honest and one more thing to maintain.