Skip to content

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 host links tokio, rumqttc, and rustls. It joins the simulated fleet as a normal edge node.
  • --features esp32 builds against esp-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_id is unique across all category files, and category is a scalar. An entry lives in exactly one category file. A subsystem in another category that consumes the entry expresses that through attaches_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_unlocks id resolves in catalog/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, a baseline block, a noise block, and a drift block. drift may be {kind: none} only with a why string, so an entry cannot silently have no drift characteristic (INV-CAT-1).
  • Every channel carries an msa block with repeatability_sigma and reproducibility_sigma, or an explicit msa_note excluding it, so the LSS engine's Gage R and R study has declared variance components to be scored against.
  • Every unit resolves 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 in catalog/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 inside range.
  • Every signal_model.kind and failure_modes[].kind resolves in the plugin registry, and every failure_modes[].catalog_id resolves in the fault catalog of docs/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_source string.
  • 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 by type_id, capability_unlocks and device_faults keep 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 different PYTHONHASHSEED values 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 -> degraded when health_score < fleet.health.degraded_below sustained for fleet.health.dwell_s.
  • degraded -> healthy when health_score >= fleet.health.recovered_above sustained 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.
  • * -> failed when health_score < fleet.health.failed_below or a mode with severity >= 9 is active.
  • * -> quarantined on a compliance violation with action: quarantine, on a revoked certificate, or on a duplicate_client_id detection. A quarantined device's publishes are refused by broker ACL, not merely ignored.
  • -> retired only 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:

selector  := segment ( "." segment )*
segment   := name | "*" | "**"
name      := [a-z0-9][a-z0-9_-]{0,31}

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:

  1. Resolve SubsystemState from the twin through attaches_to (running, idle, faulted, speed, load) and AmbientState (facility temperature, humidity).
  2. Call the registered SignalModel.sample(ctx) to produce clean_value.
  3. Apply the ordered chain of FaultTransform for every active fault on this channel, producing observed_value and accumulating fault_tags. The chain order is the catalog declaration order, which is a list, never a set (D-03).
  4. Apply quantization (resolution) and clipping (range), setting quality = uncertain on clip.
  5. Apply the tier-0 deadband policy. A suppressed sample increments deadbanded on BufferCounters and 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.

  1. Increment bdSeq in NVS, which persists across reboots. A duplicate bdSeq after a reboot is a defect the boot record makes visible.
  2. Register NDEATH as the MQTT last will, carrying that bdSeq.
  3. 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.
  4. Publish NBIRTH with seq = 0, carrying every node metric with its DataType and its alias assignment, plus Node Control/Rebirth, bdSeq, and the node properties (firmware version, hardware rev, boot record summary, sf_dropped_records).
  5. Publish one DBIRTH per attached device, carrying every metric that device will ever publish. A metric absent from DBIRTH may never appear in DDATA. ConformanceMatrix enforces this at encode time, raising rather than publishing an invalid stream.
  6. Thereafter publish NDATA and DDATA using aliases only, with seq incrementing 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

delay = uniform(0, min(cap_ms, base_ms * 2 ** attempt_index))

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.

  1. DNS. Resolve the broker hostname through Network.resolve. Failure classifies to dns_nxdomain or dns_timeout.
  2. TCP. Connect to the broker port through Network.tcp_connect. A tcp_refused result means something answered and said no. A tcp_timeout result means a middlebox silently dropped the SYN, which is the signature of an egress filter.
  3. TLS. Complete the handshake through Network.tls_handshake. A tls_unknown_ca result with a peer certificate whose issuer is not our CA means a TLS-inspecting middlebox is terminating the session. A tls_notbefore result means our clock, not our certificate. A tls_expired result means our certificate, not our clock. A tls_hostname_mismatch result means config. A tls_handshake_timeout result means interception without a valid substitute chain.
  4. MQTT. Read CONNACK through Network.mqtt_connect. A mqtt_not_authorized result means the broker ACL refused our identity. A mqtt_identifier_rejected result 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_i falls back to fleet/occurrence_prior.yaml, which holds one band per (mode_id, device_type) pair with a documented basis, until the trailing window holds at least fleet.health.min_occurrence_observations device-hours (default 168).
  • D_i falls back to fleet/detection_prior.yaml, keyed on (detector_id, mode_id), until at least fleet.health.min_detection_events matched detections exist (default 10).
  • While either prior is in use, c_i is multiplied by fleet.health.prior_confidence_penalty (default 0.5), and fleet.health.scored.v1 carries prior_used naming 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.

  1. Duty-cycle correction. Raw motor temperature is dominated by whether the motor is running. DutyCycleRegression fits 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.
  2. 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.

  1. 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. OrdinaryLeastSquares runs on the same series as the parametric comparator, and the bakeoff publishes both slopes so robustness is shown earning its place.
  2. 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."
  3. EWMA on the duty-cycle-corrected residual for change detection on the same series, giving a second, faster detector with a different failure profile.
  4. 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).
  5. Emission of pdm.prediction.v1, and when tt falls inside the maintenance planning horizon, maintenance.workorder.requested.v1 with 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 URI urn: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)

  1. The device boots with a birth credential in NVS: an X.509 certificate signed by twinflow-manufacturing-ca with CN={serial}. For the ESP32 this is written at flash time by just hil-flash.
  2. The time gate passes.
  3. The device connects to the provisioning endpoint over mTLS presenting the birth certificate.
  4. ProvisioningAuthority checks that the chain validates to the manufacturing CA, that serial is on fleet/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.
  5. The device generates a keypair locally and sends a CSR.
  6. The authority issues the operational certificate, assigns a UnsPath and an edge_node_id from fleet/placement_policy.yaml (a rule set mapping serial ranges and device types to areas and lines), and returns the desired config document.
  7. The device stores the certificate and config in NVS, reconnects to the operational broker, and publishes NBIRTH.
  8. 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.

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):

