Skip to content

ADR-0008: Digital Twin as a Separate, Stateful Package Above decision

Status Accepted
Date 2026-07-04
Deciders Chief Software Architect, MineProductivity
Governs mineproductivity.digital_twin
Related documents docs/architecture/08_Digital_Twin_Design_Specification.md; docs/design/08_Digital_Twin_Implementation_Checklist.md; docs/architecture/07_Decision_Intelligence_Design_Specification.md §19, §36; ADR-0007-Decision-Intelligence.md; docs/architecture/06_Analytics_Engine_Design_Specification.md (not yet merged to main - see feature/analytics-engine)

Context

MineProductivity's Foundation Layer and its first two consumer packages, analytics and decision, are complete, tested, and locked as of decision v0.9.0. The Decision Intelligence spec itself already anticipated what comes next: its own §19 defines WhatIfEngine as an interface deliberately reusing events.AsOf's already-reserved scenario field, "so a future scenario/what-if-forking capability (Digital Twin) can extend this type's usage without a breaking change" - a direct, named anticipation of this package. Spec 07's own §36 (Future Roadmap) further names digital_twin as the most direct consumer of ActionPlan and as a likely driver of RealTimeDecisionSession with simulated rather than real events, and ADR-0007's Future Compatibility section restates the same expectation. In other words, exactly as decision formalized a package analytics's own documents already assumed would exist, this ADR formalizes and designs digital_twin, the package decision's own documents already assumed would exist.

The immediate question this ADR answers: given a fleet, a plant, a conveyor, or another mining asset whose condition is scattered across raw events, computed KPIs, statistical judgments, and recommended actions, who owns holding one coherent, continuously-current representation of "what does this thing look like right now" - and, unlike every package below it in this series, who owns doing so in a way that is genuinely stateful, since "current condition" is inescapably a question about accumulated history, not a fresh computation from a single input? This ADR decides where, and why that stateful representation is not merely bolted onto decision.

Decision

Create mineproductivity.digital_twin as a new, independent package sitting directly above decision and directly below the not-yet-designed simulation/optimization/agents/visualization packages:

core → ontology → events → kpis → analytics → decision → digital_twin

digital_twin is permitted to import core, ontology, events, registry, plugins, connectors, kpis, analytics, and decision; it introduces its own public API (Twin - a core.BaseEntity[str] subclass - TwinContext, eleven twin category base classes, TwinStatus, TwinSynchronizer/SyncPolicy, TwinState, TwinSnapshot, an interface-only TwinSimulationModel, TwinRepository as a direct type alias over core.BaseRepository[Twin, str], and TwinStateCache) without requiring any change to any Foundation Layer file, or to analytics/decision, or to any of their public APIs or dependency rules. Its own registry (digital_twin.REGISTRY) is a direct specialization of registry.Registry, exactly as decision.REGISTRY, analytics.REGISTRY, and kpis.REGISTRY already are. Full design detail is in the governing specification linked above; this ADR records why the package exists and why it is shaped the way it is.

Why a Separate Package (Not Folded Into decision)

  1. Distinct responsibility, distinct rate of change. decision answers "what should happen, given already-known facts" - a prescriptive question over evidence someone else already assembled. digital_twin answers "what is currently true about this specific asset" - a representational question whose answer changes continuously as new events arrive, independent of whether any decision is ever made about it. A twin's state is meaningful and useful even if decision never evaluates a single policy against it.
  2. decision's own spec already drew this boundary. Spec 07 §19 explicitly designed WhatIfEngine to be implemented by digital_twin, not to contain digital-twin logic itself, and spec 07 §36 frames digital_twin strictly as a downstream consumer of decision's outputs (ActionPlan), never the reverse. Folding twin representation into decision would invert a dependency direction that package's own specification already locked.
  3. A fundamentally different statefulness profile. kpis.BaseKPI, analytics.AnalyticsModel, and decision.DecisionModel are each deliberately stateless (spec 05 §24, spec 06 §8, spec 07 §8) - every prior "as-object" abstraction in this series computes a fresh output from a fresh input with no memory between calls. A Twin cannot be stateless, because the entire reason it exists is to represent something that persists and evolves. This is not a minor implementation detail; it changes the concurrency contract (§29-§30 of the governing spec), the identity model (§8), and the testing philosophy (§32) enough that folding it into a package built entirely around statelessness would be a poor architectural fit even before considering responsibility boundaries.
  4. Consistent with every other layer boundary already drawn in this platform, including the two decision and analytics just established one and two layers down. A platform that has consistently drawn a package boundary at every "distinct responsibility, distinct rate of change, distinct statefulness/governance profile" seam would be inconsistent to stop doing so at the one seam (decisiondigital_twin) its own most recent specification already named.

Dependency Rationale