P_rx(dBm) = P_tag - L_backscatter + G_tag + G_ant - L_cable - PL(d) + fading

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.

  1. HIL forces production mode. A physical device cannot obey a virtual clock. hil.enabled: true implies mode: production, and the run manifest records deterministic: 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.
  2. The physical device publishes only. Its inbound command surface is restricted to Node Control/Rebirth and OTA. It actuates nothing. A test asserts that any other DCMD to a device with physical: true is refused at the gateway.
  3. CI cannot prove the hardware path. A nightly 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. 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 to artifacts/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.
  4. 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.

  1. Every devices.instances[].type_id resolves in the catalog, and every edge_node_id on an instance resolves in devices.edge_nodes (INV-SPB-2).
  2. Every edge_nodes[].devices[] id resolves in devices.instances, and the two directions agree, so a device cannot be hosted by a node that does not list it.
  3. No two edge nodes share an edge_node_id, and no two agent processes claim one (INV-SPB-1).
  4. Every (attaches_to, uns_parameter) pair across the bound fleet is unique, so no two channels collide on a UNS topic (INV-UNS-1).
  5. Every attaches_to selector resolves to at least one resource in each of the three worked profiles from A2 (INV-CAT-5).
  6. 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-4).
  7. Every failure_modes[].catalog_id resolves in docs/design/variability-and-faults.md C.4 or C.5, and every C.4 id this section owns appears in the 5.12 mapping (INV-CAT-6).
  8. Every signal_model.kind resolves in the plugin registry, and the set of distinct kinds across the shipped catalog has cardinality at most twelve (INV-CAT-3).
  9. Every unit resolves in the UCUM edition named in catalog/UCUM_VERSION (VG-IOT-05).
  10. warn_limit < alarm_limit < trip_limit, all inside range, on every channel that declares them, and every channel with numeric limits carries a limit_source.
  11. fleet.health.recovered_above == fleet.health.degraded_below + fleet.health.hysteresis_band, and fleet.health.failed_below < fleet.health.degraded_below.
  12. Every transport.qos_by_topic_class entry matches its conformance.yaml row, or names a departure rule id in transport.conformance_departures (INV-QOS-1), and every conformance.yaml row with a null rule_id carries a written basis (INV-QOS-2).
  13. edge.placement[].tier is at most the tier of the gateway serving the device, and every function's stage_costs_ms sums to at most its latency_budget_ms (IC-IOT-05).
  14. rf.regulatory radiated 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).
  15. rf.singulation.q_max <= 15 and rf.singulation.slot_counter_bits == 15.
  16. deployment.adapters is absent whenever simulation.mode is simulation (D-12).
  17. security.epoch_floor: build_timestamp and hil.enabled: true each need simulation.mode: production.
  18. pdm.window.stride_s divides pdm.window.length_s, and pdm.learned.inference_port is recorded whenever simulation.mode is simulation (D-04).
  19. pdm.learned.model_artifact_hash is non-null whenever a learned detector appears in pdm.bakeoff.detectors and the run is not a bakeoff-only run.
  20. ota.campaign_defaults.rings is non-empty, ring size_pct values are non-decreasing, and the last ring is 100.
  21. Every path in fleet.drift.security_critical_paths is a valid RFC 6901 JSON pointer and resolves in the desired config document of at least one device type.
  22. transport.broker.keepalive_s satisfies every value_range rule in fleet/policy.yaml that 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 classification value in iot.device.diag.ladder.v1, thirteen failure values plus ok, each driven by a Network fake configured to fail at exactly one rung (5.11). The fake fails at the rung, not at the call, which is why the port exposes resolve, tcp_connect, tls_handshake, and mqtt_connect separately (2.1).
  • peer_issuer is populated on tls_unknown_ca and 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.
  • seq wraps 255 to 0 and never skips; NBIRTH carries seq = 0; a rebirth resets seq to 0 and keeps bdSeq.
  • bdSeq increments in NVS across a simulated reboot, and the NDEATH will registered at CONNECT carries the same value the following NBIRTH carries.
  • UnsPath construction refuses a segment containing /, +, #, or an uppercase letter, and the error names the offending segment.
  • UnsPath.from_resource and to_resource on 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>.jitter stream and lands in [0, min(cap_ms, base_ms * 2 ** attempt_index)] at every attempt_index from 0 to 20.
  • The retry index resets after stability_s online 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 beyond epoch_ceiling_years.
  • StoreAndForward drop policy: oldest and newest each drop exactly the record the policy names, and dropped_records_total increments by exactly one per drop.
  • Deadband: a suppressed sample increments deadbanded, is written to the ground-truth stream, and is published anyway once max_publish_interval_s elapses.
  • 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.
  • MttdAccountant on a constructed pairing set containing one true positive, one false positive, one miss, and one detection earlier than detectable_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.
  • TimeToThreshold returns an unbounded interval exactly when the Theil-Sen slope confidence interval straddles zero, and the event says so rather than printing a point estimate.
  • MissCause selection: 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.