digital_twin is permitted to import the entire Foundation Layer plus analytics and decision, not merely decision directly, for the same structural reason ADR-0007 already established one layer down: a TwinContext (governing spec §8) legitimately needs events.EventStore/EventBus directly (for synchronization, §11/§15), kpis.KPIResult, analytics.AnalyticsResult, and decision.DecisionResult (as evidence a twin's _apply may incorporate), and ontology entity types (for the vocabulary a twin's scope is expressed in, genuinely exercised here rather than merely permitted, per the governing spec's own §5). Restricting digital_twin to only decision would have forced pass-through re-exports of types it does not otherwise need to expose, exactly the worse alternative ADR-0006 and ADR-0007 already rejected at their own layers.

connectors is included in the permitted-import list for platform-wide layering consistency, but - exactly as in analytics and decision before it - is not exercised: by the time data reaches digital_twin, it has already been normalized into events.BaseEvent subtypes by the connectorsevents pipeline (governing spec §5, §16).

Architectural Trade-offs

  • Trade-off accepted: Twin subclasses core.BaseEntity[str] and is immutable, with state changes producing a new instance (with_state()), rather than being a plain mutable Python object. This is more expensive per synchronization cycle (a small allocation instead of an in-place field write) and less familiar to an engineer expecting a "digital twin" to behave like a conventional mutable model object. It was accepted because core.BaseEntity's own documented contract already anticipates exactly this pattern ("representing a state change means producing a new instance... consistent with the platform's event-first architecture"), and because every other value/entity type in this platform is already immutable - introducing the one mutable central abstraction in the entire series would have been a far larger consistency cost than the allocation overhead, which the governing spec's own Performance Considerations (§33) judges negligible next to the I/O calls (EventStore, KPIEngine, AnalyticsPipeline) a synchronization cycle typically also makes.
  • Trade-off accepted: TwinRepository is a literal type alias over core.BaseRepository[Twin, str], not a new ABC. This is the strongest possible form of reuse available in this series - Twin is asked to satisfy BaseRepository's TEntity: BaseEntity[Any] bound exactly, with no adapter layer. The cost is that this package has no room to add digital-twin-specific repository methods beyond what core.BaseRepository already offers (add/get/find/remove/list) without either extending core itself (out of scope for a leaf package) or introducing a genuinely new ABC later. This was accepted because no such digital-twin-specific method was identified as necessary at design time, and because core.BaseRepository.list's existing BaseSpecification-based filtering already satisfies the discovery need (§18) without extension.
  • Trade-off accepted: TwinStateCache does not reuse kpis.ResultCache, despite both being "cache expensive upstream results" concepts. This is the third time this exact "shape looks similar, coupling doesn't fit" reasoning has been applied in this series (decision.ActionPlanner declining kpis.DependencyGraph, ADR-0007; now digital_twin.TwinStateCache declining kpis.ResultCache) - recorded here explicitly as a recurring, now well-precedented pattern this platform applies deliberately rather than inconsistently.
  • Trade-off accepted: telemetry integration (§16) gets no dedicated ingestion abstraction of its own, deferring entirely to connectors.FMSConnector plus events. This means a telemetry-specific concern (e.g. very-high-frequency sensor batching) that does not fit neatly into the existing event-sourcing pipeline has no purpose-built escape hatch in this design. This was accepted because introducing one would duplicate connectors' own exclusive "only place permitted to know a vendor/format exists" responsibility (spec 04) - a boundary this platform has never violated for any other package in the series, and one this ADR chose not to be the first exception to.

Alternatives Considered

  1. Fold digital-twin representation directly into decision (e.g. DecisionContext.twin_state). Rejected - directly contradicts decision's own already-locked scope (a stateless, prescriptive-reasoning package, spec 07 §3) and its own Future Roadmap's framing of digital_twin as a separate downstream consumer (spec 07 §36).
  2. Defer digital_twin entirely until simulation/optimization need it, treating stateful representation as those future packages' own internal detail. Rejected - for the same reason ADR-0006 and ADR-0007 each rejected deferring their own packages: simulation, optimization, agents, and visualization (governing spec §34) are all independently expected to consume a twin's state/snapshot; deferring this package would mean each reimplements its own, likely mutually inconsistent, notion of "what does this asset currently look like."
  3. A single, larger "decision + digital_twin" package, reasoning that a twin's state is just richer context for a decision. Rejected - simulation, optimization, and visualization (governing spec §34) are expected to consume twin state without needing decision's full rule/policy surface exposed alongside it, and vice versa; bundling the two would also bundle their fundamentally different statelessness/statefulness profiles (see "Why a Separate Package," item 3).
  4. Ship a concrete TwinSimulationModel implementation now, on the reasoning that simulating a twin forward is the single most immediately valuable capability this package could offer. Rejected - see "Alternatives Rejected" below; this was the most seriously debated alternative, mirroring the equivalent debate ADR-0006 and ADR-0007 each recorded for their own interface-only capabilities.
  5. Model Twin as a plain mutable Python object (a conventional model class with mutable attributes), reasoning that "digital twin" implementations elsewhere in the industry are typically built this way and that immutability adds allocation overhead for no functional benefit. Rejected - see "Alternatives Rejected" below.

Alternatives Rejected

Shipping at least one concrete TwinSimulationModel implementation in this initial release is rejected for the same three compounding reasons ADR-0006 and ADR-0007 already established for their own interface-only capabilities, restated at this layer:

  1. Scope creep into simulation/optimization/ML. Choosing a simulation solver, a forecasting model, or a reinforcement-learning policy is exactly the kind of algorithmic decision this package's charter (governing spec §3.1, §3.5, §4) excludes. Shipping one "just as a starting point" would establish a precedent that quietly widens digital_twin's scope every time a plausible-looking heuristic seems worth adding.
  2. Premature commitment to an interface's shape via its first implementation. Defining TwinSimulationModel now and deferring its first concrete implementation to an independently-versioned future plugin keeps the contract general, rather than shaped by whichever solver happened to ship first.
  3. The interface already has a designated future owner. decision.WhatIfEngine (spec 07 §19) was already designed with digital_twin named as its most direct anticipated implementer; shipping a concrete TwinSimulationModel now, before a simulation package exists to actually bridge the two, would preempt work that package is better positioned to do once it exists.

Modeling Twin as a plain mutable object was the second most seriously considered alternative, and is rejected for reasons distinct from (though related to) the interface-only debate above:

  1. It would make digital_twin the one package in the entire series whose central abstraction violates the immutability discipline every other value/entity type already follows - a Twin reachable from multiple threads with in-place-mutable fields reintroduces exactly the class of data-race bug this platform's frozen-dataclass convention exists to make structurally impossible everywhere else.
  2. core.BaseEntity already anticipates this exact pattern. Its own docstring documents "representing a state change means producing a new instance" as the intended discipline for identity-bearing objects in this platform - a mutable Twin would not be filling a gap in core, it would be deliberately working around a contract core already provides for precisely this situation.
  3. The performance cost is measured and judged negligible (governing spec §33) relative to the I/O TwinSynchronizer already performs on every synchronization cycle, removing the strongest practical argument in the mutable design's favor.

Giving telemetry integration its own ingestion-transport ABC, parallel to connectors.FMSConnector, was considered as a way to let a twin author express high-frequency-sensor-specific ingestion concerns (batching, backpressure, sampling) that do not map neatly onto the existing event-sourcing pipeline. Rejected - connectors spec 04 is explicit that it is "the only place in the codebase permitted to know that a specific vendor or file format exists"; a second, digital_twin-owned ingestion boundary would violate that exclusivity for no compensating benefit, since any such sensor-specific concern (batching, sampling) can be implemented as a connectors.FMSConnector implementation detail instead, upstream of where digital_twin ever sees the data.

Long-Term Evolution

This package is expected to grow along the same axes ADR-0006 and ADR-0007 already established for analytics and decision, without requiring a new ADR:

  1. New twin categories or concrete twin types within existing categories (new Twin subclasses, new TwinCategory members) - ordinary, checklist-governed extension (governing spec §27), not an architectural change.
  2. The first concrete TwinSimulationModel implementation, whenever it arrives - expected to land as a plugin against the already-stable ABC this design defines, most plausibly from a future simulation package, which is also positioned to become the first concrete implementer of decision.WhatIfEngine by composing the two (governing spec §14).
  3. A production-grade TwinRepository backend (SQL, document store) implementing core.BaseRepository[Twin, str] directly - no code change to this package required (governing spec §20).

A new ADR would be warranted if a future need arose to: change digital_twin's position in the dependency chain; permit digital_twin to be imported by a Foundation Layer package or by analytics/decision (which would indicate a design error, not a legitimate evolution); introduce a second extension mechanism alongside the Registry Framework specialization this design already commits to; or revisit the immutable-Twin/with_state() trade-off recorded above in light of a measured performance problem this ADR did not anticipate.

Future Compatibility

Every public type this package introduces (governing spec §25, TwinResult and its subclasses, plus TwinState/TwinSnapshot) is a plain, frozen core.BaseValueObject (or, for Twin itself, a frozen core.BaseEntity), serializable via the platform's existing core.serialization mechanism, with no digital_twin-internal state leaking into any type a future package would need to consume - the identical compatibility guarantee ADR-0006 and ADR-0007 each already established for analytics.AnalyticsResult and decision.DecisionResult, extended one layer up. This is a deliberate guarantee for the packages named in the governing specification's Future Roadmap (§34) - simulation, optimization, agents, and visualization - none of which are designed by this ADR or by the governing specification, but all of which are expected to consume digital_twin's output types without requiring a breaking change to them. TwinSimulationModel (governing spec §14) exists specifically so that whichever future package first needs a concrete twin-forward simulation capability can implement against a contract that already exists, and so that the same package can plausibly become the bridge implementation decision.WhatIfEngine (spec 07 §19) has been waiting for since before digital_twin itself existed.


This ADR governs the existence and shape of mineproductivity.digital_twin as a package. See 08_Digital_Twin_Design_Specification.md for the full object model and 08_Digital_Twin_Implementation_Checklist.md for the locked implementation contract.