Foundations: kernel, determinism, contracts, monorepo, deployment tiers¶
Status: design spec, implementation contract. Written for TDD. Every capability named here has a named test or CI gate.
1. Scope¶
This section is the substrate every other section stands on. It owns the seams that cannot be retrofitted.
Requirements covered in full:
| Req | Source text | Owned here |
|---|---|---|
| C1 | Determinism: single seed, splittable per-subsystem child seeds, byte-identical event logs, seed recorded per demo and eval, CI repeated-run hash check | Yes, complete |
| C2 | Sim-time versus wall-clock: one sim-clock service, configurable compression, all timestamps sim-time, wall-clock mapping per run, dashboard speed control | Yes, complete (dashboard widget markup is the dashboard section's, the control protocol is here) |
| C3 | Schema registry: /schemas versioned machine-validated contracts for every cross-package event type, additive-only within a major, CI producer/consumer drift tests |
Yes, complete |
| C5 | Config validation: every config (facility.yaml, sensor catalog, spec limits, metrics layer) validates at load against a published schema with line-numbered, suggestion-bearing errors, plus a validate command and dry-run mode | Yes, complete for all four named configs. facility.yaml and the metrics layer envelope (5.15) are owned here. The sensor catalog schema is registered by twinflow-sensors and the spec-limit schema by twinflow-lss, both through the mechanism of 2.4 |
| C6 | Migration story: versioned historian migrations, config upgrader command, CHANGELOG compatibility table | Yes, complete (the historian's own per-table migration scripts are registered by the historian section into the framework defined here) |
| C9 | Versioning and releases: semver policy over package APIs, REST/MCP contracts, event schemas, facility.yaml; lockstep versions; automated tag/changelog/build/publish of every brick from CI | Yes, complete |
| C10 | Monorepo tooling: uv workspace, justfile as single task entry point, CI matrix over Python versions plus the Rust agent, path-filtered jobs, stated CI wall-time budget | Yes, complete |
| A1 | Take-one-brick modularity: independently installable packages, clean boundaries, per-package README/tests/pip path, "use just this part" table | Yes, complete |
| A2 | Bring-your-own-facility: entire operation defined in facility.yaml, three worked profiles, CONFIGURING.md | Yes, complete for the config envelope, profile set, and gates. Sub-blocks of facility.yaml whose semantics belong to another subsystem (station physics, automation, sensor instances, policies) are declared here as namespaced blocks with owning sections named |
| A3 | Deployment tiers by configuration never by rewrite: garage, growth, enterprise, with adapter interfaces making swap points explicit, stubs plus one implemented example | Yes, complete. All nine seams of the 5.11 table have a Protocol in 3.6, a conformance suite class, and an entry in twinflow.kernel.__all__ |
| A6 | Integration surface: versioned REST/GraphQL API over twin state and findings, webhook events, composable into someone else's stack | Yes, complete for REST, GraphQL, webhooks, auth, and versioning. The MCP server (E2) and EPCIS export (E35) reuse the same service layer and are specified in their own sections |
| DST kernel | Locked architecture decision: one codebase, production mode and simulation mode, interfaces for CLOCK, RNG, NETWORK, STORAGE, deterministic scheduler, virtual clock, in-memory network with partition/latency/reorder/duplicate injection | Yes, complete. Every port named in the manifest has a Protocol body in 3.6, including Storage, BlobStore, KeyValue, and MetricsSink, and the scheduler ships the resource primitives of 5.2 |
| Nondeterminism CI lint | Locked architecture decision: lint bans time.time/datetime.now/random.*/raw sockets outside the kernel package, with an annotation escape hatch, backstopped by C1's repeated-run hash check |
Yes, complete |
Requirements touched here but owned elsewhere, listed so the boundary is explicit:
- C4 test tiers. This section defines the tier names, the runtime budgets, and the property-based invariants belonging to foundations. The testing section owns the golden-file suite for the capability report, VSM, and financial statements.
- C7 SECURITY.md and C8 CONTRIBUTING.md. This section defines the release and versioning automation those documents reference. The repo-hygiene section owns their content.
- C11 dependency hygiene. This section owns the SBOM-per-release step inside the release pipeline and the CI job wiring. The dependency-hygiene section owns the allowlist and audit policy, and 5.14 records the one allowlist row this section's dependency group needs and does not have.
- A4 published scaling evidence. The load-test harness runs against the ports defined here; the scaling section owns the harness and the curves.
- A5 ADOPTION.md. Consumes the brick table defined here.
- Reference-architecture fidelity item (b), Purdue-style network segmentation expressed for real in docker compose. Owned here as part of A3. Items (a), (c), (d), (e) are owned by the architecture and IoT sections.
- E1 hosted replay demo, E4 event-sourced replay and counterfactuals, E5 closed-loop autonomy tiers, E25 synthetic data products, E27 eval harness, E36 edge compute tiers. All six depend on the run manifest, canonical event log, and snapshot interface defined here. Their behavior is specified in their own sections.
- E26b governed semantic metric layer. This section owns the
metrics.yamlenvelope, the metric identifier registry, and its JSON Schema, because the config loader must resolvespec_limitskeys against metric ids from Phase 0 (5.15). The AI-layer section owns the metric expressions and their evaluation. - The stochastic model.
docs/design/variability-and-faults.mdis the single source of truth for distribution families, stream naming, and the fault catalog. This section owns theRngport, the run seed, and the manifest fields that record RNG state, and it restates nothing that file already fixes.
1.1 Doctrine rulings applied here¶
docs/design/DOCTRINE.md is binding. Where it and an earlier draft of this section disagreed, the
ruling won. Every place a ruling lands is marked in the text with its id.
| Ruling | What it fixes here | Where |
|---|---|---|
| D-01 | RunManifest splits into a hashed core and an unhashed provenance sidecar |
3.3, 4.1, 5.4 |
| D-02 | Wall-clock reads are legal in four named places and never reach a payload or a branch | 3.1, 5.3, 5.9 |
| D-03 | No collection whose iteration order can reach an event, a hash, or a branch is a set |
3.6, 5.9, 7.2 |
| D-04 | The learned-model seam is the Inference port, bound to a recorded adapter in simulation |
2.2, 3.6, 5.11, 6 |
| D-05 | The determinism claim is two-tier: byte-identical on a pinned platform, value-equivalent | 5.4, 7.3, 9.2 |
| D-06 | The Rust device agent derives its streams from the same name-addressed derivation | 3.2, 5.5, 7.3, 8 |
| D-07 | The envelope carries twinflowproducerid; density is per (run_id, producer_id) |
3.4, 5.13, 7.2 |
| D-08 | Network stays MQTT-shaped and EventBus is a separate port for analytics fan-out |
3.6, 5.1, 5.10, 5.11 |
| D-09 | One owning package per public symbol, and the layering contract names every package | 2.2, 2.3, 2.4, 2.7, 2.9 |
| D-10 | The kernel core install stays minimal; heavy adapters are extras on other packages | 2.2, 2.7, 3.6, 7.6 |
| D-11 | Every validation gate names an external reference, a tolerance, and a falsifier | 5.4, 7.3, 7.5, 9.13, 9.14 |
| D-12 | simulation.mode selects the port family and deployment.adapters selects within it |
5.1, 5.12, 5.14, 7.4 |
| D-13 | Timing tests run on a short scenario that fits the job budget, and the clamp is generated | 5.3, 7.2, 7.3, 7.4 |
| D-14 | Process mining is written here under Apache-2.0; PM4Py is a development-only oracle | 2.1, 2.7, 5.4, 8 |
2. Packages¶
2.1 Repository layout¶
twinflow/
justfile # single task entry point (C10)
pyproject.toml # uv workspace root, no runtime code
uv.lock # single lockfile for the whole workspace
.importlinter # package boundary contracts (A1)
ci_budget.yaml # per-job wall-time budgets (C10)
packages/
twinflow-schemas/ # this section (the one workspace leaf)
twinflow-rng/ # this section (stream registry, generator construction)
twinflow-kernel/ # this section
twinflow-config/ # this section
twinflow-storage/ # this section (storage port adapters + migration framework)
twinflow-devtools/ # this section (nondeterminism lint, codegen, compat checker)
twinflow-cli/ # this section (the `tf` command, plugin-discovered subcommands)
twinflow-api/ # this section (A6)
twinflow-twin/ # process twin section
twinflow-sensors/ # IoT / sensor catalog section
twinflow-fleet/ # fleet management + PdM section
twinflow-lss/ # LSS engine section
twinflow-procmine/ # process mining + VSM section, Apache-2.0, written here (D-14)
twinflow-forecast/ # planning + forecasting arena section
twinflow-optimize/ # optimization + what-if search section
twinflow-causal/ # causal inference section
twinflow-cv/ # computer vision auditing section
twinflow-agent/ # AI agent + accuracy stack section
twinflow-dashboard/ # dashboard section
... # further bricks introduced by their own sections
crates/
twinflow-device-agent/ # the one Rust device agent (component 2)
twinflow-rng/ # the Rust half of the RNG contract (D-06)
schemas/ # the schema registry (C3), language-neutral, published
profiles/ # facility profiles (A2)
deploy/
garage/ # compose, tier 1
growth/ # compose, Purdue-segmented, tier 2
helm/twinflow/ # Helm chart, tier 3
topologies/ # named network topologies referenced by deployment.network
migrations/
historian/ # numbered store migrations (C6)
config/ # config upgrader steps (C6)
tools/ # scripts that both `just` and CI call
scripts/checks/ # the gates the git hooks and CI both call
docs/
Distribution names are the directory names. Import paths use a PEP 420 implicit namespace package
twinflow.*, so twinflow-kernel installs twinflow/kernel/, twinflow-lss installs
twinflow/lss/, and twinflow-rng installs twinflow/rng/. No package ships a
twinflow/__init__.py. Where docs/design/variability-and-faults.md section A.4 names the stream
registry module, the module is twinflow/rng/registry.py inside the twinflow-rng distribution;
the packaging convention is this section's and the registry contents are that file's.
2.2 twinflow-kernel¶
Purpose: the DST seam. Defines the four ports the locked decision names (clock, RNG, network, storage) plus the ones experience proved are equally leaky (identity generation, environment access, event fan-out, metrics, secrets, identity, inference, hashing and serialization), supplies both a deterministic simulation implementation and a production implementation of each, and supplies the deterministic scheduler.
Runtime dependencies, core install: pydantic>=2.9, numpy>=2.1,<3, twinflow-schemas,
twinflow-rng. Nothing else, and in particular no columnar, database, or broker library. This
matters for A1: a quality manager installing only twinflow-lss gets pydantic and numpy, which
they already have. The upper bound on numpy is not decoration. NumPy's published compatibility
policy for numpy.random states that breaking stream compatibility "in order to introduce new
features or improve performance in Generator or default_rng will be allowed with caution", on
feature releases rather than patch releases, so an unbounded pin would let a routine upgrade
silently reshuffle every recorded run (5.4, VAL-F6a, VAL-F6b).
Applying D-10, no port signature names a heavy type. TableStore is typed against the narrow
structural protocols ArrowRecordBatchLike, ArrowTableLike, and ArrowSchemaLike declared in
twinflow.schemas.structural, with pyarrow imported only under TYPE_CHECKING and every module
carrying from __future__ import annotations. BRICK-4 installs the kernel alone into a clean
environment and imports it, so the claim is tested rather than asserted.
Optional extras:
| Extra | Pulls in | Enables |
|---|---|---|
sim |
simpy |
SimEventLoop, SimClock, simulation-mode runtime |
mqtt |
aiomqtt, paho-mqtt |
MqttNetwork and MqttEventBus production adapters |
otel |
opentelemetry-sdk, opentelemetry-exporter-otlp |
production tracing bridge |
arrow |
pyarrow |
the concrete Arrow types behind the TableStore structural protocols |
identity |
authlib, cryptography |
OidcIdentity and MtlsIdentity production adapters |
Applying D-09 and D-10, the kernel has no delta extra and no postgres extra. Every concrete
storage adapter and every extra that would pull a database driver belongs to twinflow-storage
(2.7). One public symbol has one owning package.
Adapters are discovered, not hard-coded. Every package that ships an adapter registers it under the
twinflow.kernel.adapters entry-point group as (port, name) -> class, which is the registry
deployment.adapters.<port> resolves a name against and the registry TF-C120 lists when the name
is unknown. The kernel names no adapter it does not itself ship: twinflow-storage
registers the storage adapters, twinflow-agent registers OllamaInference, VllmInference, and
HostedInference for the Inference port, and the kernel registers the rest.
Public API surface (twinflow.kernel.__all__):
# ports
Clock, Rng, Network, EventBus, Storage, EventLog, TableStore, KeyValue, BlobStore
IdGen, Env, MetricsSink, Secrets, Identity, Inference
# value types re-exported from twinflow-schemas so a consumer needs one import (D-09)
Quantity, ConfigHash
# value types owned here
SimInstant, Duration, TickResolution, StreamId, RunId, EventId
# runtime
Runtime, RuntimeBuilder, RunManifest, RunProvenance, RunMode
# scheduler
SimEventLoop, run_scenario, Snapshot, SnapshotHandle, Snapshottable
# scheduler resource primitives
Resource, PriorityResource, PreemptiveResource, Container, Store
# simulation implementations
SimClock, PacedClock, SplittableRng, InMemoryNetwork, InMemoryEventBus, InMemoryStorage
InMemoryEventLog, InMemoryTableStore, InMemoryBlobStore, InMemoryKeyValue
DeterministicIdGen, FrozenEnv, RecordingMetricsSink, FrozenSecrets, NullIdentity
RecordedInference
# production implementations
RealClock, MqttNetwork, MqttEventBus, OsEnv, Uuid7IdGen
DotEnvSecrets, DockerSecrets, K8sSecrets, ApiKeyIdentity, OidcIdentity, MtlsIdentity
# the distribution layer, built only on the NEP 19 stream-stable methods (5.4)
numeric
# fault injection
Fault, FaultKind, FaultSchedule, NetworkTopology, Link
# serialization and hashing
canonical_encode, canonical_decode, stable_hash, content_hash
# testing support
adapter_conformance_suite, FrozenWallClock, RecordingEventLog
Everything under twinflow.kernel._impl is private. twinflow-kernel imports twinflow-schemas
and twinflow-rng, and nothing else in the workspace.
Storage is an aggregate facade, not a peer of the four storage ports. It holds
Storage(event_log, table_store, blob_store, key_value) and exists so a subsystem constructor
takes one argument instead of four. Runtime passes storage for the facade and log for the
EventLog the kernel itself appends to, and the two name the same object:
runtime.log is runtime.storage.event_log is an assertion in RuntimeBuilder, covered by
test_runtime_log_is_the_storage_event_log.
twinflow.kernel.numeric is the distribution layer. It implements every family the Rng port
exposes on top of the three NumPy Generator methods whose stream NEP 19 commits to keeping
stable, and it is the only module permitted to call a Generator method at all (TFD017). 5.4
states why the other distribution methods are unusable here.
Uuid7IdGen implements UUID version 7 from RFC 9562 section 5.7 inside
twinflow.kernel._impl.real rather than calling a standard-library helper, because the supported
Python versions do not all ship one and a version-dependent identity format would put the
interpreter minor version into production event ids.
2.2a twinflow-rng¶
Purpose: the one place a bit generator is constructed, and the registry of stream names. The
mechanism, the derivation, the naming grammar, and the append-only registry rule are specified in
docs/design/variability-and-faults.md sections A.1 through A.6 and are not restated here.
Dependencies: numpy>=2.1,<3, twinflow-schemas. It is a leaf apart from the schema package.
Public API:
streams.get(name, **template_args) -> Generator # the only generator factory
STREAM_CATALOG_VERSION: str # semver over the registry
derive_spawn_key(stream_name: str) -> tuple[int, int, int, int]
draw_counts() -> Mapping[str, int] # per-stream draw counter, for crn_integrity
The returned object is the numpy.random.Generator that variability-and-faults.md section A.1
specifies, and no caller outside twinflow.kernel.numeric may call a method on it. That module
uses Generator.random, Generator.integers, and Generator.bytes and nothing else, for the
reason 5.4 gives. TFD017 enforces the restriction statically, and draw_counts() counts calls
through twinflow.kernel.numeric, which is what makes rng.draw_counts_sha256 a complete record
of a run's draw order rather than a partial one.
The matching Rust crate crates/twinflow-rng implements derive_spawn_key and the PCG64DXSM stream
from the same specification, which is what D-06 requires so the Rust device agent's jitter, drift,
and fault draws come from the run seed like everything else. RUST-1 (7.3) is the cross-language
known-answer gate.
2.3 twinflow-schemas¶
Purpose: the machine-validated contract registry (C3) plus generated bindings.
Contents: a loader over the language-neutral /schemas tree, the generated Pydantic v2 models, the
compatibility checker, the upcaster registry, the envelope definition, and the shared value types
that every other package needs and that must not drag a dependency downward. Dependencies:
pydantic>=2.9, jsonschema>=4.23, referencing, pint>=0.24. It is the only workspace leaf: it
imports no other twinflow package.
ConfigHash, Quantity, and the Env protocol live here rather than in the kernel, which is
D-09's rule that a shared value type sits in the leaf and is re-exported upward. The reason is
concrete: twinflow-config needs all three, and putting them in the kernel would make
pip install twinflow-config pull numpy for a package that does no arithmetic.
Public API:
Envelope, Event, Subject, SchemaVersion
ConfigHash, Quantity, Env # shared value types, re-exported by twinflow.kernel
structural.ArrowRecordBatchLike # narrow protocols so ports need no columnar library
structural.ArrowTableLike
structural.ArrowSchemaLike
registry() # -> SchemaRegistry, loaded from the packaged /schemas snapshot
SchemaRegistry.get(subject, version) -> JsonSchema
SchemaRegistry.validate(event) -> None | raises SchemaViolation
SchemaRegistry.snapshot_hash() -> str
compat_check(old_schema, new_schema, mode) -> CompatReport
upcast(event) -> Event # lifts any supported historical version to current
models.* # generated Pydantic classes, one per subject/major
The /schemas tree is packaged into the wheel as data files so a lone pip install
twinflow-schemas gives a consumer the full contract set without cloning the repo.
2.4 twinflow-config¶
Purpose: C5 and the config half of C6 and A2. Loads, validates, merges, hashes, upgrades, and explains configuration.
Dependencies: twinflow-schemas, ruamel.yaml>=0.18 (round-trip parse keeps line and column, and
preserves comments through the upgrader), rapidfuzz (suggestions). No kernel, and no numpy.
ConfigHash, Quantity, and Env come from twinflow-schemas (2.3), which is what makes that
dependency list honest rather than aspirational; BRICK-4 proves it in a clean environment.
Public API:
load(path, *, overlays=(), sets=(), strict=True) -> ResolvedConfig
validate(path, ...) -> ValidationReport # never raises, returns all errors
ResolvedConfig.hash() -> ConfigHash
ResolvedConfig.section(name) -> Mapping # namespaced block accessor
FacilityConfig, SimulationConfig, DeploymentConfig, IntegrationConfig # pydantic models
MetricRegistry, MetricDefinition # the metrics.yaml envelope of 5.15 (C5)
ConfigError, ErrorCode, render_human(report), render_json(report)
upgrade(path, to_version) -> UpgradeResult
register_section(name, model, *, schema_path, owner) # entry-point based extension
Other bricks register their own facility.yaml block through the
twinflow.config.sections entry-point group. twinflow-config knows nothing about
sortation conveyors; it knows that the automation block is owned by twinflow-twin and validates
it against the schema that package registers. A block whose owning package is not installed is
parsed, retained verbatim, and reported by tf config validate as TF-C140 section 'automation'
has no installed owner (install twinflow-twin), which is a warning at garage tier and an error
when strict_sections: true.
2.5 twinflow-devtools¶
Purpose: the tools CI runs that are not library code. Never a runtime dependency of anything.
Console scripts:
| Script | Purpose |
|---|---|
tf-lint-det |
the nondeterminism AST lint (section 5.9) |
tf-schemas gen |
generate Pydantic and Rust serde bindings from /schemas |
tf-schemas check |
registry drift, additive-only compat, producer/consumer contract check |
tf-api check |
OpenAPI and GraphQL SDL diff gate |
tf-api-diff |
Python public API diff against the previous tag (griffe-backed) |
tf-budget |
assert a CI job stayed inside ci_budget.yaml |
tf-brick-isolate |
build each brick into a clean venv and run its isolated test subset |
tf-count-services |
count containers and networks in a compose file, for TIER-5 |
tf-measure-rows |
measure stored bytes per row for a subject and write x-twinflow-stored-bytes |
2.6 twinflow-cli¶
Purpose: the tf command. A thin argument parser plus a plugin loader over the
twinflow.cli.commands entry-point group, so pip install twinflow-lss alone still gives you
tf lss chart ... and nothing else. Dependencies: typer, rich, twinflow-config.
Commands owned by this section: tf run, tf replay, tf config validate|explain|upgrade|hash,
tf facility init|doctor, tf schemas list|show|check, tf historian migrate|status,
tf serve api, tf det check.
tf run flags: --profile, --scenario, --seed, --replication, --overlay, --set,
--faults, --speed, --horizon, --dry-run, --out, --force. --faults names a fault
schedule file and is folded into the resolved config as deployment.faults before the config hash
is taken, so a faulted run and its clean baseline have different config_hash values and
so different run_id values (M1). Without --force, tf run refuses to write into an
existing artifacts/runs/<run_id>/ and exits 4 with TF-D002 run artifacts already exist, so a
repeated run cannot silently destroy the artifacts it was meant to be compared against. --out
redirects a run to a named directory, which is how DET-1 and DET-4 get two artifact sets for two
runs that share a run_id by design (M6).
2.7 twinflow-storage¶
Purpose: implementations of the storage ports plus the migration framework (C6). Applying D-09, every concrete storage adapter in the workspace is declared here and nowhere else. The kernel declares the ports and the in-memory implementations; this package declares everything that talks to a real system.
The adapter set this package owns: DuckDbTableStore, DeltaTableStore (delta-rs), PostgresTableStore,
PostgresEventLog, PostgresKeyValue, LocalFileKeyValue, LocalNdjsonEventLog,
LocalBlobStore, S3BlobStore, KafkaEventBus (the implemented enterprise example for the
EventBus port of D-08), and KafkaEventLog. Stub adapters with a documented conformance target: SnowflakeTableStore,
DatabricksSqlTableStore, AzureIotHubIngress, AwsIotCoreIngress.
Extras: delta pulls deltalake, duckdb, and pyarrow; postgres pulls asyncpg; kafka
pulls aiokafka; s3 pulls boto3. The base install of twinflow-storage takes three workspace
packages and no driver, twinflow-config, twinflow-kernel, and twinflow-schemas, and its
public surface is the historian rather than any adapter above: Historian, ConfigSnapshot,
TableFormat, SeriesName, and the naming that places the historian in the Purdue map. Each
adapter lands with the phase whose port it serves, so the take-one-brick promise survives here
too.
License positions for the two storage libraries that carry the most weight, read from the Python
Package Index on 2026-08-09: deltalake 1.6.2 is Apache-2.0 and duckdb 1.5.5 is MIT. Both are
compatible with the Apache-2.0 plus commercial dual license, which is the check D-14 showed is not
automatic. simpy 4.1.2, the kernel's sim extra, is MIT from the same source.
Public API includes migration_framework.register(domain, migrations_dir) so the historian, the
QMS, and the ledger each register their own numbered migration sets without this package knowing
their tables.
2.8 twinflow-api¶
Purpose: A6. FastAPI application, Strawberry GraphQL schema, webhook delivery service, OpenAPI
artifact generation. Dependencies: fastapi, strawberry-graphql, uvicorn, twinflow-kernel,
twinflow-schemas, twinflow-config. Domain packages are optional dependencies discovered through
the twinflow.api.routers entry-point group, so twinflow-api with only the LSS brick installed
serves /findings and returns 404 with a TF-A020 router not installed problem document for
/twin/state.
2.9 Package boundary rules (A1)¶
- No package imports another package's
_internalor_implsubpackage. Enforced byimport-lintercontractforbidden: *._impl from anything outside its own distribution. - Sibling domain packages do not import each other at all. They communicate through kernel ports
and versioned events. Enforced by an
independencecontract listing every domain package. - Layering contract, top to bottom:
apps (api, dashboard, cli)->domain (twin, lss, sensors, fleet, procmine, forecast, optimize, causal, cv, agent)->storage->config->kernel->rng->schemas.devtoolssits outside the graph because nothing imports it at runtime. An import that skips upward fails CI. The layer list and the domain-package list are read from[tool.twinflow.layers]in the rootpyproject.toml, the same tableBRICK-2reads, so the contract and the gate cannot disagree (D-09). - Every public symbol has exactly one owning package.
IMPORT-2walks the workspace import graph and fails on a cycle.IMPORT-3asserts that every name in each package's__all__is defined in that package or is listed in that package's declared re-export set, and that no name appears in two packages' declared ownership. Re-exports are declared, not inferred, which is howtwinflow.kernelcan re-exportConfigHashfromtwinflow-schemaswithout owning it. - Every package ships
README.mdwith a copy-pasteable install line,API.mdlisting its public symbols, its owntests/, and at least one test marked@pytest.mark.brick_isolated. - The flagship README carries the "use just this part" table:
| You are | Install | You get |
|---|---|---|
| A quality manager who wants SPC as code | pip install twinflow-lss |
control charts, capability, Gage R&R, hypothesis tests, findings |
| An AI team who wants the grounding checker | pip install twinflow-agent[grounding] |
execution-grounded answers, semantic metrics layer, grounding gate, abstention |
| An IoT team who wants a device fleet simulator | pip install twinflow-sensors |
catalog-driven sensor simulation, failure modes, Sparkplug B publishing |
| A process analyst | pip install twinflow-procmine |
discovery, conformance, variant analysis, auto VSM, all Apache-2.0 (D-14) |
| A planner | pip install twinflow-forecast |
backtest arena, conformal intervals, inventory policy |
| Someone building their own deterministic sim | pip install twinflow-kernel[sim] |
the DST kernel alone |
3. Domain model¶
All types are frozen Pydantic v2 models or dataclasses unless stated. Field types are exact.
3.1 Time¶
TickResolution = Literal[1_000, 1_000_000, 1_000_000_000] # ticks per simulated second
SimInstant = NewType("SimInstant", int) # non-negative integer ticks since sim epoch
Duration = NewType("Duration", int) # signed integer ticks
Invariants:
- T1. Sim time is an integer on every application-visible surface. No float represents a point in
time or a duration in a port signature, an event payload, a config after resolution, or a
comparison that orders events. Config authors write
"4.5 min"; the loader converts to ticks with round-half-up away from zero and records the exact tick value. - T1a.
SimEventLoop.time()is the single float shim in the codebase. It exists becauseasyncio.AbstractEventLoopdeclares it, and its value never orders an event.TFD015bansloop.time()outsidetwinflow.kernel._impl.sim, andSimEventLoopoverridescall_laterso the deadline is computed asenv.now + round(delay * tick_hz)in integer ticks rather than asloop.time() + delayin float seconds.test_call_later_deadline_is_computed_in_ticksasserts that a delay whose float representation is inexact still lands on the exact tick. - T2.
SimInstant >= 0. The sim epoch is tick 0 and maps towall_clock_anchor.utc. - T3.
Clock.now()is non-decreasing within a run (INV-K1). - T4. Default
tick_hzis 1_000_000 (microsecond ticks). At 1 microsecond a 64-bit signed integer covers 292,000 years of sim time, so integer overflow is not a concern. The loader rejects a horizon beyond 100 sim years withTF-C031. - T5.
horizon_ticks + warmup_ticks <= 2**53 - 1, rejected asTF-C032otherwise. The bound comes fromSimEventLoop.time(): above 2**53 ticks, two distinct tick values can map to the same float64, and any asyncio-internal comparison over those floats stops being able to tell two scheduled instants apart. Attick_hz: 1000and1000000the 100-year cap of T4 binds first. Attick_hz: 1000000000this bound binds first and caps a run at about 104 sim days, which is stated in theTF-C032message together with the arithmetic. Open question 9 records that the nanosecond option carries this cap. - T6. Wall clock and sim clock are different concepts with different names, and the vocabulary is
never mixed.
SimInstantandDurationare sim time.WallClockAnchor.utcand the provenance sidecar fields of 3.3 are wall time. Applying D-02, a wall clock is read in exactly four places: the provenance sidecar writer, the paced-clock pacer, the observability exporter, and operator-facing log lines. In none of the four does the value enter an event payload, enter the hashed tape, or steer control flow.
3.2 Randomness¶
SplittableRng invariants. The derivation itself is fixed by
docs/design/variability-and-faults.md section A.1 and is reproduced here only so a reader of this
section can see what the manifest records. Where the two differ, that file wins.
- R1. One
base_seed: intin[0, 2**64)per run, taken fromsimulation.seedor--seed, and onereplication_index: int >= 0, taken from--replicationand defaulting to 0. The pair is the root entropy, so replications of one scenario get independent trees while stream names stay stable across them. - R2. A child stream is addressed by name, not by creation order. The derivation is
key_bytes = blake2b(stream_name.encode("utf-8"), digest_size=16, person=b"twinflow-rng"), thenspawn_keyis those 16 bytes read as four little-endianuint32words, thenSeedSequence(entropy=<four uint32 words>, spawn_key=spawn_key), thenGenerator(PCG64DXSM(seed_seq)). The entropy is a fixed-width four-worduint32array, never a tuple of Python integers: numpy emits as many words as each integer's magnitude needs, so a tuple yields two to four words depending on the seed and the mixing path changes with it.docs/design/variability-and-faults.mdsection A.1 owns the byte-for-byte form and this line never restates it, because a cross-language contract with two spellings has no contract. Adding, removing, or reordering subsystems does not perturb any other subsystem's draws (INV-K4). This is the single most important property of the RNG design: without it, adding a sensor type in Phase 3 would invalidate every golden file recorded in Phase 2. - R3. Stream names are registered.
Rng.child(name)raisesUnregisteredStreamunless the name matches a registered fixed name or template declared by the owning package through thetwinflow.rng.streamsentry point. A registry means the collision check is a startup assertion, not a bug hunt. - R4. The name-to-key hash is BLAKE2b with a 16-byte digest and the personalisation string
twinflow-rng. It is not Python'shash(), which is randomized per process, and it is not a truncation of a differently personalised digest.stable_hash(the general-purpose helper in 5.4) is a different function with a different digest length and is never substituted for it. - R5. Bit generator is
numpy.random.PCG64DXSM, withnumpy>=2.1,<3pinned in every package that draws, and known-answer vectors committed (VAL-F6a, VAL-F6b). NEP 19, NumPy's Random Number Generator Policy, names the methods whose stream NumPy commits to keeping stable: "They MUST guarantee stream-compatibility for a specified set of methods ... Namely,.bytes(),integers()...random()". Every otherGeneratormethod, includingnormal,gamma, andpoisson, sits outside that commitment, and the same document allows breaking their streams on a feature release.twinflow.kernel.numericbuilds every family theRngport exposes on those three methods alone. 5.4 states the consequence and VAL-F6a and VAL-F6b state the gates. The generator isPCG64DXSMrather than numpy's defaultPCG64by the decision recorded on 2026-08-09 indocs/design/variability-and-faults.mdopen question G.11. numpy seeds the two through one routine, so their seeded state and increment are identical and only generation differs, which is why this rule changes in name and in nothing else. - R6. Applying D-06, the Rust device agent derives its streams from the same specification. The
crates/twinflow-rngcrate implements the BLAKE2b-16 personalised digest, the little-endian spawn-key packing,SeedSequenceentropy mixing, and PCG64DXSM.RUST-1asserts that Python and Rust produce byte-identical first-1000 draws for each of the twelve named streams of VAL-F6a. Without this the agent's dropped connections, clock drift, duplicate reads, crash loops, degraded read rates, sensor drift, and stuck-at faults would be outside the single-seed guarantee C1 makes, which is the boundary this project is loudest about. - R7. Draw counts are recorded.
twinflow-rngkeeps a per-stream draw counter and the manifest carriesrng.draw_counts_sha256over the sorted stream-name-to-count map. A code change that alters draw order changes that hash even when the log hash happens to survive, which is the cheapest available detector for hazard 2 ofvariability-and-faults.mdsection A.3. - R8. Draw block size is fixed at one. Every draw through
twinflow.kernel.numericrequests a single value, never an array. NumPy's compatibility policy fornumpy.randomis explicit that block size is part of the stream contract: "Callingrng.random()5 times is not guaranteed to give the same numbers asrng.random(5)". A vectorised path added later for speed would change the tape, so any such path gets its own stream name under the versioning rule ofvariability-and-faults.mdsection A.3 rather than reusing the scalar stream.test_numeric_layer_draws_one_value_at_a_timewalks the module's calls and fails on asizeargument.
3.3 Run identity¶
Applying D-01, run identity is two objects, not one. RunManifest is the hashed core and rides
inside run_started. RunProvenance is the sidecar, is written to manifest.json beside
events.ndjson, and never enters an event or a hash. The split exists because the earlier single
object carried started_wall_utc and a platform fingerprint into the first event of the log, which
made DET-1 unachievable by construction: two runs seconds apart could never match, and the
cross-platform gate could never pass.
class RunManifest(BaseModel): # hashed core, carried by run_started
run_id: RunId # "run_" + 26-char Crockford base32 of content hash
seed: int # base_seed (R1)
replication_index: int # R1
mode: RunMode # "simulation" | "production"
config_hash: ConfigHash # blake2b-256 over canonical resolved config
schema_snapshot_hash: str # blake2b-256 over the registry manifest
faults_hash: str # blake2b-256 over the canonical fault schedule, or of b""
profile: str # e.g. "profiles/midmarket_3pl.yaml"
scenario: str | None
tick_hz: int
horizon_ticks: int
warmup_ticks: int
wall_clock_anchor: WallClockAnchor # declared sim epoch, from config, not observed
rng: RngDescriptor # mechanism, bit generator, stream catalog version
class RunProvenance(BaseModel): # sidecar, written to manifest.json, never hashed
run_id: RunId
speed: float | Literal["asap"] # pacing only, never in the hash (M6)
anchor_monotonic_ns: int | None # production mode only, the pacer's own reading
started_wall_utc: datetime
finished_wall_utc: datetime | None
event_log_hash: str | None # blake2b-256 over the canonical log bytes
event_count: int | None
rng_draw_counts_sha256: str | None
packages: dict[str, str] # distribution -> version, sorted, every twinflow-*
git: GitProvenance # sha, dirty flag, branch
platform: PlatformFingerprint # os, arch, python, numpy, simd features disabled
host: str
exit: Literal["completed", "horizon", "aborted", "error"] | None
Invariants:
- M1.
run_idis derived, not random:content_hash(seed, replication_index, config_hash, schema_snapshot_hash, faults_hash, scenario, mode). Two identical runs produce the samerun_id, which is what makes the CI repeated-run check a file comparison rather than an id-stripping exercise.faults_hashis in the inputs because without it a faulted run and its clean baseline collide on identity and on the artifact directory, and DET-5's second run would overwrite the first before it could be compared. - M1a.
deployment.faultsis a resolved config key, soconfig_hashalready covers the fault schedule.faults_hashis carried separately anyway, because the fault schedule is the one input a reader most often wants to compare between two runs and digging it out of a config hash is not comparison. - M2. Every demo, eval case, dataset card (E25), and replay bundle (E1, E4) references a
RunManifest.tf runrefuses to start without a seed, either from config or flag, withTF-D001 no seed supplied; determinism is a contract, not a default. - M3.
RunProvenance.platform.simd_disabledrecords the value ofNPY_DISABLE_CPU_FEATURESused, because numpy's runtime SIMD dispatch is a cross-machine determinism hazard for reductions. It sits in the sidecar, so recording it does not put the machine's identity into the tape. - M4.
event_log_hashcovers the canonical bytes of every event fromrun_startedup to but not includingrun_finished, andrun_startedcarries the hashed core only. The carve-out is asserted bytest_run_started_carries_no_wall_clock_or_platform_field, which walks therun_startedpayload schema and fails on any property whose name or type is a wall-clock instant, a host name, a package version, or a platform field. A field added later toRunProvenancecannot leak into the hash without that test failing. - M5.
wall_clock_anchoris declared, never observed. Its fields aresim_ts,utc,tz, anddst_offset_minutes, all of which come fromfacility.timezoneand the scenario's declared start date. It carries no monotonic reading at all: the one the pacer takes lives inRunProvenance.anchor_monotonic_nsand is populated only in production mode. That is why INV-K19 can freeze the process wall clock at two different instants and still get identical logs. - M6.
speedis not in the hashed core and not inConfigHash. Pacing changes when an event is emitted in wall time and never which event is emitted or in what order (D-02, 5.3), so a speed that entered either hash would make DET-4 unachievable by construction: the gate runs the same scenario atasapand at a finite speed and compares logs, and a speed-bearing first event guarantees they differ.simulation.speedcarriesx-twinflow-not-in-hash: truein the facility schema, and that keyword marks the complete carve-out, currently one key.test_config_hash_is_unchanged_by_speedandtest_run_started_payload_has_no_speed_fieldpin both halves, and INV-K11 excludes exactly the keys carrying the keyword rather than making a blanket exception. A consequence worth stating: two runs that differ only in speed share arun_id, so DET-1 and DET-4 pass--outto give each run its own directory, and--forceis reserved for the case where an operator means to overwrite.
3.4 Events¶
class Envelope(BaseModel):
specversion: Literal["1.0"] # CloudEvents 1.0
id: EventId # unique within run, sortable
source: str # URI-reference: "/twinflow/<package>/<component>"
type: str # the subject, e.g. "twinflow.telemetry.sensor_reading"
time: datetime # RFC 3339, derived from sim_ts via the anchor
datacontenttype: Literal["application/json"]
subject: str | None # UNS topic where applicable
dataschema: str # "twinflow:schemas/telemetry/sensor_reading/v1.3.json"
# CloudEvents extension attributes (lowercase alphanumeric, at most 20 characters)
twinflowsimts: str # decimal integer ticks, the authoritative timestamp
twinflowrunid: str
twinflowproducerid: str # the emitting process, D-07
twinflowseq: str # decimal, per (run, producer) emission sequence
twinflowcausationid: str | None
twinflowcorrid: str | None # correlation id
partitionkey: str | None # CloudEvents Partitioning extension
traceparent: str | None # CloudEvents Distributed Tracing extension
tracestate: str | None # CloudEvents Distributed Tracing extension
data: dict # payload, validated against dataschema
Decision and rationale: the envelope is CloudEvents 1.0 compliant. Alternative considered and
rejected: a bespoke envelope. CloudEvents costs nothing here, gives webhook consumers (A6) a format
their existing tooling already parses, and gives the test suite a published conformance target
(VAL-F12). The constraint it imposes comes from the specification's own naming rule, quoted from
the CloudEvents 1.0.2 specification, section "Attribute Naming Convention": "CloudEvents attribute
names MUST consist of lower-case letters ('a' to 'z') or digits ('0' to '9') from the ASCII
character set. Attribute names SHOULD be descriptive and terse and SHOULD NOT exceed 20 characters
in length." Hence twinflowsimts rather than twinflow_sim_ts, and hence twinflowcorrid at 14
characters rather than twinflowcorrelationid at 21, which would have failed the very conformance
gate the envelope was chosen to make possible. The longest name the envelope now carries is
twinflowcausationid at 19. test_every_extension_attribute_name_is_at_most_20_chars is a unit
test, not a review habit.
Three attributes are the specification's own rather than twinflow's, for one reason each time: a
twinflow-prefixed name would be invisible to every off-the-shelf CloudEvents tool, which is the
interoperability the envelope was adopted for. Tracing uses the Distributed Tracing extension,
whose attributes are traceparent (REQUIRED within the extension) and tracestate (OPTIONAL).
Partitioning uses the Partitioning extension's partitionkey, described there as "A partition key
for the event, typically for the purposes of defining a causal relationship/grouping between
multiple events", which is exactly what the historian partitions on. time is wall time under
CloudEvents, which is why twinflowsimts carries the authoritative value.
Two attributes are twinflow's where a standard extension exists, and the reason is a type
constraint rather than preference. CloudEvents 1.0.2 defines its Integer type as "A whole number
in the range -2,147,483,648 to +2,147,483,647 inclusive", and both of twinflow's counters leave
that range: at the default tick_hz of 1e6 a one-day horizon is already 8.64e10 ticks, and the
sensor-volume example of 5.6 produces 5.184e9 readings in one run. twinflowsimts and
twinflowseq are CloudEvents String attributes carrying a decimal integer, parsed back
to int by the reader. The Sequence extension is not used for the same reason plus two others: its
sequence values are string-encoded 32-bit integers that "wrap around" at 2^31-1, and its
Integer sequence type "MUST start with a value of 1", while twinflowseq starts at 0 so that
run_started sits at sequence 0 with no off-by-one in the total order of E4.
test_extension_attribute_types_match_cloudevents_type_system asserts every attribute against the
type it declares, which is the assertion that would have caught the original integer typing.
Invariants:
- E1.
timeis always derivable:anchor.utc + (twinflowsimts - anchor.sim_ts) / tick_hz, and in simulation mode is derived, never observed. - E2.
idisDeterministicIdGenoutput in simulation mode:content_hash(run_id, twinflowproducerid, twinflowseq)truncated to 26 Crockford base32 characters. In production mode it is UUIDv7. The producer id is inside the hash because sequence numbers are per producer (E3), so without it two producers would mint the same event id. - E3. Applying D-07,
twinflowseqincrements by exactly 1 per emitted event within a(twinflowrunid, twinflowproducerid)pair. It is not globally dense, and no global counter is claimed, because every tier including garage runs several processes and the Rust device agent is a fourth. A gap within one producer's sequence means a dropped event and is a test failure (INV-K18).twinflowproduceridis a slug naming the process role, drawn from the closed setsim,api,dashboard,agent,device-agent, andcli, extended only by a line inschemas/registry.yaml. - E3a.
twinflowproduceridis added now rather than later because 5.5 makes an envelope change a MAJOR bump on every subject, and Phase 0 freezes the envelope. This is the reason D-07 was settled before any schema was written. - E4. The canonical total order of the log is
(twinflowsimts, twinflowproducerid, twinflowseq), ascending, withtwinflowproduceridcompared as a byte string. All three components are needed: sim time orders across producers, the producer id breaks ties deterministically between processes that emit at the same tick, and the sequence orders within one producer. The pagination cursor of 5.13 and the replay reader of 5.7 both use this triple and no other order.
3.5 Configuration¶
class ConfigDocument(BaseModel):
path: Path
schema_version: str # "MAJOR.MINOR" of the facility schema, not the release
raw: CommentedMap # ruamel round-trip node, carries line/col
resolved: dict # after includes, overlays, --set, defaults
provenance: dict[str, Origin] # every leaf key -> (file, line, col, source_kind)
class ConfigError(BaseModel):
code: ErrorCode # "TF-C012"
severity: Literal["error", "warning"]
message: str
origin: Origin # file, line, col, and the source snippet
suggestion: str | None
valid_alternatives: list[str]
docs_url: str
Invariants:
- G1. Every resolved leaf key has a provenance entry naming the file and line that set it, or the
literal
defaultorigin naming the schema that supplied it.tf config explain layout.docks[2].widthprints the chain. - G2.
validate()returns all errors, never the first. An implementer fixing a profile must not have to run the loader eleven times. - G3. Unknown keys are errors (
model_config = ConfigDict(extra="forbid")), except inside a block whose owning package is not installed (see 2.4). - G4.
ConfigHashis computed over the resolved config serialized with RFC 8785 canonical JSON, minus the keys the facility schema marksx-twinflow-not-in-hash: true. Comments, key order, and whitespace do not affect it (INV-K11). The carve-out is one key today,simulation.speed, and M6 gives the argument for it; a second key may only be added with the same argument, that the key provably cannot change the tape, andtf config explainprints whether a key is in the hash.
3.6 Ports¶
class Clock(Protocol):
def now(self) -> SimInstant: ...
async def sleep(self, d: Duration) -> None: ...
async def sleep_until(self, t: SimInstant) -> None: ...
def anchor(self) -> WallClockAnchor: ...
def timeout(self, d: Duration) -> AbstractAsyncContextManager[None]: ...
class Rng(Protocol):
def child(self, name: StreamId) -> Rng: ...
def uniform(self, lo: float, hi: float) -> float: ...
def normal(self, mu: float, sigma: float) -> float: ...
def lognormal(self, mu: float, sigma: float) -> float: ...
def exponential(self, scale: float) -> float: ...
def gamma(self, shape: float, scale: float) -> float: ...
def weibull(self, shape: float, scale: float) -> float: ...
def triangular(self, lo: float, mode: float, hi: float) -> float: ...
def bernoulli(self, p: float) -> bool: ...
def poisson(self, lam: float) -> int: ...
def choice(self, seq: Sequence[T], weights: Sequence[float] | None = None) -> T: ...
def sample_without_replacement(self, seq: Sequence[T], k: int) -> list[T]: ...
def permutation(self, seq: Sequence[T]) -> list[T]: ...
def duration(self, dist: DistributionSpec) -> Duration: ... # config-driven, returns ticks
class Network(Protocol):
"""MQTT-shaped transport. Sparkplug B rides on this port and only this port (D-08)."""
async def publish(self, topic: str, payload: bytes, *, qos: int = 1,
retain: bool = False) -> None: ...
def subscribe(self, pattern: str, *, qos: int = 1) -> AsyncIterator[Message]: ...
async def connect(self, node: NodeId, *, will: Will | None = None) -> Connection: ...
def topology(self) -> NetworkTopology: ...
class EventBus(Protocol):
"""Subject-addressed fan-out. No retain, no will, no wildcard pattern (D-08)."""
async def publish(self, subject: str, event: Envelope, *, key: str | None) -> None: ...
def subscribe(self, subjects: Sequence[str], *, group: str) -> AsyncIterator[Envelope]: ...
async def commit(self, upto: Cursor) -> None: ...
class EventLog(Protocol):
async def append(self, event: Envelope) -> None: ...
def read(self, *, since: SimInstant | None, subjects: Sequence[str] | None,
cursor: Cursor | None) -> AsyncIterator[Envelope]: ...
async def hash(self) -> str: ...
class TableStore(Protocol):
"""Typed against structural protocols, never against pyarrow itself (D-10)."""
async def write(self, table: str, batch: ArrowRecordBatchLike, *,
mode: Literal["append", "overwrite"]) -> None: ...
async def query(self, sql: str, params: Mapping[str, Any], *,
order_by: Sequence[str]) -> ArrowTableLike: ...
async def schema(self, table: str) -> ArrowSchemaLike: ...
class BlobStore(Protocol):
async def put(self, key: str, data: bytes, *, content_type: str) -> BlobRef: ...
async def get(self, key: str) -> bytes: ...
def list(self, prefix: str) -> AsyncIterator[BlobRef]: ... # ascending by key
async def delete(self, key: str) -> None: ...
def url(self, key: str, *, expires: Duration | None = None) -> str: ...
class KeyValue(Protocol):
async def get(self, key: str) -> bytes | None: ...
async def set(self, key: str, value: bytes, *, ttl: Duration | None = None) -> None: ...
async def compare_and_set(self, key: str, expect: bytes | None,
value: bytes) -> bool: ...
async def delete(self, key: str) -> None: ...
def scan(self, prefix: str) -> AsyncIterator[tuple[str, bytes]]: # ascending by key
...
class Storage(Protocol):
"""Aggregate facade over the four storage ports. Holds no state of its own."""
event_log: EventLog
table_store: TableStore
blob_store: BlobStore
key_value: KeyValue
class MetricsSink(Protocol):
def counter(self, name: str, value: int, **labels: str) -> None: ...
def gauge(self, name: str, value: float, **labels: str) -> None: ...
def histogram(self, name: str, value: float, **labels: str) -> None: ...
def flush(self) -> None: ...
class Secrets(Protocol):
async def resolve(self, ref: SecretRef) -> str: ... # "env:NAME" | "file:P" | "k8s:NAME"
def redact(self, text: str) -> str: ... # for log lines and problem docs
class Identity(Protocol):
async def authenticate(self, credential: Credential) -> Principal: ...
def scopes(self, principal: Principal) -> Sequence[str]: ... # sorted, D-03
async def client_certificate(self, node: NodeId) -> ClientCert | None: ...
class Inference(Protocol):
"""The learned-model seam named by D-04. Simulation mode binds RecordedInference."""
async def complete(self, request: InferenceRequest) -> InferenceResponse: ...
def model_artifact_hash(self) -> str: ...
def budget(self) -> InferenceBudget: ... # deterministic token and step caps, never seconds
class IdGen(Protocol):
def new(self, prefix: str) -> str: ...
class Env(Protocol):
def get(self, key: str, default: str | None = None) -> str | None: ...
def hostname(self) -> str: ...
def cwd(self) -> Path: ...
Two rules bind every port signature and are checked by the conformance suite rather than by
review. First, applying D-03, no method returns an unordered collection whose iteration order could
reach an event, a hash, or a branch. Identity.scopes, BlobStore.list, and KeyValue.scan are
specified as ascending, and TableStore.query takes a mandatory order_by and returns rows in
exactly that order. Second, applying D-10, no signature names a type from a heavy dependency; the
three Arrow*Like protocols come from twinflow-schemas and describe only the members the kernel
touches.
TableStore.query deserves the explicit rule because the in-memory adapter runs DuckDB over Arrow
buffers, and a SQL engine is free to return hash-aggregate and hash-join results in any order.
InMemoryTableStore sets threads=1 and preserve_insertion_order=true on its
connection, and rejects a query with an empty order_by as TF-K012 query has no total order.
INV-K21 is the property that a query's result is identical across thread counts.
Runtime is the injected bundle: Runtime(clock, rng, net, bus, storage, ids, env, metrics,
secrets, identity, inference, log, manifest). There are no module-level singletons. Every
subsystem takes a Runtime in its constructor. This is what makes both modes possible and what the
lint enforces. log and storage.event_log are the same object (2.2).
3.7 Faults¶
class FaultKind(StrEnum):
PARTITION # split the topology into named groups
LATENCY # add a delay drawn from a distribution to a link
DROP # drop with probability p
DUPLICATE # deliver twice with probability p
REORDER # buffer and release out of order within a window
BANDWIDTH # cap bytes per sim second on a link
BROKER_DOWN # the broker node refuses connections
CLOCK_SKEW # a node's reported wall clock drifts (sim clock never skews)
CORRUPT # flip payload bytes with probability p
SLOW_CONSUMER # a subscriber's queue drains at a capped rate
class Fault(BaseModel):
kind: FaultKind
start: SimInstant
end: SimInstant | None # None = for the rest of the run
scope: FaultScope # links, nodes, or topic patterns
params: dict[str, float | DistributionSpec]
class FaultSchedule(BaseModel):
faults: list[Fault]
rng_stream: StreamId = "kernel.net.faults"
Invariants:
- F1. All fault randomness draws from
kernel.net.faults, a dedicated child stream. Enabling faults does not shift any process-level draw, so a faulted run and a clean run at the same seed remain comparable, which is exactly what the resilience tests need. - F2.
end > start. Overlapping faults on the same scope compose in declaration order, and the composition order is recorded in the manifest. - F3. With an empty schedule,
InMemoryNetworkdelivers every message exactly once, per topic, in publish order (INV-K7).
4. Events¶
Every subject below lives in /schemas. Shapes show data only; the envelope of section 3.4
wraps all of them. All are additive-only within their major version.
4.1 Published by this section¶
twinflow.kernel.run_started v1.0
Always the first event in the log, at twinflowsimts = 0, twinflowproducerid = "sim",
twinflowseq = 0. Applying D-01, the payload carries the hashed core only. It carries no
started_wall_utc, no git provenance, no platform fingerprint, no package versions, and no host
name; those are RunProvenance and live in manifest.json.
test_run_started_carries_no_wall_clock_or_platform_field (M4) is the standing assertion.
twinflow.kernel.run_finished v1.0
{
"event_count": 184213,
"event_log_hash": "b2:...",
"finished_sim_ts": 86400000000,
"exit": "completed|horizon|aborted|error",
"error": null
}
Two hazards, stated so an implementer chases neither. First, event_log_hash covers every event up
to but not including run_finished, so the field is not self-referential. Second, there is no
finished_wall_utc here. Wall time would put the machine's clock into the last event of the tape
and reopen exactly the hole D-01 closed at the first event; the value lives in RunProvenance
instead. event_count is the total across all producers and is recomputed by the reader from the
merged log, so it is a checksum rather than a counter any single producer maintains.
twinflow.kernel.fault_injected v1.0
{
"fault": {
"kind": "PARTITION",
"scope": { "groups": [["dev-*"], ["broker", "historian"]] },
"params": {}
},
"phase": "start|end",
"affected_links": 52
}
twinflow.kernel.clock_speed_changed v1.0
{
"from": 1.0,
"to": "asap",
"requested_by": "dashboard|api|agent|cli",
"actor": "user:demo",
"sim_ts": 3600000000
}
Consumed by the dashboard speed control and by the E5 audit trail.
twinflow.kernel.checkpoint_written v1.0
{
"checkpoint_id": "ckpt_01J...",
"sim_ts": 43200000000,
"size_bytes": 8412331,
"state_hash": "b2:...",
"resumable_from_schema_snapshot": "b2:...",
"covered_subsystems": [
"kernel.scheduler",
"kernel.rng",
"kernel.network",
"kernel.storage"
],
"resumable": false
}
covered_subsystems and resumable exist because the schema freezes at Phase 0 under
additive-only rules while subsystem snapshots land with E4. A Phase 0 checkpoint covers kernel
state only, so state_hash is honest about what it hashes and resumable: false says plainly that
resuming from it would drop subsystem state. covered_subsystems is a sorted list of the
Snapshottable implementations that participated, so a reader of an old checkpoint can tell what
was in it. The alternative, a state_hash over kernel state alone with no coverage record, would
have shipped a checkpoint that looks resumable and is not, and the frozen schema would have made
that unfixable without a MAJOR bump. simulation.snapshot stays a live config key from
Phase 0 and produces truthful checkpoints from Phase 0.
twinflow.config.config_applied v1.0
{
"config_hash": "b2:...",
"schema_version": "1.3",
"profile": "profiles/midmarket_3pl.yaml",
"overlays": ["scenarios/peak_day.yaml"],
"sets": ["simulation.speed=asap"],
"changed_keys": ["resources.labor.pickers.count"],
"applied_by": "cli|api|agent",
"autonomy_tier": "L1|L2|L3"
}
This is the event E5's closed-loop autonomy audit trail is built from, and the reason it lives here rather than in the autonomy section: an accepted what-if flowing back into the running config is a config event, and the config event schema must exist from Phase 0 or the audit trail has a hole in its history.
twinflow.config.validation_failed v1.0
{
"path": "profiles/x.yaml",
"errors": [
{
"code": "TF-C012",
"message": "...",
"origin": { "file": "...", "line": 214, "col": 7 },
"suggestion": "conveyor_speed"
}
]
}
twinflow.migration.applied v1.0
{
"domain": "historian",
"from_version": 6,
"to_version": 7,
"migration": "0007_add_energy_kwh",
"rows_touched": 1240113,
"duration_ms": 8412,
"check": "passed"
}
twinflow.integration.webhook_delivery_attempted v1.0
{
"subscription_id": "whs_01J...",
"event_id": "evt_01J...",
"attempt_number": 2,
"outcome": "delivered|failed|dropped",
"status_code": 503,
"latency_ticks": 412000,
"next_retry_sim_ts": 3600412000
}
twinflow.integration.api_request v1.0
{
"method": "POST",
"route": "/api/v1/whatif",
"status": 202,
"principal": "apikey:demo",
"scopes": ["whatif:run"],
"idempotency_key": "ik_...",
"latency_ticks": 91000,
"request_hash": "b2:...",
"response_hash": "b2:..."
}
Feeds the 6a11 audit-trail integrity requirement and the 6a15 SIEM analog. Recording it here means those sections consume an existing stream instead of retrofitting instrumentation.
4.2 Consumed by this section¶
twinflow-api and the event log consume every subject, but only through the envelope. They never
depend on a payload field. Two exceptions are declared in consumes.yaml and so covered by
the CI consumer-drift test:
/findingsreadstwinflow.lss.findingfieldsfinding_id, severity, rule, evidence_window, station_id, opened_sim_ts, status./twin/statereadstwinflow.twin.state_snapshotfieldsstations[].id, stations[].wip, stations[].utilization, bottleneck_station_id.
Any change to those fields fails SCHEMA-3 until twinflow-api's consumes.yaml is updated in
the same commit.
Sequencing, because SCHEMA-3 is a required check from Phase 0 while twinflow.lss.finding lands
in Phase 2 and twinflow.twin.state_snapshot in Phase 1. Every consumes.yaml entry carries a
since field naming the phase its producer first exists. SCHEMA-3 skips an entry whose subject
is absent from schemas/registry.yaml and reports it as pending, and prints the pending count.
The check that keeps that from becoming a silent hole is SCHEMA-6: a consumes.yaml entry may be
pending only while its subject is absent from the registry, so the day the producer registers its
subject, the consumer's declaration becomes binding without anyone editing it. A pending entry with
a registered subject is a failure, not a skip.
The same rule covers the reverse direction. twinflow.integration.api_request and
twinflow.integration.webhook_delivery_attempted are registered in Phase 0 (4.1) although
twinflow-api is a Phase 2 deliverable, because SCHEMA-1 needs three samples per subject and
those samples are authored with the schema. Their registry.yaml rows carry
producers: [] and status: reserved until Phase 2, and SCHEMA-7 asserts that a reserved
subject has no producer and that a subject with a producer is not reserved.
4.3 Subject naming and the registry manifest¶
Subjects are twinflow.<domain>.<event_name>, snake_case, singular verb-phrase past tense.
Registered domains at Phase 0: kernel, config, migration, integration, telemetry, twin, lss,
fleet, genealogy, order, finance, mining, forecast, agent, cv, security. New domains need a
line in schemas/registry.yaml and an owning package.
# schemas/registry.yaml
producer_ids: [sim, api, dashboard, agent, device-agent, cli] # the closed set of E3
subjects:
- subject: twinflow.telemetry.sensor_reading
owner: twinflow-sensors
versions: ["1.0", "1.1", "1.2", "1.3"]
current: "1.3"
status: active # active | reserved | deprecated
compat: additive-only
since_phase: 3
producers: [twinflow-sensors, crates/twinflow-device-agent]
producer_ids: [sim, device-agent]
consumers:
[
twinflow-fleet,
twinflow-lss,
twinflow-twin,
twinflow-api,
twinflow-storage,
]
partition_key: device_id
retention: historian
producer_ids at the top of the file is the closed set E3 draws twinflowproducerid from, and the
per-subject producer_ids list is which of them may emit that subject. SCHEMA-8 fails an event
whose twinflowproducerid is not listed for its subject, which turns a mislabelled producer into a
contract failure rather than a confusing log.
5. Behavior¶
5.1 The two modes¶
One codebase, two runtimes. The difference is entirely which implementations RuntimeBuilder
binds.
| Port | Simulation mode | Production mode |
|---|---|---|
| Clock | SimClock over the SimEventLoop, optionally wrapped in PacedClock |
RealClock (time.monotonic_ns for durations, datetime.now(UTC) for the anchor only) |
| Rng | SplittableRng(base_seed, replication_index) |
SplittableRng(base_seed, replication_index), same class; production runs are seeded too, because a production device agent that draws jitter stays reproducible |
| Network | InMemoryNetwork with FaultSchedule |
MqttNetwork over aiomqtt to Mosquitto, EMQX, or NanoMQ |
| EventBus | InMemoryEventBus |
MqttEventBus over the tier's broker at garage and growth, KafkaEventBus at enterprise |
| EventLog | InMemoryEventLog plus optional NDJSON sink |
LocalNdjsonEventLog, PostgresEventLog, or KafkaEventLog |
| TableStore | InMemoryTableStore (Arrow buffers, queried through an embedded DuckDB pinned to one thread) |
DuckDbTableStore / DeltaTableStore / PostgresTableStore |
| BlobStore | InMemoryBlobStore |
LocalBlobStore or S3BlobStore |
| KeyValue | InMemoryKeyValue |
LocalFileKeyValue at garage, PostgresKeyValue above it |
| IdGen | DeterministicIdGen |
Uuid7IdGen |
| Env | FrozenEnv populated from the resolved config only |
OsEnv |
| MetricsSink | RecordingMetricsSink |
OpenTelemetry OTLP exporter |
| Secrets | FrozenSecrets populated from the resolved config only |
DotEnvSecrets, DockerSecrets, or K8sSecrets |
| Identity | NullIdentity (every principal is sim:local with the configured scopes) |
ApiKeyIdentity, OidcIdentity, or MtlsIdentity |
| Inference | RecordedInference replaying a pinned response cassette (D-04) |
OllamaInference, VllmInference, or HostedInference, registered by twinflow-agent |
InMemoryTableStore is the one simulation implementation that embeds a third-party engine, so its
module path is listed in adapter_paths (5.9) with a reason string rather than left to trip
TFD003. It opens DuckDB with threads=1, preserve_insertion_order=true, and no filesystem
access, which removes the two nondeterminism sources a SQL engine otherwise contributes: a thread
count derived from the machine's core count, and an unordered hash aggregate. INV-K21 is the
property that proves it.
EventBus has no in-memory production binding at any tier, and the reason is that production means
more than one process. Garage tier already runs twinflow-sim, twinflow-api, and
twinflow-dashboard as separate containers (5.11), so an in-process fan-out would deliver to
nobody. MqttEventBus publishes subject-addressed messages over the tier's existing broker with
retain and last-will unused, which is the subset D-08 says an EventBus may rely on, and
KafkaEventBus takes over at enterprise where the fan-out is a partitioned log. The broker bridge
between them is the arrangement D-08 describes: MQTT at the operational edge, the partitioned log
at the information layer.
Precedence, applying D-12, because two keys can name a binding and the earlier draft did not say
which wins. simulation.mode selects the port family: simulation binds the deterministic family
in column two, production binds the real family in column three. deployment.adapters.* selects
within the production family only. Naming an adapter while simulation.mode: simulation is
TF-C043 deployment.adapters.<port> is meaningless in simulation mode; the port family is chosen by
simulation.mode, an error rather than a silent no-op. deployment.tier selects nothing by itself;
it only supplies the defaults each deployment.adapters.* key takes when the key is absent.
RuntimeBuilder.from_config(cfg) is the one place that reads either key. There is no
if simulation: anywhere outside RuntimeBuilder and the adapter modules. A lint rule (TFD013)
flags the identifier mode compared against a literal outside those files.
5.2 The deterministic scheduler¶
Every subsystem is written as ordinary async def code. In production mode it runs on the standard
asyncio loop. In simulation mode it runs on SimEventLoop, a full implementation of
asyncio.AbstractEventLoop whose time source and timer heap are backed by simpy.Environment.
Mechanics:
SimEventLoop.time()returnsenv.now / tick_hzas a float for asyncio's benefit, whileSimClock.now()returns the integer tick value that all application code uses. Application code never callsloop.time().call_at(when, cb)schedules a SimPy timeout event. SimPy already breaks ties on(time, priority, eid)with a monotoniceidcounter, which gives a total order over simultaneous events.call_soon(cb)appends to a FIFO ready queue drained fully before sim time advances. The loop never advances sim time while a callback is runnable, which is what makes "zero-duration work" take zero sim time and makes ordering independent of machine speed.- All IO in simulation mode goes through
InMemoryNetworkandInMemoryStorage, which complete throughcall_soonorcall_at. There are no real file descriptors, soadd_reader/add_writerraiseNotImplementedError. That is deliberate: a subsystem that tries to open a socket fails loudly in simulation rather than silently becoming nondeterministic. run_in_executorraises.asyncio.to_threadraises. Threads are the one thing the scheduler cannot make deterministic, and lint ruleTFD004bans them ahead of the runtime error.- Structured concurrency uses
asyncio.TaskGroup. Task creation order is the tie-break for simultaneous readiness, and task creation order is deterministic because the code path is deterministic.
Rationale for building the loop rather than writing subsystems as SimPy generators: SimPy generators cannot run in production, and the whole point of DST is one codebase. The alternative considered and rejected is writing the subsystems twice.
What SimPy is used for, stated concretely because the earlier draft used only env.now and the
tie-break rule, which heapq supplies in a few lines and which would not have justified the
dependency:
| SimPy element | Used for |
|---|---|
Environment.now and the timeout heap |
the loop's time source and timer heap |
(time, priority, eid) ordering with monotonic eid |
the total order over simultaneous events |
Resource, PriorityResource, PreemptiveResource |
station servers, dock doors, AMR charge points, forklifts |
Container |
bulk levels: AMR battery charge, tank levels in the upstream factory |
Store and FilterStore |
buffers and staging lanes where an item is selected by predicate |
The three resource classes, Container, and Store are the reason a warehouse twin reaches for a
DES library at all, and none of them is available to async def code through SimPy's generator
API. twinflow.kernel.resources rebuilds them as asyncio-native, deterministically
ordered primitives and is a Phase 0 deliverable, not a Phase 1 discovery. Waiter ordering is FIFO
within a priority class, ties broken by request sequence number, which is INV-K22.
test_resources_match_simpy_semantics runs a table of scripted request-and-release sequences
against both the twinflow primitive and the SimPy equivalent and asserts the grant order matches,
which makes SimPy a conformance oracle for the part of it that is not used directly.
The size of the loop is not estimated. SimEventLoop implements the subset of
asyncio.AbstractEventLoop that the codebase calls, and that subset is enumerated rather than
guessed: time, call_soon, call_at, call_later, create_task, create_future,
run_until_complete, run_forever, stop, is_running, is_closed, close,
default_exception_handler, and set_exception_handler. Everything else raises
NotImplementedError. test_sim_event_loop_implements_exactly_the_declared_surface compares the
overridden method set against that list and fails on either a missing entry or an undeclared
extra, so the surface is a checked contract rather than a line count.
Snapshotting: Snapshot captures the current sim instant, the ready queue, the timer heap, the RNG
stream draw counters, the resource waiter queues, storage state, network buffers, and per-subsystem
state gathered through the Snapshottable protocol. Phase 0 defines that protocol and implements it for the kernel's own
state, including the resource primitives above. Subsystem implementations land with E4, which is
when replay and counterfactuals need them. The DET-6 gate is written in Phase 0 and marked
xfail(strict=True) until E4, so the gate exists before the feature and flips to passing rather
than being invented later. checkpoint_written.resumable (4.1) is false until that flip, so a
Phase 0 checkpoint never claims more than it holds.
5.3 The sim clock and time compression (C2)¶
SimClock exposes integer tick time. PacedClock wraps it and is the pacer of D-02, one of the
four places a wall clock may be read:
before advancing sim time from t0 to t1:
if speed is finite:
target_wall = pacer_start_monotonic_ns
+ (t1 - run_start_sim_ts) * 1_000_000_000 // (tick_hz * speed)
block on the real monotonic clock until target_wall
The monotonic reading is the pacer's own, taken when pacing starts and held in PacedClock. The
wall_clock_anchor in the hashed core carries no monotonic field at all (M5), and the pacer's
reading never reaches an event payload, the hashed tape, or a branch. PacedClock lives at
packages/twinflow-kernel/src/twinflow/kernel/_impl/paced.py, which is listed in adapter_paths
(5.9) with the reason string the D-02 pacer, and it carries the required
# twinflow: allow-nondeterminism(TFD001) annotation with an ADR link because it sits inside the
kernel package.
Pacing delays the loop between events and never reorders them, so the emitted log is identical at
every speed. test_pacing_does_not_change_the_tape asserts a paced run and an unpaced run produce
identical logs, and DET-4 is the CI gate over the same property. That is the whole safety argument
for time compression: pacing changes when an event is emitted in wall time, never which event is
emitted or in what order.
Speed control protocol: a control-plane subject twinflow.control.command with
{"command": "sim.speed.set", "value": 4.0} accepted from the CLI, the REST API
(POST /api/v1/runs/{id}/speed), and the dashboard websocket. Accepted values: pause,
step with {"ticks": n} or {"events": n}, asap, or a float in [0.01, 100000]. Every change
emits twinflow.kernel.clock_speed_changed. Out-of-range values return TF-A031.
The lower bound of 0.01 is what a human operator can ask for at the dashboard, where running a demo at one hundredth of real time is a legitimate request. It is not what a property test or a determinism gate may use, and applying D-13 the arithmetic is written down rather than assumed.
| Consumer | Scenario | Speed range | Worst-case wall time | Budget |
|---|---|---|---|---|
| Operator | any | [0.01, 100000] |
unbounded, and that is the point | none |
| DET-4 | SCN-F0, 60 simulated seconds |
asap and 50 |
60 / 50 = 1.2 s for the paced arm | 6 min |
| INV-K9 | SCN-F0, 60 simulated seconds |
log-uniform [60, 1e5] |
60 / 60 x 25 examples = 25 s | 5 min |
The Hypothesis strategy speeds() clamps its log-uniform draw to [60.0, 100000] and caps INV-K9
at 25 examples, and both numbers live in the generator rather than only in the config validator, so
a property run cannot select a speed whose worst case exceeds the property job budget. The clamp
is stated in one place and imported by every property that draws a speed;
test_speed_strategy_lower_bound_is_clamped asserts the generator's own minimum, so a future edit
that widens it fails a test rather than a timeout. BUDGET-1 reads the same three numbers and
recomputes the products above, which is what turns the table from arithmetic in prose into a
checked claim: a scenario that grows past its job budget then fails as a defect rather than as a
timeout.
Timeouts: clock.timeout(Duration) is the only timeout primitive. A "broker keepalive of 60
seconds" is 60 sim seconds and compresses with everything else. asyncio.wait_for is banned by
TFD014 because it uses loop.time() directly.
Wall-clock mapping: recorded once per run in RunManifest.wall_clock_anchor:
Shift calendars, tariff effective dates, and S&OP monthly cycles all need a real calendar date, so
the anchor carries a timezone and the config declares facility.timezone. DST transitions inside a
run are honored through zoneinfo at the calendar layer; sim ticks themselves are monotonic and
unaffected, which is the property that avoids a night shift being recorded as 25 hours long across
a fall-back transition. VAL-F14 is the gate over it.
5.4 Determinism (C1)¶
Applying D-05, the determinism claim is two tiers and the README states both rather than the stronger one alone. A vague determinism claim is worse than none, and an unachievable one is worse than vague.
| Tier | Conditions | Guarantee | Gate |
|---|---|---|---|
| A. Byte-identical | same seed, same resolved config, same platform, same pinned lockfile | the canonical event log is byte-identical, and so is event_log_hash |
DET-1, hash equality |
| B. Value-equivalent | same seed and resolved config, different OS or CPU | business events are identical in count, order, subject, and every integer and quantised field; continuous fields agree within a measured tolerance | DET-2, reported divergence |
The gap between A and B is not a rounding gap, and calling it one would have been the most
dangerous sentence in this document. NumPy's published compatibility policy for numpy.random
states the mechanism plainly: "different CPUs implement floating point arithmetic differently, and
this can cause differences in certain edge cases that cascade to the rest of the stream." A
rejection-sampling distribution such as gamma or Poisson compares a transcendental against a
uniform draw. One unit in the last place of log or exp can flip that comparison, which consumes
a different number of uniforms, which desynchronises the stream from that point on. The result is
not a 1e-9 difference in one field. It is a different run, with different integer fields and a
different event count.
The same page sets the ceiling on tier A. It promises the stream only when you "perform the same sequence of method calls with the same arguments, on the same build of numpy, in the same environment, on the same machine", and adds that "these conditions are very strict". Tier A's conditions are that sentence restated as a gate, which is why tier A names the pinned lockfile and the platform and tier B does not.
Two consequences follow, and both are design decisions rather than caveats.
First, the twinflow distribution layer calls only the three Generator methods NEP 19 commits to.
NEP 19, NumPy's Random Number Generator Policy, is specific about which methods carry a stream
guarantee: "They MUST guarantee stream-compatibility for a specified set of methods ... Namely,
.bytes(), integers() ... random()". normal, gamma, poisson, and every other
distribution method sit outside that set, and NEP 19 states that breaking their streams "will be
allowed with caution ... on X.Y releases, never X.Y.Z". twinflow.kernel.numeric
implements inversion and ziggurat sampling for the families the Rng port exposes on top of
random, integers, and bytes alone. This removes the rejection-loop desynchronisation from the
twinflow-owned path, because the number of draws a twinflow distribution consumes is fixed by its
own algorithm rather than by a floating-point comparison inside NumPy. TFD017 bans every other
Generator method outside that module, and R8 fixes the block size at one value per call because
the same policy page states that "Calling rng.random() 5 times is not guaranteed to give the same
numbers as rng.random(5)".
Second, DET-2 distinguishes desynchronisation from drift instead of lumping them together.
test_rng_stream_length_is_identical_across_platforms compares
RunProvenance.rng_draw_counts_sha256 between the two runners first. If the draw counts differ,
the failure is named as stream desynchronisation and the field-by-field comparison is not run,
because comparing two different runs field by field produces noise rather than a diagnosis. Only
when draw counts match does DET-2 compare fields.
The DET-2 tolerance is measured, not chosen, and until it has been measured it is absent rather
than guessed. The gate reports the observed maximum relative divergence over continuous fields
across the matrix, writes it to artifacts/determinism/divergence-<version>.json, and compares it
against [tool.twinflow.det] cross_platform_tolerance in the root pyproject.toml. That key is
set from the first ten green runs across the ubuntu and macos runners, at ten times the largest
divergence those runs showed, and the file records the run ids it came from. Before those ten runs
exist the key is absent, DET-2 asserts only the integer and quantised half and reports the
continuous divergence without judging it, and open question 14 records that the number is not yet
established. When a later observation exceeds the recorded tolerance, the gate names which of the
two explanations applies: the tolerance was set from too little evidence, or a real defect
appeared. It never passes by widening itself, and test_cross_platform_tolerance_is_not_edited_by_ci
asserts that no CI job writes the key.
Mitigations that narrow the gap: all times are integers (T1); every float that crosses the event
boundary is quantised to the precision its schema declares in x-twinflow-precision before
serialization; canonical JSON uses RFC 8785, whose section 3.2.2.3 requires numbers to be
serialized per ECMA-262 section 7.1.12.1 including its Note 2, which is shortest round-trip and
so platform independent; CI exports NPY_DISABLE_CPU_FEATURES to a common baseline so
numpy's SIMD dispatch matches across runners.
PYTHONHASHSEED: just and CI export PYTHONHASHSEED=0. DET-3 also runs one job at
PYTHONHASHSEED=12345 and asserts an identical log, which catches the set-iteration leaks D-03
bans and that the lint's static rule cannot see.
Third-party determinism is the remaining risk. scikit-learn, Optuna, statsforecast, and any torch-backed model all reach for global RNG state. Handling:
twinflow.kernel.thirdparty.deterministic_context(rng_child)seedsnumpy.randomlegacy global state,random,torch(if present), andPYTHONHASHSEED-sensitive library caches, and restores on exit. Every call into a third-party stochastic library is wrapped in it.- Each such library gets a dedicated regression test in
tests/determinism/thirdparty/namedtest_<library>_is_deterministic, asserting two calls with the same context produce identical output. When a library version bump breaks it, the failing test names the library. - Libraries that cannot be made deterministic are recorded in
docs/limitations.mdwith the specific call site and the effect, and their outputs are excluded from the byte-identity gate by an explicit allowlist indeterminism_exclusions.yaml, each entry needing an owner and a linked issue. Exclusions are visible, never silent.
Process mining is not on that list, and the reason is a license rather than a seed. Applying D-14,
PM4Py and pm4pyminimal are AGPL-3.0 (version 2.7.23.3, read from the package index), and AGPL
section 13 covers network interaction, which this project does through a dashboard, an MCP server,
and an HTTP API. Importing either would place the whole work under AGPL and break the Apache-2.0
plus commercial dual license. twinflow-procmine is written here under Apache-2.0 instead, and
its determinism is this repository's to guarantee rather than a third party's to break. PM4Py
remains available as a development-only comparison oracle, which is what gives the conformance
gates in the LSS section an external reference under D-11.
Seed recording: tf run writes artifacts/runs/<run_id>/manifest.json, which is RunProvenance,
and events.ndjson, which is the tape. Every demo script, eval case (E27), dataset card (E25), and
replay bundle (E1) carries the run id and the seed in its front matter. A README claim with a
number carries the run id that produced it, checked by a docs lint
(tools/check_measured_claims.py) that fails on a number in the README's headline block without an
adjacent run reference. The same lint runs over the operator-facing strings in
twinflow/config/messages/, so a number printed to a user in a warning carries a run reference
too; 5.6's historian sizing warning is the case that rule was written for.
5.5 Schema registry (C3)¶
Layout:
schemas/
registry.yaml
envelope/v1.json
telemetry/sensor_reading/v1.3.json
telemetry/sensor_reading/v1.2.json # historical versions are never deleted
lss/finding/v2.0.json
genealogy/lot_event/v1.0.json
order/order_state_changed/v1.1.json
finance/gl_posting/v1.0.json
...
x-keywords.md # documentation of twinflow's custom keywords
JSON Schema 2020-12. Custom annotation keywords, all prefixed x-twinflow-:
| Keyword | Meaning |
|---|---|
x-twinflow-precision |
decimal places a float is quantised to before serialization |
x-twinflow-unit |
the SI unit the number is expressed in, validated against pint |
x-twinflow-open-enum |
consumers must tolerate unknown members; only such enums may gain members within a major |
x-twinflow-open-range |
consumers must tolerate values outside the declared range; only such fields may widen |
x-twinflow-pii |
always false in this repo, asserted by a test, because the data is synthetic |
x-twinflow-partition-key |
the field used for partitioning in the historian |
x-twinflow-since |
the release that added this field |
x-twinflow-not-in-hash |
a config key excluded from ConfigHash because it cannot change the tape (G4, M6) |
x-twinflow-stored-bytes |
measured stored bytes per row for a subject, with the run id it was measured on (5.6) |
Version numbering: MAJOR.MINOR per subject, independent of the release version. MAJOR changes are
breaking, MINOR are additive.
Compatibility rules, enforced by tf-schemas check:
| Change | Within a major |
|---|---|
| Add an optional property with a default | Allowed, MINOR bump |
| Add a required property | Rejected |
| Remove a property | Rejected |
| Make an optional property required | Rejected |
| Make a required property optional | Rejected (breaks consumers that assume presence) |
| Widen a numeric range or string pattern | Allowed, MINOR bump, only where x-twinflow-open-range: true |
| Widen a range on a closed-range field | Rejected (breaks consumers that assume the narrow range) |
| Narrow a numeric range or string pattern | Rejected |
| Add an enum member | Allowed only if x-twinflow-open-enum: true, MINOR bump |
| Remove an enum member | Rejected |
| Change a type | Rejected |
| Rename a property | Rejected (add the new one, deprecate the old, remove at MAJOR) |
| Add a new subject | Allowed, no bump to existing subjects |
| Deprecate a subject | Allowed, MINOR bump, status: deprecated plus a removed_in release |
The range rule needs the paired keyword because widening breaks a consumer in the same way
narrowing does. A consumer that read divert_rate as a value in [0, 1] and sized a fixed-point
field accordingly breaks when the producer starts emitting 1.4, and it breaks silently. The
distinction is declared by the producer at the field level, exactly as
x-twinflow-open-enum declares it for enums, and a consumer that reads an x-twinflow-open-range
field has been told to handle values outside the range. SCHEMA-3 fails on a widened range for any
field a consumer declares in consumes.yaml unless that field carries x-twinflow-open-range:
true, which closes the hole where a widened range passed the drift check in silence.
A MAJOR bump needs: a new vN.0.json, an upcaster from every prior major, an entry in the
CHANGELOG compatibility table, and both versions publishing in parallel for at least one minor
release of the repo. That last clause is a gate, not an intention. SCHEMA-9 reads
registry.yaml's parallel_until field on the superseded major, and for each release inside the
overlap window it asserts that the SCN-F4 smoke run emitted at least one sample of the superseded major
and at least one of its successor. A producer that quietly stopped emitting the superseded major during the
overlap fails the check, which is what the clause was for.
Producer/consumer contract tests:
- Each producing package ships
tests/contract/samples/<subject>.v<ver>.json, at least three samples per subject covering minimum, maximum, and a realistic middle case.SCHEMA-1validates every sample against its schema. - Each consuming package ships
consumes.yamllisting the subject, version range, the exact field paths it reads, and thesincephase of 4.2.SCHEMA-3fails when a consumed path disappears, changes type, narrows, or widens outsidex-twinflow-open-range. SCHEMA-2fails when a schema file changes without a version bump, detected by comparing content hashes against the previous tag.SCHEMA-4fails when generated bindings (Pydantic and Rust serde) differ from a fresh regeneration, which keeps the checked-in bindings honest.
Codegen: just schemas-gen runs datamodel-code-generator for Python into
twinflow/schemas/models/ and a typify-class generator for
crates/twinflow-device-agent/src/schemas/. The Rust agent and the Python fleet cannot
drift on the wire format, which is the failure a hand-maintained second definition invites.
Codegen closes the wire-format gap. It does not close the behavior gap, and D-06 is the ruling
that names the other half: the Rust agent draws randomness, so it needs the same RNG contract, not
only the same field names. RUST-1 (7.3) is to the RNG what SCHEMA-4 is to the wire format, and
both run in the rust CI job.
5.6 Config validation (C5)¶
Pipeline, in order, each stage reporting all its errors before the next runs:
- Parse.
ruamel.yamlround-trip load. Syntax errors report asTF-C001with line and column from the parser. - Include and overlay resolution.
!includeof relative paths,--overlay file.yamlmerges with documented semantics (mappings deep-merge, sequences replace unless the overlay key ends in+, in which case it appends),--set dotted.path=valueapplied last. Merge is associative over disjoint overlays and idempotent for a repeated identical overlay (INV-K10). - Schema validation. JSON Schema 2020-12 against
schemas/config/facility/v1.3.jsonand the registered section schemas. Errors map JSON pointers back to line and column through the provenance map. - Model validation. Pydantic models with
extra="forbid", field validators for ranges and formats. - Unit resolution. Every value whose schema declares
x-twinflow-unitis parsed by pint and converted to the canonical unit."1.2 m/s"is fine,"1.2"isTF-C201 missing unit,"1.2 kg"for a speed field isTF-C202 unit mismatch: expected a velocity, got a mass. - Cross-reference checks. Every reference resolves:
flows[].station_idintolayout.stations[].id(TF-C101);sensors.instances[].typeinto the sensor catalog (TF-C102);spec_limitskeys into metric ids inmetrics.yaml(TF-C103);policies.slotting.zonesintolayout.zones[].id(TF-C104);partners.suppliers[].lanes[]intotransport.lanes[].id(TF-C105). Each names both the dangling reference and the nearest valid candidate. - Plausibility checks. Warnings, not errors, in the
TF-C3xxrange: a station with zero capacity, a shift calendar covering fewer than 1 hour per day, a dock count of zero with inbound flows defined, and a sensor sample rate and horizon whose product exceeds the historian volume threshold.
Stage 6 works from Phase 0, and the sequencing that makes that true is worth stating because the
targets it resolves against belong to later phases. Each of the five reference targets is a
registered reference domain declared through the twinflow.config.references entry-point group
by the package that owns it, and the loader resolves against the union of the domains that are
registered in the running installation. A reference into a domain no package has registered is
reported as TF-C130 reference target 'transport.lanes' has no installed owner (install
twinflow-forecast), a warning at garage tier and an error under strict_sections: true. This is
the same mechanism 2.4 uses for config blocks, applied to cross-references.
That keeps CFG-2 honest from Phase 0. Every TF-C1xx code is produced by at least one test
because each reference domain ships a fixture pair, one resolving and one dangling, alongside its
schema. TF-C101 and TF-C103 have their fixtures in Phase 0 because layout and metrics.yaml
are Phase 0 artifacts; TF-C102 gains its fixture in Phase 3 with the sensor catalog; TF-C104 in
Phase 3b with slotting; TF-C105 in Phase 3h with the transport network. CFG-2 reads the same
entry-point registry and asserts that every code belonging to a registered domain has a test, so
the gate cannot pass by the domain being absent and cannot fail for a domain that has not shipped.
CFG-4 asserts the complement: every reference domain declared in docs/reference/error-codes.md
either has a registered owner or a named phase.
Error rendering, human format:
error[TF-C012]: unknown key 'converyor_speed' in station 'sort-01'
--> profiles/midmarket_3pl.yaml:214:7
|
212 | - id: sort-01
213 | type: sortation
214 | converyor_speed: 1.2 m/s
| ^^^^^^^^^^^^^^^ did you mean 'conveyor_speed'?
|
= valid keys for station.type=sortation: conveyor_speed, divert_count, divert_logic,
induct_rate, recirculation_limit
= docs: https://<pages>/reference/facility/#stationsortation
warning[TF-C312]: sensor 'vib-conv-07' samples at 2000 Hz over a 30 day horizon
--> profiles/midmarket_3pl.yaml:891:9
|
= 2000 Hz x 2592000 s = 5.184e9 readings
= {STORED_BYTES} stored bytes per reading gives about {VOLUME} in the historian
= bytes per reading measured from run {RUN_ID}, subject
twinflow.telemetry.sensor_reading v1.3, Delta with zstd level 3
= set sensors.instances[].downsample or shorten simulation.horizon
The volume warning prints two numbers and treats them differently, because they are two kinds of
claim. The reading count is arithmetic over the config, recomputed in the message so a reader can
check it against the two config values named on the line above. The byte figure is a measurement,
so the template carries no literal at all: {STORED_BYTES} is read from
x-twinflow-stored-bytes on the subject schema, {VOLUME} is the product, and {RUN_ID} is the
profile run the constant was measured on. just measure-row-bytes writes all three, and
tools/check_measured_claims.py fails the build when x-twinflow-stored-bytes changes without its
run reference changing.
Until that measurement exists, the field is absent and the message degrades to the reading count
plus historian volume not yet measured, run just measure-row-bytes. An unattributed round number
in operator-facing output is the same defect as an unattributed round number in the README, and it
is caught by the same lint. The current unfilled value is
TBD, which the release gate
refuses to tag from v0.4.0 onward while it still reads TBD. The tag it names is the one its
producer arrives at: foundations 5.5 places twinflow.telemetry.sensor_reading at since_phase: 3,
which roadmap.yaml maps to v0.4.0, and the Delta writer EVENT_TABLE names is the delta extra
of 2.7 rather than a base install. A marker owed at a tag whose producer does not exist is a claim
no measurement can discharge, and the three exits the gate offers are to measure it, to drop the
claim, or to name the tag it arrives at. Dropping it would delete a number the plan owes, which
ENGINEERING rule 4 refuses, so it names the tag. tools/measure_row_bytes.py fills it, and today it
refuses. It prints the two reasons rather than assuming them. twinflow.telemetry.sensor_reading is
absent from schemas/registry.yaml, so nothing fixes the payload whose bytes would be counted. The
writer EVENT_TABLE names is not installed, so there is no Delta table to measure. A Parquet byte
count belongs to the encoder that produced it, and a substitute encoder measures itself.
JSON format is the same content under {"errors": [...], "warnings": [...]} for CI and editor
integration. Exit codes: 0 clean, 1 errors present, 2 warnings present with --strict-warnings,
3 the file could not be parsed at all.
Commands:
tf config validate <path> [--overlay ...] [--set ...] [--format human|json] [--strict-warnings]tf config explain <dotted.path>prints the resolved value, its unit, its provenance chain, its schema description, and its default.tf config hash <path>prints theConfigHash.tf run --dry-runperforms the full load, builds theRuntime, constructs every subsystem, emitsrun_started, and exits without advancing the clock. It reports the resource counts it would create (devices, stations, tasks) and the estimated historian volume.
5.7 Migrations (C6)¶
Two domains, one framework.
Store migrations. migrations/<domain>/NNNN_<slug>.py with:
VERSION = 7
DOMAIN = "historian"
DESCRIPTION = "add energy_kwh to station_cycle"
DEPENDS_ON = 6
async def up(store: TableStore, log: EventLog) -> MigrationResult: ...
async def check(store: TableStore) -> None: ... # raises on failure
BACKFILL = "recompute" # "none" | "recompute" | "null" | "constant:<value>"
Forward only. Rollback is by restoring a backup, and the drill for that is E18's backup and
recovery scenario, which is why that scenario is a real capability rather than a story. Applied by
tf historian migrate [--to N] [--dry-run], recorded in a _twinflow_schema_version table and as
a twinflow.migration.applied event. Applying twice is a no-op (INV-K14). On Delta tables,
permitted operations are add-column and add-partition-column only; anything else writes a new table
version and a view, because rewriting a Delta table in place forfeits time travel and time travel
is the point of using Delta.
Recorded-run compatibility. A log recorded under an older schema snapshot is readable through
upcasters. twinflow.schemas.upcast holds a registry keyed (subject, from_version). Every
version bump that removes or renames anything ships an upcaster in the same commit; SCHEMA-5
fails a version bump without one. Upcasters are never deleted, so the set grows monotonically and
the repo can always read every log it has ever written. Archived golden logs live in
tests/fixtures/logs/<release>/ and MIG-2 replays each one on every CI run, asserting that
derived KPIs match the golden values.
Config upgrader. tf config upgrade <path> --to 2.0 applies steps from migrations/config/,
using ruamel round-trip so comments and formatting survive. Each step declares
(from_version, to_version, transform, ambiguity_check). Where a transform cannot decide (a split
key whose new value depends on operator intent), it writes the best guess plus a
# TODO(twinflow-upgrade): confirm, see docs/migrations/1.x-to-2.0.md#stations-split comment and
exits 2. --check reports what would change without writing.
CHANGELOG compatibility table, maintained per release in CHANGELOG.md:
| Release | facility.yaml schema | event schema snapshot | historian version | reads runs from | reads configs from |
|---|---|---|---|---|---|
| v0.3.0 | 1.0 | s-2026.03 | 4 | >= v0.1.0 | >= v0.1.0 |
| v0.4.0 | 1.1 | s-2026.05 | 6 | >= v0.1.0 | >= v0.1.0 |
SEMVER-3 fails a release whose table row is missing or inconsistent with the registry.
5.8 Versioning and releases (C9)¶
Four versioned contracts and what governs each:
| Contract | Version carrier | Breaking change means |
|---|---|---|
| Python package public API | the release version, lockstep across all bricks | removing or renaming a symbol in __all__, changing a signature incompatibly, tightening an input type, loosening an output guarantee |
| REST / GraphQL / MCP | URL major (/api/v1), GraphQL SDL with @deprecated, MCP tool schema version |
removing a route, field, or tool; adding a required parameter; changing a status code class; narrowing a response type |
| Event schemas | per-subject MAJOR.MINOR, snapshot id in the manifest |
anything in the rejected column of section 5.5 |
| facility.yaml and catalogs | schema_version key inside the file |
removing a key, changing a default that changes behavior, changing a unit |
Lockstep versions across bricks, as C9 requires. Rationale: a reader who installs two bricks must
never consult a compatibility matrix. Cost, stated honestly in docs/versioning.md: a patch in one
brick republishes all of them, and PyPI carries versions with no changes for most bricks. The
alternative considered, independent versions per brick, was rejected because A1's promise is
"take one brick", not "solve a dependency graph", and because it would put a compatibility matrix in
the README where the "use just this part" table belongs.
Enforcement:
SEMVER-1:tf-api-diff(griffe) compares public API against the previous tag and fails a removal or incompatible signature change unless the commit body has aBREAKING CHANGE:footer and the computed next version is a major.SEMVER-2:tf-api checkdiffsapi/openapi.v1.jsonandapi/schema.graphqlagainst the previous tag with the same rule.SEMVER-3: CHANGELOG compatibility table present and consistent.- Deprecation:
@deprecated(since="0.6.0", remove_in="1.0.0", use="new_name")emits aDeprecationWarning, is listed inAPI.md, and a test asserts the warning is raised. Minimum overlap is one minor release for Python APIs and one major for event schemas. - Pre-1.0: until Phase 5 completes, the minor acts as the major.
docs/versioning.mdstates the 1.0.0 criterion explicitly: Phase 5 complete, all VAL-GATEs green, all three A2 profiles running in CI, and the A6 surface frozen.
Release pipeline, triggered by a tag on main:
- Compute the version from conventional commits since the last tag.
- Regenerate the CHANGELOG section for the release, grouped by phase milestone.
- Run the full CI matrix plus the nightly-only jobs.
- Build sdists and wheels for every brick with
uv build --all. - Build the Rust device agent for
x86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu, andxtensa-esp32-espidf(the last one for E47's hardware-in-the-loop path). - Generate the SBOM (CycloneDX) for the Python workspace and the Rust crate (C11 hook).
- Record a determinism manifest: the
run_idandevent_log_hashof a smoke run for every profile present inprofiles/at this version, committed toartifacts/determinism/<version>.jsonalong with the profile count. This is what makes "byte-identical across releases where nothing relevant changed" a checkable claim rather than an assertion. The set is read from the directory rather than fixed at three, becausemicro_fulfillmentlands in Phase 0,midmarket_3plin Phase 3, andenterprise_networkin Phase 3h, so a release tagged before Phase 3h has fewer than three and a manifest demanding three would fail every early release.SEMVER-3asserts the manifest lists exactly the profiles the tree contains, which is the assertion that keeps a profile from being quietly skipped once all three exist.CFG-1andA2-1apply to the profiles that exist, on the same rule. - Publish to PyPI with Trusted Publishing (OIDC, no long-lived token in the repo).
- Create the GitHub release with the CHANGELOG section, SBOM, and determinism manifest attached.
Conventional commits are enforced on PR titles and on every commit by commitlint in CI
(LINT-3), with the parser tested against the Conventional Commits 1.0.0 specification's own
examples (VAL-F11).
5.9 The nondeterminism lint¶
tf-lint-det is an AST walker over packages/*/src/**/*.py plus tools/**. It is deliberately
not a Ruff plugin, because Ruff has no stable plugin API; it runs as a separate just lint-det
step and as CI gate LINT-1.
Rules:
| Code | Bans | Rationale |
|---|---|---|
| TFD001 | time.time, time.time_ns, time.monotonic, time.perf_counter, time.sleep, datetime.now, datetime.utcnow, date.today, pandas.Timestamp.now |
wall clock leak |
| TFD002 | random.*, numpy.random.* module-level functions, numpy.random.seed, secrets.*, uuid.uuid1, uuid.uuid4, os.urandom |
unseeded randomness |
| TFD003 | socket, requests, httpx, urllib, aiomqtt, asyncpg, duckdb, deltalake imports |
real IO outside adapters |
| TFD004 | threading, multiprocessing, concurrent.futures, asyncio.to_thread, loop.run_in_executor, subprocess |
unschedulable concurrency |
| TFD005 | iteration over a set literal, set(...), frozenset(...), or a name annotated set[...]/frozenset[...] without sorted() |
hash-order dependence |
| TFD006 | os.environ, os.getenv |
environment leak; use the Env port |
| TFD007 | builtin hash() |
randomized per process; use stable_hash |
| TFD008 | id() in a comparison, sort key, f-string, or serialized value |
address leak |
| TFD009 | global statements and module-level mutable containers |
hidden state that survives across runs |
| TFD010 | open, Path.write_text, Path.write_bytes, Path.read_*, shutil |
filesystem outside storage adapters |
| TFD011 | asyncio.run, asyncio.get_event_loop, asyncio.new_event_loop |
the loop is chosen by RuntimeBuilder |
| TFD012 | platform.*, socket.gethostname, os.cpu_count, sys.getsizeof |
machine-shape leak |
| TFD013 | comparing an identifier named mode/run_mode against a string literal outside RuntimeBuilder and adapter modules |
mode branching in business logic |
| TFD014 | asyncio.wait_for, asyncio.timeout |
use clock.timeout so timeouts are sim time |
| TFD015 | loop.time(), and .time() on any name bound from get_event_loop or get_running_loop, outside twinflow.kernel._impl.sim |
the float time shim of T1a is not for callers |
| TFD016 | a datetime, a host name, or a package version reaching an argument of EventLog.append or EventBus.publish |
the D-01 carve-out, checked at authoring time |
| TFD017 | any numpy.random.Generator method other than random, integers, and bytes, and any of those three called with a size argument, outside twinflow.kernel.numeric |
only the NEP 19 stream-stable calls (R5, R8) |
Scope and exemptions:
-
Exempt paths are declared once, in the root
pyproject.toml, each with a reason string so a reader can tell an adapter from an oversight:[tool.twinflow.det] adapter_paths = [ { path = "packages/twinflow-kernel/src/twinflow/kernel/_impl/real/**", reason = "production adapters" }, { path = "packages/twinflow-kernel/src/twinflow/kernel/_impl/paced.py", reason = "the D-02 pacer" }, { path = "packages/twinflow-kernel/src/twinflow/kernel/_impl/memtable.py", reason = "embedded DuckDB over Arrow buffers, pinned to one thread" }, { path = "packages/twinflow-storage/src/twinflow/storage/adapters/**", reason = "storage adapters" }, { path = "packages/twinflow-api/src/twinflow/api/_server/**", reason = "ASGI server boundary" }, { path = "packages/twinflow-cli/src/twinflow/cli/_entry.py", reason = "process entry point" }, { path = "tools/**", reason = "build tooling, never imported at runtime" }, ]The two kernel entries are the ones a reader checks first, because both are simulation-mode code rather than production adapters.
paced.pyreads the real monotonic clock and is the pacer D-02 permits;memtable.pyimports DuckDB, whichTFD003otherwise bans, and pins it to one thread with insertion order preserved so it contributes no nondeterminism. Both carry the inline annotation below in addition to the path entry, because they sit inside the kernel package. -
Inline escape hatch, required form:
# twinflow: allow-nondeterminism(TFD003) reason="paho-mqtt is the production transport" \ # owner="@jack" expires="2027-01-01" \ # adr="docs/adr/0003-the-network-port-and-its-mqtt-adapter.md"reasonandownerare mandatory.expiresis mandatory outsideadapter_paths, andLINT-2fails on an expired annotation, which stops the escape hatch becoming a permanent hole. Inside the kernel package anadrlink is also mandatory. -
Every annotation is inventoried by
just lint-det --report, and the count is printed in CI output so the number is visible in every PR.
The lint is static, so it is incomplete. It cannot see nondeterminism inside a dependency, or a dict ordering that happens to be derived from a set upstream. That is why DET-1 through DET-4 exist: the lint catches the class cheaply at authoring time, and the hash check catches whatever the lint missed at CI time.
5.10 In-memory fault-injecting network¶
InMemoryNetwork is the simulation implementation of the Network port, which D-08 keeps
MQTT-shaped. It models a topology of nodes and links, not a single bus, because the Purdue
segmentation of A3 and the E18 cross-zone drills need per-zone behavior. InMemoryEventBus is
the separate simulation implementation of EventBus and models subject fan-out with no retain, no
will, and no wildcard, which is what the analytics path actually needs and what a partitioned log
can deliver.
The topology file is named by deployment.network.topology, which resolves against
deploy/topologies/. Phase 0 ships two files so the key has a real default from the first run:
single_zone.yaml, which is one zone and no links and is the garage default, and
purdue_three_zone.yaml, which is the file below and is the growth and enterprise default. A run
with no topology named still has one, and TF-C121 reports a name with no matching file
together with the list of files that do exist.
# resolved from deployment.network
topology:
zones:
ot: { nodes: ["dev-*", "broker-ot", "gw-area-*"] }
dmz: { nodes: ["broker-ot", "historian", "twin-sync"] }
it: { nodes: ["analytics", "agent", "dashboard", "api"] }
links:
- {
from: "ot",
to: "dmz",
via: "broker-ot",
base_latency: "2 ms",
jitter: { dist: "lognormal", mu: -6.9, sigma: 0.4 },
}
- { from: "dmz", to: "it", via: "historian", base_latency: "1 ms" }
# no ot -> it link exists; a publish across it raises NoRouteToNode
Delivery semantics: MQTT QoS 0, 1, and 2 are modeled, including QoS 1 duplicate delivery on
retry, retained messages, last-will-and-testament on ungraceful disconnect (which is what the
Sparkplug death certificate rides on), and per-subscriber inflight windows. Store-and-forward
buffering at the device agent (component 6c) is device-side behavior that this network makes
testable: a BROKER_DOWN fault plus a device buffer yields the "kill the broker mid-demo and lose
nothing" demo as an automated test rather than a manual stunt.
Sequencing, because the device-side buffer is component 6c and lands in Phase 4 while SCN-F2 runs
in the e2e job from Phase 0. SCN-F2 asserts what exists at each phase and says which assertion
is which, rather than asserting a Phase 4 capability from Phase 0:
| Phase | What SCN-F2 asserts about the broker outage |
|---|---|
| 0 to 3 | the outage is injected and healed on schedule, fault_injected brackets it, publishes during the outage raise BrokerUnavailable at the caller, no message is delivered twice after heal, and delivery order after heal is publish order |
| 4 | the above, plus zero message loss end to end: every reading published during the outage is present in the historian after reconnect, with the device buffer as the mechanism |
Both rows are written in Phase 0. The Phase 4 row is marked xfail(strict=True) until component 6c
lands, so it flips to passing rather than being invented then, which is the same discipline DET-6
uses for snapshots.
Fault application order per message: partition check, then drop, then corrupt, then duplicate, then latency draw, then reorder buffer, then bandwidth accounting. Order is documented because it changes outcomes, and the test suite pins it.
Every fault decision draws from kernel.net.faults, so a run with --faults chaos/broker_flap.yaml
at seed 42 is exactly reproducible, and the same seed with no faults produces a comparable baseline
because the process streams were never touched (F1).
5.11 Deployment tiers (A3)¶
The rule is that tiers differ by configuration, never by code path. What varies is which adapter is bound to each port and which containers exist.
Garage tier. just up-garage runs docker compose -f deploy/garage/compose.yaml up.
Services: mosquitto, twinflow-sim (devices plus twin in one process), twinflow-api,
twinflow-dashboard, and otel-collector, with ollama behind an optional compose profile.
DuckDB runs in-process and is not a service. One network, no TLS, bound to loopback. Storage: a
DuckDB file plus a local Delta directory. This is the five-minute quickstart of requirement 9, and
TIER-1 asserts the quickstart completes in under 5 minutes of wall time on the CI runner from a
cold image pull, with the number published in the README.
Service and network counts are asserted, not narrated. TIER-5 runs tf-count-services over each
compose file and compares the result against [tool.twinflow.tiers] in the root pyproject.toml,
which is also the table the README renders from. A service added to a compose file without
updating that table fails CI, so the counts in the README cannot drift away from the deployment
they describe. The counts themselves are properties of files in this repository rather than
measurements of the world, which is why they can be asserted exactly.
Growth tier. just up-growth. Three docker networks that express the Purdue model for real,
which is reference-architecture fidelity item (b):
networks:
ot_net: { internal: true } # devices + broker-ot + area gateways
dmz_net: { internal: true } # broker-ot + historian + twin-sync + otel-collector
it_net: {} # analytics + agent + api + dashboard + postgres
broker-ot is on ot_net and dmz_net. historian is on dmz_net and it_net. No device
container is on it_net, and ot_net is internal: true so it has no default route out. The
crossing point is the broker bridge, and only the broker bridge. TIER-2 proves it: a test
execs into a device container and tries a TCP connect to the analytics container and to
1.1.1.1, asserting both fail, then attempts a connect to broker-ot and asserts it succeeds.
Adapters: EMQX broker, Postgres, Delta on a local volume, OpenTelemetry collector.
Enterprise tier. deploy/helm/twinflow with a chart per subsystem and values-enterprise.yaml.
Kubernetes NetworkPolicies mirror the three docker networks so the Purdue statement holds at both
tiers. Adapters bind to Kafka, Delta on object storage or Databricks SQL or Snowflake, an external
Postgres, and a cloud IoT hub ingress. TIER-3 runs helm lint plus helm template and validates
the rendered manifests with kubeconform; TIER-4 runs the chart against a kind cluster in the
nightly job and asserts the API pod serves /healthz and the NetworkPolicy denies the OT-to-IT
path.
Adapter seam table, which is what A3 actually asks to be made explicit:
| Port | Garage | Growth | Enterprise | Shipped state |
|---|---|---|---|---|
Network (MQTT, Sparkplug) |
Mosquitto | EMQX | EMQX at the OT edge, bridged into the log | Mosquitto, EMQX, NanoMQ (E36 tier-1 gateway) implemented; AzureIotHubIngress and AwsIotCoreIngress ship as stubs |
EventBus (analytics fan-out) |
MQTT over Mosquitto | MQTT over EMQX | Kafka | MqttEventBus implemented; KafkaEventBus is the implemented enterprise example |
TableStore |
DuckDB + local Delta | Postgres + local Delta | Delta on ADLS/S3, Databricks SQL, Snowflake | DuckDB, Delta, Postgres implemented; DatabricksSqlTableStore and SnowflakeTableStore ship as stubs |
EventLog |
local NDJSON | Postgres | Kafka + Delta | local NDJSON, Postgres, and Kafka implemented |
BlobStore (CV frames, reports) |
local filesystem | local filesystem | S3-compatible | local and S3 implemented |
KeyValue (idempotency, leases) |
local file | Postgres | Postgres | LocalFileKeyValue and PostgresKeyValue implemented |
Secrets |
.env file |
docker secrets | Kubernetes secrets or External Secrets | all three implemented |
Identity |
single API key | broker username/password | mTLS from the internal CA plus OIDC on the API | ApiKeyIdentity and OidcIdentity implemented; MtlsIdentity lands in Phase 5 |
Inference |
Ollama local | Ollama or vLLM | hosted API plus the E45 cost router | OllamaInference and HostedInference implemented; VllmInference ships as a stub |
Nine seams, nine Protocol bodies in 3.6, nine entries in twinflow.kernel.__all__, and nine
conformance classes. Kafka appears only on the EventBus and EventLog rows and never on
Network, which is D-08: a partitioned log has no retained message, no last will, and no wildcard
subscription, and the Sparkplug death certificate rides on the last will. Identity at garage is a
single API key rather than nothing, because integrations.rest.auth: none is permitted only with a
loopback bind (TF-C212) and an unauthenticated port is a shipped state a reader would rightly
distrust.
Every stub is a real class that raises NotImplementedAdapter with a docstring naming the
conformance suite it must pass. The suite is twinflow.kernel.testing.adapter_conformance_suite,
one parameterized pytest class per port, that any adapter can be pointed at:
adapter_conformance_suite = (
TestClockConformance, TestRngConformance, TestNetworkConformance,
TestEventBusConformance, TestEventLogConformance, TestTableStoreConformance,
TestBlobStoreConformance, TestKeyValueConformance, TestIdGenConformance,
TestEnvConformance, TestMetricsSinkConformance, TestSecretsConformance,
TestIdentityConformance, TestInferenceConformance,
)
@pytest.mark.parametrize("adapter", all_registered_table_stores())
class TestTableStoreConformance:
async def test_write_then_query_roundtrip(self, adapter): ...
async def test_append_preserves_order(self, adapter): ...
async def test_schema_evolution_add_column(self, adapter): ...
async def test_concurrent_append_no_loss(self, adapter): ...
async def test_query_is_parameterised_not_interpolated(self, adapter): ...
async def test_query_result_order_matches_order_by(self, adapter): ...
That suite is the seam made testable, and INV-K17 is true of all fourteen ports rather than of the
four that had bodies before. A reader who wants to plug in their own warehouse runs one command
against their adapter and knows whether it fits. Stubs are excluded from the parameterized run by a
marker and included in a separate test that asserts they raise the documented exception, so a stub
can never silently become a passing no-op. test_every_port_has_a_conformance_class compares the
suite tuple against the port list in twinflow.kernel.__all__ and fails on either side being
longer, which is what stops a port added later from arriving without a suite.
5.12 Bring-your-own-facility (A2)¶
facility.yaml top-level blocks and their owning section:
| Block | Owner | Contents |
|---|---|---|
schema_version |
this section | "1.3" |
facility |
this section | id, name, timezone, geo, uom_system, site_type |
simulation |
this section | seed, mode, tick_hz, horizon, warmup, speed, snapshot |
deployment |
this section | tier, adapters{}, network{} |
observability |
this section | otel{}, log_level, metrics_sink |
integrations |
this section | rest{}, graphql{}, webhooks[] |
calendar |
twin section | shifts[], holidays[], operating_days |
layout |
twin section | zones[], docks[], stations[], aisles[], racks[], travel_graph |
flows |
twin section | routing definitions per flow class |
resources |
twin + HR sections | labor[], equipment[], automation{amr, palletizer, asrs, sortation} |
sensors |
IoT section | catalog: <path>, instances[] |
items |
planning section | sku_classes[], sku_source |
partners |
supplier/transport sections | suppliers[], carriers[], customers[] |
policies |
multiple | slotting, picking, replenishment, dock_scheduling, inventory, returns |
metrics |
this section, envelope only | catalog: <path> plus inline definitions; expressions belong to the AI-layer section (5.15) |
spec_limits |
LSS section | <metric_id>: {lsl, usl, target} or a path to a separate file |
scenarios |
this section | named overlay paths available to tf run --scenario |
Types for the blocks this section owns:
schema_version: "1.3" # string, MAJOR.MINOR, must be a known version
facility:
id: mid3pl-01 # slug, ^[a-z][a-z0-9-]{2,31}$
name: "Mid-market 3PL building" # string, 1..120 chars
timezone: America/Chicago # IANA zone, validated against zoneinfo
geo: { lat: 41.88, lon: -87.63 } # optional, floats, needed by E42 and E40
uom_system: metric # metric | us_customary, controls report rendering only
site_type: distribution_center # distribution_center | micro_fulfillment | factory | crossdock
simulation:
seed: 42 # int 0..2**64-1, required unless --seed given
mode: simulation # simulation | production
tick_hz: 1000000 # 1000 | 1000000 | 1000000000
horizon: "30 d" # duration string, > 0, <= 100 y
warmup: "1 d" # duration string, >= 0, < horizon
speed: asap # asap | float in [0.01, 100000]
snapshot: { every: "8 h", keep: 5 } # optional
deployment:
tier: growth # garage | growth | enterprise
adapters:
network: emqx # must name a registered adapter for this port
table_store: postgres
event_log: postgres
blob_store: local
inference: ollama
network:
topology: purdue_three_zone # names a topology in deploy/topologies/
integrations:
rest: { enabled: true, bind: "127.0.0.1:8080", auth: apikey }
graphql: { enabled: true, max_depth: 12, max_complexity: 2000 }
webhooks:
- id: ops-slack
url: "http://localhost:9001/hook" # http allowed only at garage tier
subjects: ["twinflow.lss.finding"]
filter: "severity >= high" # CEL-style expression, validated at load
secret_ref: "env:WEBHOOK_SECRET_OPS"
delivery: { max_attempts: 6, backoff: exponential, timeout: "5 s" }
Validation rules worth naming because they catch real mistakes: warmup < horizon (TF-C041);
speed finite requires mode: simulation (TF-C042, because pacing a production run is
meaningless); deployment.adapters.* must name an adapter registered by an installed package
(TF-C120, listing what is available); a webhook url with scheme http at growth or enterprise
tier is TF-C210; secret_ref must be env:NAME, file:PATH, or k8s:NAME, never a literal
(TF-C211, and a literal-looking value is reported as a secret leak, not a type error).
Three worked profiles, each a directory with the config, a smoke scenario, a golden KPI file, and a README section:
| Profile | Shape | Devices | Purpose |
|---|---|---|---|
profiles/micro_fulfillment/ |
1 dock, 3 stations, no automation, 1 shift, 1 SKU class | 6 | proves the floor: a startup can model their unit in one screen of YAML. It is also the Phase 1 walking-skeleton target |
profiles/midmarket_3pl/ |
8 docks, 24 stations, sortation plus 6 AMRs, 2 shifts, 2 customers, wholesale plus parcel | ~120 | the everyday case, and the default for demos and the E1 replay recording |
profiles/enterprise_network/ |
2 DCs plus 1 upstream factory plus a transport network, full automation, 3 shifts, cross-dock and e-commerce and returns flows | ~600 | proves the ceiling and is the E13 multi-site and A4 scaling target |
Gates: A2-1 all three validate clean with --strict-warnings. A2-2 all three complete a
one-simulated-day scenario inside the e2e budget. A2-3 the golden KPI file matches.
A2-4 is the mechanical proof that "same code, three companies" is true and not aspirational, and
it measures the claim instead of policing an identifier. An earlier draft made it a lint that
failed on any source line comparing facility.id or facility.site_type to a literal outside the
config package. That gate was both too strong and too weak. Too strong, because site_type is an
open enum whose members include crossdock and factory, and the cross-docking flow-versus-store
engine and the upstream factory are required capabilities that behave differently by construction.
Too weak, because x = cfg.facility.site_type on one line and if x == "factory" on the next
defeats it entirely.
The gate is structural instead. SCN-F4 already runs all three profiles. A2-4 collects
branch-level coverage for each of the three runs and asserts two things. First, that the set of
imported twinflow.* modules differs across the three profiles only within the capability modules
listed in [tool.twinflow.capability_modules], each of which is enabled by a config key rather
than by a site-type test. Second, that within every module outside that list, the set of executed
branches is identical across the three profiles. A branch that fires for one profile and not
another, in a module that is not declared config-driven, is the exact defect the lint was reaching
for, and coverage data sees it whichever way the branch is spelled.
test_a2_4_detects_a_planted_site_type_branch adds a site-type comparison to a non-capability
module in a temporary working tree and asserts the gate fails, which is what stops A2-4 from
becoming a test that cannot fail (D-12).
CONFIGURING.md walks a reader from tf facility init --template micro through measuring their
own stations to a running twin, with tf facility doctor reporting what is still at default and
what the twin cannot yet represent about their building.
5.13 Integration surface (A6)¶
REST, /api/v1, OpenAPI 3.1, spec committed at api/openapi.v1.json and regenerated by
just api-spec.
| Route | Method | Notes |
|---|---|---|
/api/v1/runs |
GET, POST | list runs; POST starts a run from a profile plus scenario plus seed, returns 202 and a job |
/api/v1/runs/{id} |
GET, DELETE | the RunManifest |
/api/v1/runs/{id}/events |
GET | cursor-paginated canonical event stream, filterable by subject and sim-time range |
/api/v1/runs/{id}/speed |
POST | C2 speed control |
/api/v1/stream |
GET (SSE) | live envelope stream for the dashboard |
/api/v1/twin/state |
GET | current station states, WIP, utilization, bottleneck |
/api/v1/twin/stations/{id} |
GET | per-station detail |
/api/v1/fleet/devices |
GET | registry with health scores |
/api/v1/fleet/devices/{id} |
GET | device twin desired vs reported (E44) |
/api/v1/findings |
GET | filter by severity, subject, station, window; the findings stream |
/api/v1/findings/{id} |
GET, PATCH | PATCH for shelve and acknowledge (alarm rationalization) |
/api/v1/metrics/{metric_id} |
GET | a metric from the registry of 5.15, evaluated over a window once E26b supplies expressions; 404 for an unregistered id and 501 with a problem document naming E26b while the expression is null |
/api/v1/scenarios |
GET | available scenarios |
/api/v1/whatif |
POST | run a config delta, returns 202 plus a job id |
/api/v1/jobs/{id} |
GET | job status and result reference |
/api/v1/reports/capability |
POST | generate the capability report for a window, returns an artifact URL |
/api/v1/genealogy/lots/{id} |
GET | forward and backward trace |
/api/v1/config |
GET, POST | resolved config; POST proposes a change subject to the E5 autonomy tier |
/api/v1/webhooks |
GET, POST, DELETE | subscriptions |
/healthz, /readyz, /version |
GET | unversioned, outside /api/v1 |
Cross-cutting behavior:
- Pagination is cursor based. The cursor is an opaque base64 of
(twinflowsimts, twinflowproducerid, twinflowseq), the canonical total order of E4 and no other order. All three components are present because the sequence is dense only per producer (D-07), so a two-part cursor would skip or repeat items whenever two producers emitted at the same tick. Because the event log is append-only and read in that order, paging never skips or duplicates an item that existed when the cursor was created (INV-K16). Offset pagination is deliberately not offered. - Errors are RFC 9457 problem documents with
typepointing athttps://<pages>/errors/TF-A031, and thecodefield carries theTF-Axxxcode so a client can branch on it. - Idempotency covers every POST, which accepts
Idempotency-Key. Replaying a key within the retention window returns the original response withIdempotency-Replayed: true. - Conditional requests put an
ETagon every GET, andIf-None-Matchreturns 304. The ETag is the content hash of the canonical response body, which makes it deterministic in simulation mode. - Auth differs by tier. Garage uses a single API key from
secret_ref. Growth uses per-client API keys with scopes. Enterprise uses OIDC bearer tokens with scopes mapped to the 6a15 RBAC roles. Scopes are per-route and listed in the OpenAPI security requirements:runs:read,runs:write,findings:read,findings:write,whatif:run,config:read,config:propose,config:apply,webhooks:admin.config:applyis never granted to the agent principal below autonomy tier L3, which is where E5's guardrail is actually enforced. - Rate limiting is a token bucket per principal, answering
429withRetry-After. The bucket refills on sim time in simulation mode, which keeps rate-limit tests deterministic.
GraphQL at /graphql, read-only in v1. It exists because findings, genealogy, and the supplier
DAG are graphs and REST forces clients into N+1 fetches over them. Strawberry, code-first, SDL
committed at api/schema.graphql with a diff gate. Depth limit 12, complexity limit 2000, both
configurable. Persisted-query allowlist available at enterprise tier. Mutations are deliberately
absent in v1 and the reason is written into the schema description: writes go through REST so that
idempotency keys, problem documents, and the audit event have one implementation.
Webhooks: subscriptions as configured above or created through the API. Delivery is
at-least-once with event_id as the natural idempotency key. Signature is
X-Twinflow-Signature: t=<unix_seconds>,v1=<hex hmac-sha256 of "t.body">, with a 300 second
tolerance window. The header shape follows Stripe's documented webhook signature scheme, so a
consumer that already verifies Stripe webhooks can reuse its verification code with the header name
changed. VAL-F10 grounds the primitive itself in RFC 4231 rather than in that scheme. Retries use exponential backoff with jitter drawn from kernel.webhook.jitter,
which makes retry timing deterministic in simulation and so testable. After max_attempts
the delivery lands in a dead-letter store readable at /api/v1/webhooks/{id}/dead_letters. Every
delivery try emits twinflow.integration.webhook_delivery_attempted.
The same service layer backs the MCP server (E2), the EPCIS export (E35), and the agent's tools
(component 7). Those three sections wrap twinflow.api.services, they do not call the HTTP surface
and they do not reimplement the queries. That is why the service layer is a separate module from
the router module, and import-linter has a contract asserting no router imports another router.
5.14 Monorepo tooling (C10)¶
Root pyproject.toml:
[tool.uv.workspace]
members = ["packages/*"]
[tool.uv]
required-version = ">=0.5"
default-groups = ["dev"]
[dependency-groups]
dev = ["pytest", "pytest-asyncio", "hypothesis", "pytest-cov", "ruff", "ty",
"import-linter", "schemathesis", "griffe", "syrupy"]
One entry in that group needs a license note, because C4 names the tool and C11 owns the allowlist.
Hypothesis 6.165.2 is MPL-2.0, read from the Python Package Index on 2026-08-09. MPL-2.0 is
file-level copyleft that does not reach across a package boundary, and a development dependency is
not distributed with the product, so it is compatible with the Apache-2.0 plus commercial dual
license in this position and only in this position. The allowlist in CONTRIBUTING.md
needs an MPL-2.0 row scoped to [dependency-groups] dev, which is the dependency-hygiene section's
to write; until it has one, the allowlist refuses the property-testing library C4 requires. The
requirement is recorded here because this section declares the group.
justfile recipes, each a one-line delegation to tools/ so local and CI run identical code:
| Recipe | Does |
|---|---|
just bootstrap |
uv sync --all-packages, install git hooks, pull compose images |
just fmt / just fmt-check |
ruff format, rustfmt |
just lint |
ruff check, lint-det, lint-imports, check-profile-branches, check-measured-claims, commitlint |
just lint-det |
the nondeterminism lint, --report for the annotation inventory |
just typecheck |
ty check over the workspace, cargo clippy -- -D warnings |
just test-unit |
pytest -m "not property and not e2e and not brick_isolated" |
just test-property |
pytest -m property with a fixed Hypothesis profile and database |
just test-contract |
schema samples, consumer drift, adapter conformance, OpenAPI diff |
just test-e2e |
the seeded scenarios of section 7.4 |
just test-integration |
the adapter conformance suite and SCN-F3 against real containers |
just test-brick-isolation |
tf-brick-isolate over every package |
just measure-row-bytes |
run a profile and write x-twinflow-stored-bytes plus its run id for every subject |
just det-check |
DET-1 through DET-4 and DET-9 |
just schemas-gen / just schemas-check |
codegen and registry checks |
just config-validate |
validate all three profiles with --strict-warnings |
just run PROFILE SEED / just demo / just replay RUN_ID |
run, demo, replay |
just up-garage / just up-growth / just down |
compose tiers |
just helm-lint / just kind-up |
enterprise tier checks |
just bench |
the A4 load-test harness |
just docs / just docs-serve |
mkdocs-material build |
just audit / just sbom |
pip-audit, cargo-audit, license check, CycloneDX (C11) |
just release VERSION |
local dry run of the release pipeline |
just ci |
everything a PR must pass, in the order CI runs it |
Any recipe a contributor needs that is not in just --list is a bug. The README quickstart uses
just and nothing else.
CI matrix (GitHub Actions):
| Job | Matrix | Trigger | Budget |
|---|---|---|---|
lint |
ubuntu, py3.13 | every PR | 3 min |
typecheck |
ubuntu, py3.13 | every PR | 3 min |
unit |
{ubuntu, macos, windows} x | every PR, path-filtered to packages/** |
90 s per cell |
property |
ubuntu, py3.13 | every PR | 5 min |
contract |
ubuntu, py3.13 | PRs touching schemas/**, packages/**, api/** |
2 min |
determinism |
{ubuntu, macos} x py3.13, plus one PYTHONHASHSEED=12345 cell |
every PR | 6 min |
e2e |
ubuntu, py3.13 | every PR | 10 min |
brick-isolation |
ubuntu, py3.13 | PRs touching packages/** |
4 min |
rust |
{ubuntu, macos} x | PRs touching crates/** or schemas/** |
5 min |
compose-garage |
ubuntu | PRs touching deploy/garage/** or nightly |
8 min |
compose-growth-purdue |
ubuntu | PRs touching deploy/growth/** or nightly |
10 min |
integration |
ubuntu | PRs touching adapter paths, plus nightly | 15 min |
helm |
ubuntu | PRs touching deploy/helm/** |
4 min |
kind-enterprise |
ubuntu | nightly | 20 min |
scaling |
ubuntu, large runner | nightly | 30 min |
audit |
ubuntu | daily and on release | 5 min |
Stated wall-time budget: 15 minutes for the critical path on a PR with jobs in parallel, 45
minutes for the full nightly. tf-budget reads ci_budget.yaml and fails a job that exceeds its
budget by more than 25 percent, which turns "CI got slow" into a failing test with an owner rather
than a slow drift nobody notices. Path filtering uses dorny/paths-filter-class detection with the
filter map in .github/filters.yaml; the filters are themselves tested by
tools/test_ci_filters.py against a table of changed-file sets and expected job sets, because a
broken path filter silently stops running tests, which is the worst kind of CI bug.
Concurrency group per PR with cancel-in-progress: true. Caches: uv cache keyed on uv.lock,
cargo cache keyed on Cargo.lock, Hypothesis example database keyed on the property test files so
found counterexamples persist across runs.
Required checks for merge: lint, typecheck, unit (all cells), property, contract,
determinism, e2e, brick-isolation.
The integration job is the container tier open question 1 asks about, and it now has a budget and
a trigger rather than an intention. It runs the adapter conformance suite against live Mosquitto,
EMQX, Postgres, and Delta, and it runs SCN-F3, which is a production-mode comparison and cannot
run anywhere else (7.4). It is not a required merge check, because a 15 minute container job on
every PR would break the 15 minute critical-path budget on its own; it gates the nightly and it
gates a release. docs/limitations.md states the consequence in the words open question 1 asks to
confirm: deterministic simulation covers business logic, and adapters are covered by container
integration tests.
5.15 The metric registry envelope (C5, E26b)¶
C5 names four configurations that must validate against a published schema at load with
line-numbered, suggestion-bearing errors: facility.yaml, the sensor catalog, the spec limits, and
the metrics layer. The first is owned here, the second by twinflow-sensors and the third by
twinflow-lss through the registration mechanism of 2.4. The fourth is owned here, and it is owned
here for a sequencing reason rather than a topical one: stage 6 of the config pipeline resolves
spec_limits keys into metric ids (TF-C103) from Phase 0, so the identifier space has to exist
from Phase 0 even though the expressions that compute the metrics are an E26b deliverable in the
AI-layer section.
The split is envelope here, semantics there. This section owns the file, its JSON Schema, the identifier grammar, the registry, and the validation rules. The AI-layer section owns the expression language, the evaluator, and the governance workflow around changing a definition.
File: metrics.yaml, named by metrics.catalog in facility.yaml, validated against
schemas/config/metrics/v1.0.json.
schema_version: "1.0"
metrics:
- id: twin.throughput.units_per_hour
title: "Units per hour"
unit: 1/h # x-twinflow-unit, resolved by pint
grain: [facility, station, shift] # the dimensions it may be sliced by
direction: higher_is_better # higher_is_better | lower_is_better | target_is_best
precision: 2 # x-twinflow-precision, decimal places when serialized
owner: twinflow-twin # the package that produces the inputs
since: "0.1.0"
expression: null # owned by the AI-layer section (E26b)
status: active # active | deprecated
class MetricDefinition(BaseModel):
id: MetricId # grammar below
title: str # 1..80 chars
unit: str # pint-parseable, or "1" for dimensionless
grain: list[str] # sorted at load, D-03
direction: Literal["higher_is_better", "lower_is_better", "target_is_best"]
precision: int # 0..9
owner: str # a distribution name
since: str # release that introduced it
expression: str | None # opaque here; parsed by the AI layer
status: Literal["active", "deprecated"]
deprecated_in: str | None
class MetricRegistry(BaseModel):
schema_version: str
metrics: list[MetricDefinition] # ordered by id, checked at load
Identifier grammar: <domain>.<area>.<name>, lowercase, snake_case within each part, matching
^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){2}$. The domain part draws from the registered event domains
of 4.3, so a metric's identifier says which subsystem's events it is computed from. A metric id is
never reused for a different quantity; a redefinition takes a new id and deprecates its predecessor,
which is what makes a spec_limits entry recorded in a run last year still mean what it meant.
Validation rules, all producing the line-numbered rendering of 5.6:
| Code | Condition |
|---|---|
TF-C103 |
a spec_limits key names a metric id that is not in the registry |
TF-C150 |
two definitions share an id |
TF-C151 |
unit does not parse under pint |
TF-C152 |
expression is present but no installed package registers a metric evaluator |
TF-C153 |
id does not match the grammar, with the nearest registered id as a suggestion |
TF-C154 |
grain names a dimension no installed package declares |
TF-C155 |
a metric with status: deprecated and no deprecated_in |
TF-C152 is the one that makes the Phase 0 and Phase 6 split honest. From Phase 0 the registry
loads and validates with expression: null on every entry, TF-C103 works, and
/api/v1/metrics/{metric_id} returns 501 with a problem document naming E26b. When the AI layer
lands, its evaluator registers through the twinflow.config.references group of 5.6, expressions
become resolvable, and the same route starts serving values. Nothing about the file changes shape
in between, which is the property that lets spec_limits be authored in Phase 2 against ids that
compute in Phase 6.
The registry is the reference domain metrics for the purposes of 5.6, so CFG-2 covers its error
codes from Phase 0 and CFG-4 covers the domain's ownership.
Gates: METRIC-1 asserts every metric id referenced anywhere in the workspace, in spec_limits,
in a dashboard tile definition, in an eval case, or in a golden KPI file, resolves against the
registry, so a renamed metric fails at CI rather than at a demo. METRIC-2 asserts the registry is
append-only within a facility schema major: an id may gain status: deprecated and a
deprecated_in, and may be removed only at a MAJOR bump with an upcaster that maps it to its
replacement, which is the same rule 5.5 applies to event schemas and for the same reason.
test_metric_id_grammar_table is parametrised over accepting and rejecting cases, and
test_spec_limits_dangling_metric_id_reports_nearest_candidate pins the suggestion behavior C5
asks for.
6. Configuration¶
Every key this section reads, its type, and its validation rule. Keys owned by other sections are listed in the block table of 5.12 and validated by their registered schemas.
| Key | Type | Default | Validation |
|---|---|---|---|
schema_version |
string MAJOR.MINOR |
required | must be a known facility schema version; unknown gives TF-C002 with the upgrade command in the suggestion |
facility.id |
slug | required | ^[a-z][a-z0-9-]{2,31}$, unique across a multi-site config |
facility.name |
string | required | 1..120 chars |
facility.timezone |
IANA zone | required | resolvable by zoneinfo, TF-C021 |
facility.geo.lat / .lon |
float | null | -90..90, -180..180 |
facility.uom_system |
enum | metric |
metric or us_customary |
facility.site_type |
enum | distribution_center |
open enum, x-twinflow-open-enum: true |
simulation.seed |
uint64 | required unless --seed |
0..2^64-1, TF-D001 if absent |
simulation.mode |
enum | simulation |
simulation or production |
simulation.tick_hz |
enum int | 1000000 |
one of 1e3, 1e6, 1e9 |
simulation.horizon |
duration string | required | > 0, <= 100 y, TF-C031 |
simulation.warmup |
duration string | 0 s |
>= 0 and < horizon, TF-C041 |
simulation.speed |
asap or float |
asap |
float in [0.01, 100000]; finite requires mode: simulation (TF-C042); x-twinflow-not-in-hash (M6) |
simulation.snapshot.every |
duration | null | > 0 if present |
simulation.snapshot.keep |
int | 3 | >= 1 |
simulation.rng_streams_strict |
bool | true | when true, an unregistered stream id raises rather than warns |
strict_sections |
bool | false at garage, true above | when true, a block or reference domain with no installed owner is an error rather than a warning (2.4, 5.6) |
metrics.catalog |
path | metrics.yaml |
file exists and validates against the metric registry schema (5.15) |
metrics.inline[] |
list | empty | inline MetricDefinition entries merged after the catalog; a duplicate id is TF-C150 |
deployment.tier |
enum | garage |
garage, growth, enterprise |
deployment.adapters.network |
string | tier default | must be a registered Network adapter, TF-C120 lists available |
deployment.adapters.event_bus |
string | tier default | registered EventBus adapter |
deployment.adapters.table_store |
string | tier default | registered TableStore adapter |
deployment.adapters.event_log |
string | tier default | registered EventLog adapter |
deployment.adapters.blob_store |
string | tier default | registered BlobStore adapter |
deployment.adapters.key_value |
string | tier default | registered KeyValue adapter |
deployment.adapters.secrets |
string | tier default | registered Secrets adapter |
deployment.adapters.identity |
string | tier default | registered Identity adapter |
deployment.adapters.inference |
string | ollama |
registered Inference adapter |
deployment.network.topology |
string | tier default | names a file in deploy/topologies/, TF-C121 |
deployment.faults |
path or inline | null | validates against the FaultSchedule schema |
observability.otel.enabled |
bool | false at garage, true above | |
observability.otel.endpoint |
url | http://otel-collector:4317 |
required when enabled |
observability.log_level |
enum | info |
standard levels |
observability.metrics_sink |
enum | recording in sim, otlp in prod |
|
integrations.rest.enabled |
bool | true | |
integrations.rest.bind |
host:port | 127.0.0.1:8080 |
non-loopback bind at garage tier warns TF-C320 |
integrations.rest.auth |
enum | apikey |
none allowed only at garage tier with loopback bind (TF-C212) |
integrations.graphql.enabled |
bool | true | |
integrations.graphql.max_depth |
int | 12 | 1..50 |
integrations.graphql.max_complexity |
int | 2000 | 1..1e6 |
integrations.webhooks[].id |
slug | required | unique |
integrations.webhooks[].url |
url | required | http only at garage tier (TF-C210) |
integrations.webhooks[].subjects |
list of subject globs | required | each must match a registered subject (TF-C122) |
integrations.webhooks[].filter |
expression | null | parses as a CEL-style predicate over the payload; unparseable is TF-C013 with the column of the parse failure |
integrations.webhooks[].secret_ref |
reference | required | env:, file:, or k8s: prefix; a literal is TF-C211 |
integrations.webhooks[].delivery.max_attempts |
int | 6 | 1..20 |
integrations.webhooks[].delivery.backoff |
enum | exponential |
fixed, linear, exponential |
integrations.webhooks[].delivery.timeout |
duration | 5 s |
100 ms .. 60 s |
scenarios.<name> |
path | none | file exists and validates as an overlay |
Environment variables, the only ones read, and all read through the Env port inside
twinflow-config: TWINFLOW_CONFIG, TWINFLOW_SEED, TWINFLOW_PROFILE, TWINFLOW_LOG_LEVEL,
TWINFLOW_API_KEY, WEBHOOK_SECRET_*, ANTHROPIC_API_KEY / OPENAI_API_KEY /
GOOGLE_API_KEY (optional, per the constraint that the repo runs fully local with an optional env
var for a hosted LLM), NPY_DISABLE_CPU_FEATURES, PYTHONHASHSEED. Any other TWINFLOW_*
variable present in the environment is a startup warning TF-C321 unknown TWINFLOW_ variable, with
a suggestion, because an env var name typo that silently does nothing is undiagnosable from inside
the running system.
7. Testing¶
Tiers and budgets are the C4 contract as it applies here: unit under 90 s per matrix cell, property under 5 min, contract under 2 min, e2e under 10 min, determinism under 6 min.
7.1 Unit tests¶
Named per behavior, not per method. A non-exhaustive list of the ones that must exist because they pin a decision:
test_sim_instant_is_integer_ticks_and_rejects_floattest_duration_string_parsing_table(a table of 40 duration strings and their tick values, including"1.5 h","90 min","1 d 6 h", and rejections like"soon","-5 s","5")test_clock_now_never_decreases_across_1000_scheduled_wakeupstest_paced_clock_emits_identical_log_at_speed_1_and_asaptest_rng_child_stream_is_addressed_by_name_not_creation_ordertest_rng_unregistered_stream_raises_when_stricttest_rng_known_answer_vectors(committed golden draws for 12 named streams)test_deterministic_id_gen_is_content_addressed_and_sorts_in_emission_ordertest_in_memory_network_delivers_exactly_once_with_no_faultstest_in_memory_network_partition_blocks_ot_to_it_and_heals_in_ordertest_in_memory_network_qos1_duplicate_is_deduped_by_message_idtest_fault_randomness_does_not_perturb_process_streamstest_sim_event_loop_rejects_add_reader_and_run_in_executortest_canonical_encode_matches_rfc8785_vectorstest_config_error_reports_line_and_column_for_every_error_class(parametrised over everyTF-Ccode, asserting each has a test that produces it; a code without a test failstest_every_error_code_is_reachable)test_config_unknown_key_suggests_nearest_valid_keytest_config_hash_ignores_comments_and_key_ordertest_config_hash_is_unchanged_by_speedandtest_run_started_payload_has_no_speed_field(M6)test_run_started_carries_no_wall_clock_or_platform_field(M4)test_every_extension_attribute_name_is_at_most_20_chars(3.4)test_extension_attribute_types_match_cloudevents_type_system(3.4)test_numeric_layer_draws_one_value_at_a_time(R8)test_metric_id_grammar_tableandtest_spec_limits_dangling_metric_id_reports_nearest_candidate(5.15)test_every_port_has_a_conformance_class(5.11)test_a2_4_detects_a_planted_site_type_branch(5.12)test_cross_platform_tolerance_is_not_edited_by_ci(5.4)test_speed_strategy_lower_bound_is_clamped(5.3)test_call_later_deadline_is_computed_in_ticks(T1a)test_resources_match_simpy_semantics(5.2)test_runtime_log_is_the_storage_event_log(2.2)test_compat_checker_table(parametrised over the full table in 5.5, both directions)test_upcaster_exists_for_every_removed_fieldtest_semver_policy_rejects_public_symbol_removal_without_breaking_footertest_webhook_signature_matches_rfc4231_derived_vectorstest_problem_document_shape_matches_rfc9457test_cursor_pagination_is_stable_under_concurrent_append
7.2 Property-based invariants (Hypothesis)¶
Each is a named test in tests/property/, run under the ci Hypothesis profile with a persistent
example database.
| Invariant | Statement | Generator |
|---|---|---|
| INV-K1 monotone clock | for any interleaving of scheduled sleeps and immediate callbacks, clock.now() observed in emission order is non-decreasing |
random schedules of 1..200 tasks with durations 0..1e9 ticks |
| INV-K2 tick arithmetic | t + d1 + d2 == t + (d1 + d2) and durations parsed from strings round-trip to the same tick value |
random instants and durations |
| INV-K3 stream independence | draws from stream A are unchanged by any number of draws from stream B, in any interleaving | random stream name pairs and interleavings |
| INV-K4 name addressing | the first 100 draws of stream X are identical regardless of which other streams exist or their creation order |
random sets of stream names |
| INV-K5 canonical stability | encode(x) is invariant to dict key insertion order and to numerically equal float representations, and decode(encode(x)) == x |
recursive JSON-shaped structures |
| INV-K6 envelope validity | every event produced by any registered producer validates against its schema and the envelope | model-based generation from schemas |
| INV-K7 lossless delivery | with an empty fault schedule, the multiset of messages received by each subscriber equals the multiset published matching its pattern, and per-topic order is preserved | random topic trees and publish sequences |
| INV-K8 fault bounds | with PARTITION active, zero messages cross; with DROP p=0, none are dropped; after heal, buffered messages deliver in publish order |
random fault schedules |
| INV-K9 speed invariance | the canonical log is identical for speed=asap and any finite speed |
SCN-F0, speeds log-uniform on the clamped range of 5.3, at most 25 examples |
| INV-K10 merge algebra | overlay merge is associative over disjoint overlays and idempotent for a repeated identical overlay | random config trees and overlays |
| INV-K11 hash sensitivity | any single leaf value change changes ConfigHash; comment, whitespace, and key-order changes do not |
mutation of a valid config |
| INV-K12 compat metamorphic | schemas mutated by the additive-only mutator always pass compat_check; schemas mutated by the breaking mutator always fail |
schema generator plus two mutator families |
| INV-K13 upcast totality | every archived event version upcasts to an event valid against the current schema | archived logs plus generated historical events |
| INV-K14 migration idempotence | applying migrations to version N twice equals applying once, and check() passes |
random fixture stores at each version |
| INV-K15 webhook delivery closure | under any fault schedule and any max_attempts in 1..20, every matching event is either delivered under a distinct event_id or present in the dead-letter store, never both and never neither |
random fault schedules, subscriptions, and delivery settings drawn from the configuration surface of section 6 |
| INV-K16 pagination stability | paging a cursor created at time T never skips or duplicates an item that existed at T, regardless of appends during paging | random append interleavings |
| INV-K17 port substitutability | every registered adapter for a port passes the same conformance suite with the same assertions | parametrised over adapters |
| INV-K18 sequence density per producer | for each (twinflowrunid, twinflowproducerid) pair in a completed run, twinflowseq is exactly 0..n-1 with no gaps or repeats, and the merged log is a strict total order under (twinflowsimts, twinflowproducerid, twinflowseq) |
any scenario, run with one, two, and four producer processes |
| INV-K19 no wall-clock leak | the same scenario run with the process wall clock frozen at two different instants produces identical logs | FrozenWallClock at random epochs |
| INV-K20 unit round-trip | converting a Quantity to base units and back is lossless to the declared precision |
random quantities across all declared units |
| INV-K21 query order independence | a TableStore.query result is identical across thread counts and across repeated runs for any order_by that is a total order over the result |
random tables, queries, and thread counts 1..8 |
| INV-K22 resource waiter ordering | requests granted by Resource, PriorityResource, and PreemptiveResource follow FIFO within a priority class, ties broken by request sequence number |
random request and release scripts, compared against SimPy |
| INV-K23 metric id closure | every metric id referenced by a resolved config, a golden KPI file, or an eval case resolves against the registry of 5.15, and no id resolves to two definitions | random registries and reference sets |
7.3 Determinism gates¶
| Gate | What it does | Failure means |
|---|---|---|
| DET-1 | run SCN-F1 twice in separate processes on the same runner, each with its own --out, compare event_log_hash byte for byte |
a nondeterminism the lint missed |
| DET-2 | compare SCN-F1 logs produced on ubuntu and macos runners in two stages: first rng_draw_counts_sha256, then, only if those match, integer and quantised fields byte for byte and continuous fields against the measured cross_platform_tolerance of 5.4 |
stage one, stream desynchronisation; stage two, a platform-dependent numeric path. The two are named separately because they need different fixes |
| DET-3 | run SCN-F1 at PYTHONHASHSEED=0 and PYTHONHASHSEED=12345, compare logs |
a set or hash iteration order leak |
| DET-4 | run SCN-F0 at speed=asap and speed=50, each with its own --out, compare logs |
wall clock leaking into logic |
| DET-5 | run SCN-F2 (faults active) twice at the same seed, compare logs |
fault injection is not reproducible |
| DET-6 | snapshot at mid-horizon, resume, compare the resumed suffix to the uninterrupted log | snapshot state is incomplete. Written in Phase 0, xfail(strict=True) until E4 implements subsystem snapshots |
| DET-7 | per third-party stochastic library, two calls inside deterministic_context produce identical output |
that library version broke determinism; the test names it |
| DET-8 | the release determinism manifest for version N reproduces on the tagged commit | a release cannot be reproduced from its tag |
| DET-9 | test_rng_stream_length_is_identical_across_platforms, reported on its own rather than folded into DET-2 |
the RNG stream desynchronised, which is a different defect from numeric drift and takes a different fix |
| RUST-1 | the Rust crates/twinflow-rng crate and the Python twinflow-rng package produce byte-identical first-1000 draws for each of the twelve named streams of VAL-F6a, at the same base_seed and replication_index |
the cross-language RNG contract of D-06 is broken and every recorded fleet run is suspect. Runs in the rust job |
7.4 Seeded end-to-end scenarios¶
| Id | Profile | Seed | Description | Asserts |
|---|---|---|---|---|
| SCN-F0 | micro_fulfillment | 42 | 60 simulated seconds, no faults, one arrival and one station cycle | the paced-clock properties only: DET-4 and INV-K9. It exists because a paced run costs wall time in proportion to its simulated span, and the arithmetic of 5.3 shows a 1 sim day scenario cannot fit the job budget (D-13) |
| SCN-F1 | micro_fulfillment | 42 | 1 sim day, no faults | manifest completeness, run_started/run_finished bracket the log, DET-1, DET-2, DET-3, DET-9 |
| SCN-F2 | midmarket_3pl | 1337 | 1 sim day with a 90 s broker outage at t=4 h | zero message loss with device buffering, fault events present, DET-5 |
| SCN-F3 | midmarket_3pl | 7 | same scenario in production mode against two real adapter stacks: garage compose (Mosquitto, DuckDB, local Delta) and growth compose (EMQX, Postgres, local Delta) | the normalized business event stream is identical across the two stacks, which is the mechanical proof of "tiers by configuration, never by rewrite" (D-12). Runs in the integration job because it needs containers |
| SCN-F4 | all three | 42 | 1 sim day each | A2-1 through A2-3 |
| SCN-F5 | archived | recorded | replay each archived golden log through upcasters and recompute KPIs | MIG-2 |
| SCN-F6 | midmarket_3pl | 42 | boot the API against a completed run, run schemathesis over the OpenAPI spec, exercise webhook delivery under a SLOW_CONSUMER fault |
API-1..API-4 |
| SCN-F7 | midmarket_3pl | 99 | tf run --dry-run on all three profiles |
dry run constructs every subsystem and exits with the resource inventory, without advancing the clock |
SCN-F3's normalization is a named function rather than a description, because a comparison that
strips whatever happens to differ proves nothing. twinflow.kernel.testing.normalise_for_tier_diff
does exactly four things and is itself unit tested: it drops the envelope's id and time, which
are UUIDv7 and wall time in production mode; it drops twinflowrunid, which differs because the
two stacks resolve different deployment.adapters values and so different config_hash
values; it drops the twinflow.kernel.* and twinflow.integration.* subjects, which describe the
run and the transport rather than the business; and it re-sorts the remainder under the canonical
total order of E4. Every payload field survives. test_normalise_for_tier_diff_drops_only_the_four
asserts the field set it removes against a literal list, so a future failure cannot be silenced by
widening the normalizer, which is the failure mode that makes tier-portability proofs worthless.
7.5 Validation gates against published references¶
These are the VAL-GATEs for foundations. There is no published reference for "is this simulation deterministic", so the gates here validate the standards-bearing components rather than inventing authority. Each names its source and its tolerance.
| Gate | Reference (published, citable) | What is asserted | Tolerance |
|---|---|---|---|
| VAL-F1 | JSON Schema Test Suite, draft 2020-12 (json-schema-org/JSON-Schema-Test-Suite) | our validator configuration passes every case for the keywords the registry uses, and correctly rejects the negative cases | exact, 100 percent of the filtered case set |
| VAL-F2 | Semantic Versioning 2.0.0 specification, including its precedence examples | the version comparator orders the specification's own example list identically, and the bump calculator matches a table derived from the spec text | exact |
| VAL-F3 | RFC 9457 Problem Details for HTTP APIs; RFC 9110 conditional requests; RFC 8288 Link header | error bodies, ETag/If-None-Match handling, and Link pagination headers conform | exact field and header shape |
| VAL-F4 | OpenAPI Specification 3.1.0 | api/openapi.v1.json validates under openapi-spec-validator, and every route has a documented 4xx and a security requirement |
exact |
| VAL-F5 | PEP 440 version identifiers, PEP 621 project metadata, PEP 420 namespace packages | every built distribution passes twine check and validate-pyproject, and no package ships a twinflow/__init__.py |
exact |
| VAL-F6a | NEP 19, NumPy Random Number Generator Policy, which names .bytes(), integers(), and random() as the methods that MUST guarantee stream compatibility |
committed known-answer vectors for the 12 named streams, taken through those three methods only, reproduce exactly. Falsified by any vector changing on a numpy upgrade inside the pinned range | exact bit equality |
| VAL-F6b | The twinflow distribution layer's own committed vectors, generated from VAL-F6a's draws | twinflow.kernel.numeric's families reproduce their committed vectors from the same raw draws. This gate is twinflow-internal by construction and says so: NEP 19 is the external reference underneath it, and the layer exists because NEP 19 covers nothing above those three methods |
exact bit equality |
| VAL-F7a | NIST Special Publication 811, Appendix B, the factors printed in boldface, which the source defines as exact | pint reproduces each exact definitional factor, for example 1 in = 25.4 mm and 1 lb = 0.45359237 kg | 4 units in the last place of float64 |
| VAL-F7b | NIST Special Publication 811, Appendix B, the rounded factors | pint reproduces each rounded factor across mass, length, velocity, pressure, energy, power, and temperature | relative 5e-7, derived below |
| VAL-F8 | RFC 8785 JSON Canonicalization Scheme, whose Appendix B holds IEEE 754 sample values and their JSON serialization | canonical_encode reproduces every Appendix B sample byte for byte, including the number-serialization edge cases of section 3.2.2.3 |
exact bytes |
| VAL-F9a | RFC 7693, Appendix A (BLAKE2b-512 trace), Appendix B (BLAKE2s-256 trace), and the Appendix E self-test module | the BLAKE2b primitive reproduces the Appendix A digest and passes the Appendix E self-test, which covers digest lengths 20, 32, 48, and 64 bytes over inputs of 0, 3, 128, 129, 255, and 1024 bytes, keyed and unkeyed | exact bytes |
| VAL-F9b | The BLAKE2 reference implementation in the BLAKE2/BLAKE2 repository, ref/blake2b-ref.c |
twinflow's personalised and shortened digests reproduce the reference implementation's output at the same outlen and personal parameters |
exact bytes |
| VAL-F10 | RFC 4231 HMAC-SHA test vectors | the webhook signature primitive reproduces the RFC vectors | exact |
| VAL-F11 | Conventional Commits 1.0.0 specification examples | the commit parser classifies every example in the specification correctly, including the ! and footer forms |
exact |
| VAL-F12 | CloudEvents 1.0.2 specification, its JSON Event Format, and the Partitioning extension | every envelope validates against the CloudEvents JSON format schema; every extension attribute name is lower-case alphanumeric and at most 20 characters; every attribute value matches its declared CloudEvents type, which is the assertion that rules out Integer for the two counters of 3.4; a round trip through the binary HTTP binding preserves all attributes |
exact |
| VAL-F13 | W3C Trace Context Recommendation, and the CloudEvents Distributed Tracing extension that references it | traceparent values we emit parse under the specification's grammar and survive a round trip through the OTel SDK |
exact |
| VAL-F14 | IANA Time Zone Database (tzdata), via zoneinfo |
shift calendars spanning a DST transition produce the correct wall-clock shift boundaries for three named zones, including a spring-forward and a fall-back case | exact to the minute |
| VAL-F15 | RFC 9562, Universally Unique IDentifiers, section 5.7 | Uuid7IdGen output carries version 7 and the variant bits in the positions the section requires, and its timestamp field is non-decreasing within a process |
exact bit fields |
Three of these tolerances are derived rather than chosen, and the derivations are written out because D-11 rule 2 forbids a tolerance tighter than the precision of the value it checks.
VAL-F7b's 5e-7. NIST SP 811 Appendix B states how its factors are printed: "The factors given in Secs. B.8 and B.9 are written as a number equal to or greater than 1 and less than 10, with 6 or fewer decimal places", and "A factor in boldface is exact. All other factors have been rounded to the significant digits given in accordance with accepted practice." A mantissa in the interval [1, 10) carried to six decimal places is at most seven significant digits, so the half-unit in the last printed place is 5e-7 absolute on the mantissa, which is 5e-7 relative at the worst case of a mantissa near 1. An earlier draft asserted 1e-12 relative against the same table, which is five orders of magnitude tighter than the published value and would have failed on every non-exact entry. VAL-F7b is falsified by any entry whose deviation exceeds 5e-7 relative; VAL-F7a is falsified by any exact factor deviating by more than 4 units in the last place of float64.
VAL-F9's split. RFC 7693 says of itself that "due to space constraints, this document does not
contain a full set of test vectors for BLAKE2", and its Appendix E self-test covers BLAKE2b at
digest lengths 20, 32, 48, and 64 bytes. content_hash is BLAKE2b-256, so it sits inside that set
and VAL-F9a covers it. Two twinflow digests do not: the 16-byte personalised name hash of R2 and
the 8-byte stable_hash64. Neither is a truncation of a longer digest, because RFC 7693's own
initialization mixes the digest length into the state, h[0] := h[0] ^ 0x01010000 ^ (kk << 8) ^ nn,
and personalisation is out of the RFC's scope entirely: "[BLAKE2] defines additional variants of
BLAKE2 with features such as salting, personalized hashes, and tree hashing. These OPTIONAL
features use fields in the parameter block that are not defined in this document." The external
reference for those two is the BLAKE2 reference implementation rather than the RFC, which
is what VAL-F9b names. Open question 13 records the residue: no published vector set covers
BLAKE2b at an 8-byte digest with a personalisation string, so VAL-F9b compares against an
implementation rather than against a published table, and that is a weaker footing than the rest of
this table stands on.
VAL-F6b's honesty note. D-11 rule 1 forbids a gate that cites this repository as its own reference.
VAL-F6b compares twinflow's distribution layer against twinflow's own committed vectors, so it is
recorded as a regression gate and never as external validation. It stands only on VAL-F6a beneath
it, which is grounded in NEP 19: NumPy guarantees the raw draws, and VAL-F6b asserts that a release
turns those draws into the same values as the release before it. It proves reproducibility, never
correctness. Correctness of the distribution families is the
goodness-of-fit work in docs/design/variability-and-faults.md section F.3, which is where an
external distributional reference belongs.
Sparkplug B TCK conformance is a VAL-GATE, but it belongs to the IoT section, which owns the payload. It is named here only to record that the registry's Sparkplug mapping artifact is the input to that gate.
7.6 Named CI gates owned by this section¶
| Gate | Assertion |
|---|---|
| LINT-1 | tf-lint-det clean |
| LINT-2 | no expired nondeterminism annotation; every annotation has reason and owner |
| LINT-3 | conventional commits and import-linter contracts clean |
| SCHEMA-1 | every sample validates against its schema; every subject has at least 3 samples |
| SCHEMA-2 | no schema content change without a version bump |
| SCHEMA-3 | no consumer-declared field removed or narrowed |
| SCHEMA-4 | generated bindings match a fresh regeneration (Python and Rust) |
| SCHEMA-5 | a version bump that removes or renames ships an upcaster in the same commit |
| SCHEMA-6 | a consumes.yaml entry is pending only while its subject is absent from the registry (4.2) |
| SCHEMA-7 | a reserved subject has no producer, and a subject with a producer is not reserved (4.2) |
| SCHEMA-8 | every event's twinflowproducerid is listed for its subject in registry.yaml (4.3) |
| SCHEMA-9 | during a MAJOR overlap window, the SCN-F4 smoke run emits at least one sample of each major (5.5) |
| CFG-1 | all three profiles validate with --strict-warnings |
| CFG-2 | every TF-C error code belonging to a registered reference domain is produced by at least one test |
| CFG-3 | tf config upgrade round-trips every archived config to the current version and the result validates |
| CFG-4 | every reference domain in docs/reference/error-codes.md has a registered owner or a named phase |
| METRIC-1 | every metric id referenced anywhere in the workspace resolves against the registry of 5.15 |
| METRIC-2 | the metric registry is append-only within a facility schema major |
| IMPORT-1 | import-linter boundary, independence, and layering contracts clean |
| IMPORT-2 | the workspace import graph has no cycle |
| IMPORT-3 | every name in a package's __all__ is defined there or declared as a re-export, and no name has two owners |
| MIG-1 | migrations apply forward from every supported historian version and check() passes |
| MIG-2 | every archived golden log replays and reproduces its golden KPIs |
| MIG-3 | the CHANGELOG compatibility table is present and matches the registry |
| SEMVER-1 | no public Python symbol removed without a breaking footer and a major bump |
| SEMVER-2 | no OpenAPI or GraphQL breaking change without the same |
| SEMVER-3 | release metadata consistent (table, tag, manifest) |
| BRICK-1 | each package installs alone into a clean venv and its isolated tests pass |
| BRICK-2 | no sibling domain package, as listed in [tool.twinflow.layers], is in sys.modules in a brick-isolated test |
| BRICK-3 | every package has README, API.md, tests, and at least one isolated test |
| BRICK-4 | twinflow-kernel and twinflow-config import in a clean environment holding only their declared dependencies |
| TIER-1 | garage quickstart completes cold in under 5 minutes and serves the dashboard |
| TIER-2 | in the growth compose, a device container cannot reach the IT segment or the internet, and can reach the OT broker |
| TIER-3 | helm lint and kubeconform clean on the rendered enterprise manifests |
| TIER-4 | nightly kind deploy serves /healthz and its NetworkPolicy denies OT to IT |
| TIER-5 | container and network counts in each compose file match [tool.twinflow.tiers], which the README renders from |
| API-1 | schemathesis finds no spec violation over the generated OpenAPI |
| API-2 | webhook signature, replay-window rejection, and dead-letter behavior correct |
| API-3 | GraphQL depth and complexity limits enforced; SDL diff gate clean |
| API-4 | every route emits twinflow.integration.api_request with the principal and scopes recorded |
| BUDGET-1 | every CI job inside its ci_budget.yaml allowance, and the paced-cell arithmetic of 5.3 recomputed |
| A2-4 | executed branches outside the declared capability modules are identical across the three A2 profiles (5.12) |
| RUST-1 | Python and Rust produce identical draws for the twelve named streams (7.3, D-06) |
Two of these gates need a note on how they stay falsifiable, which is D-12's rule. BRICK-2 reads
its domain-package list from [tool.twinflow.layers] in the root pyproject.toml. That is the
same table the layering contract of 2.9 reads, so the gate and the contract cannot disagree. The
list it excludes is exactly schemas, rng, kernel, config, and storage, which every domain
package depends on by construction. Naming a sibling domain package is the defect the gate exists
to catch, and an earlier wording that excluded every twinflow package could not fail at all.
BUDGET-1 has two failure conditions. A job that exceeds its ci_budget.yaml allowance fails it.
So does a mismatch between the 5.3 table's products and the scenario lengths, speeds, and example
counts the test suite is configured with, which is what makes a scenario that grows past its budget
a defect rather than a timeout.
8. Phase placement¶
The ordering rule is simple: anything that later work would have to be rewritten to accommodate goes in Phase 0. Everything else lands with the first consumer that needs it, and nothing is dropped.
Phase 0, contracts that cannot be retrofitted. This is the agreed resequencing.
| Piece | Why it must be first |
|---|---|
| Kernel ports (clock, RNG, network, storage, ids, env) | every subsystem is written against them from its first line; retrofitting dependency injection across 20 packages is a rewrite |
SimEventLoop and SimClock (C2) |
the scheduler determines how all async code is written |
SplittableRng with name-addressed streams (C1) |
if streams were creation-order addressed, every golden file recorded before the last subsystem was added would be invalid |
Canonical encoding, stable_hash, RunManifest (C1) |
the log format is the substrate for E1, E4, E25, E27, and every golden file |
| Schema registry with the envelope, the compat checker, and codegen (C3) | additive-only evolution is only enforceable from the first event; a registry added later cannot police the history it did not see |
| Config loader with line-numbered errors (C5) | every subsystem registers its block; a loader added later means every existing block gets rewritten |
| uv workspace, justfile, CI skeleton with LINT-1, DET-1, SCHEMA-1 (C10) | the gates must exist before the code they judge |
| Package topology and import-linter contracts (A1) | boundaries are cheap to declare on day one and expensive to impose on day 400 |
| The nondeterminism lint | the same argument as the boundaries: it prevents the debt rather than paying it down |
InMemoryNetwork with the fault kinds |
the twin's first message goes through it |
| Migration framework skeleton and the upcaster registry (C6) | the first schema version must already have somewhere to upcast from |
micro_fulfillment profile (A2, first of three) |
it is the Phase 1 walking-skeleton target, so it is authored as the schema is authored |
Snapshot protocol definition plus DET-6 as xfail(strict=True) |
the gate exists before the feature so E4 flips it rather than inventing it |
crates/twinflow-rng and RUST-1 (D-06) |
retrofitting a compatible RNG after the device agent ships invalidates every recorded fleet run, which is the one class of damage no later phase can undo |
twinflow.kernel.numeric, the distribution layer over the NEP 19 stream-stable methods |
every draw in the repository goes through it, and moving a subsystem onto it later changes that subsystem's draw order and its golden files |
twinflow.kernel.resources, the asyncio-native DES primitives (5.2) |
the twin's stations, docks, and charge points are written against them from their first line; discovering in Phase 1 that there is no queue primitive is a rewrite |
| The metric registry envelope and its schema (C5, 5.15) | stage 6 of the config pipeline resolves spec_limits into metric ids from Phase 0, so the identifier space has to exist even though the expressions do not |
SCN-F0, the 60 simulated second paced scenario |
DET-4 is a required merge check from Phase 0 and cannot run against a full simulated day inside the job budget (D-13) |
Phase 1, walking skeleton. Garage tier compose (A3 tier 1) with Mosquitto, DuckDB, and the
five-minute quickstart; TIER-1; production-mode adapters for MQTT and DuckDB so both modes are
exercised from the first week rather than production mode being discovered broken in Phase 5;
tf run, tf config validate, tf facility init.
Phase 2, LSS engine. REST API v1 read surface (A6, first slice): /findings, /twin/state,
/metrics/{id}, /runs, /stream. It lands here because the dashboard and the E1 replay viewer
both consume it, and E1 pulls forward to just after Phase 2 in the agreed resequencing. GraphQL and
webhooks wait, because nothing consumes them yet and shipping an unused API surface is how APIs
acquire bad shapes.
Just after Phase 2, E1 hosted replay. Consumes the canonical event log and the run manifest, both Phase 0. No new foundations work beyond a static export command, which is why E1 can move this far forward.
Phase 3, sensor breadth. midmarket_3pl profile (A2, second of three), because that is the
first configuration with enough stations and sensors to exercise the catalog. Growth tier compose
with Purdue segmentation and TIER-2 (A3 tier 2), because a 120-device fleet is the first time the
single-network garage topology stops representing anything real. Historian migrations become live
(C6 store half), because Phase 3 is the first time the historian schema changes under recorded data.
Webhook delivery (A6), because the CMMS loop in 6b is the first genuine outbound integration.
Phase 3c, process mining. twinflow-procmine is written here under Apache-2.0, which is D-14.
No new foundations work beyond one arrangement: PM4Py enters the integration job as a
development-only comparison oracle, never as a runtime dependency and never distributed, so the
conformance gates in the LSS section get an external reference under D-11 without the AGPL reaching
the shipped work. DET-7 gains no entry here, because nothing stochastic from a third party runs
inside the simulation at this phase; its first entry arrives in Phase 3d with statsforecast.
Phase 3h, transport network, and E13 multi-site. enterprise_network profile (A2, third of
three), because it is the first configuration with more than one site. Broker-to-broker bridging
extends InMemoryNetwork's topology model with an inter-site link class.
Phase 4, CV and store-and-forward. BlobStore port gains its first real consumer (synthetic
frames). SCN-F2's store-and-forward assertions become end-to-end rather than network-level.
Phase 5, polish. mTLS from the internal CA at the growth tier, which is MtlsIdentity, the last
unimplemented Identity adapter; the enterprise adapter set, which A3 requires as the one
implemented example per seam and which nothing earlier consumes: KafkaEventBus, KafkaEventLog,
DeltaTableStore against object storage, and S3BlobStore, each landing with its conformance-suite
run in the integration job; the enterprise Helm chart, TIER-3 and TIER-4 (A3 tier 3); GraphQL
(A6), by which point the findings and genealogy graphs are large enough to justify it; the 1.0.0
release and the frozen semver policy (C9 reaches full force at 1.0.0, having operated in pre-1.0
mode until then).
Phase 6, the E-tier. Foundations work that lands with its dependent:
- E2 MCP server wraps
twinflow.api.services, no new foundations. - E4 event-sourced replay implements subsystem snapshots and flips DET-6 from
xfailto passing. - E5 autonomy tiers consume
twinflow.config.config_appliedand theconfig:applyscope, both defined in Phase 0 and Phase 2 respectively. - E36 edge compute tiers extend the network topology model with the four compute tiers and add per-tier latency budget assertions to the fault framework.
- E43 MLOps consumes the migration framework for model registry versioning.
- E45 AI FinOps consumes the
api_requestevent for per-question cost accounting.
Cross-cutting: C9's release automation is wired in Phase 1 (tagging, changelog, build) and completes in Phase 5 (PyPI publish of every brick, SBOM, determinism manifest). It is wired early because a repo whose first release is at Phase 5 has no release history to show, and the release history is itself part of the presentation requirement.
9. Open questions¶
These are genuine ambiguities an implementer will hit. None has been silently resolved.
-
Production-mode code paths are not covered by DST. The design deliberately swaps
aiomqtt,asyncpg, anddeltalakeout in simulation mode, which means the production adapter code is never exercised by the deterministic suite. Theintegrationjob of 5.14 answers the mechanical half:just test-integration, 15 minutes, path-filtered plus nightly, running the adapter conformance suite against live Mosquitto, EMQX, Postgres, and Delta, plus SCN-F3. What is still open is the claim made to a reader. Confirm that the README says "deterministic simulation covers business logic; adapters are covered by container integration tests", rather than implying DST covers everything, and confirm that a nightly-only gate is acceptable coverage for the adapter layer or whether the conformance suite must become a required merge check at some later phase. -
Cross-platform float determinism has a floor. DET-2 as specified asserts byte identity only for integer and quantised fields. Making continuous fields byte-identical across architectures would need either fixed-point arithmetic throughout the physics models or a vendored softfloat library for transcendental functions. Both are large and both would slow the sim. The question for the author: is quantise-at-the-boundary enough, or is full cross-architecture bit identity a requirement worth a fixed-point numerics layer in a later phase? The current spec assumes the former and documents the limit.
-
Lockstep versioning versus take-one-brick. C9 says lockstep versions across bricks. A1 says a reader adopts one brick. Those pull in opposite directions: lockstep means a quality manager pinning
twinflow-lss==0.9.3gets a version number that moved because the transport network changed. The spec follows C9 because C9 is explicit, and records the cost. Confirm, because the alternative (independent versions with a published compatibility matrix) is defensible and the choice is visible to every adopter. -
CloudEvents adoption. The envelope is specified as CloudEvents 1.0 compliant, which is an addition to the source requirements, not a contradiction of them. It buys an interoperability standard and a conformance test target, and it costs the awkward lowercase extension attribute names. Confirm before Phase 0 freezes the envelope, because changing it later is a major bump on every subject.
-
Facility schema version versus release version. The spec makes
schema_versioninsidefacility.yamlindependent of the release version, on the grounds that a config author must not have to edit their file for every release. C9 lists facility.yaml as one of the four versioned contracts without saying whether it shares the release version. Confirm. -
GraphQL scope. v1 is read-only, with writes going through REST. A reader might reasonably expect a
runWhatIfmutation. The tradeoff is one implementation of idempotency and audit versus GraphQL feeling incomplete. Confirm the read-only decision, or accept the duplication. -
Enterprise cloud adapters cannot have real CI coverage.
AzureIotHubIngress,AwsIotCoreIngress,SnowflakeTableStore, andDatabricksSqlTableStoreneed accounts the constraint paragraph forbids ("fully local, no cloud account"). The spec ships them as stubs plus the conformance suite. The open question is whether to also ship recorded-cassette contract tests against captured protocol traces, which would raise confidence but requires the traces to have been captured somewhere, and capturing them on a client account would violate the IP hygiene rule. The current position is stubs plus conformance suite plus an explicit limitations entry. -
Sensor catalog schema ownership. Requirement 2b puts the sensor catalog in YAML with its own structure. This section owns config mechanics and this section's schemas; the IoT section owns the catalog's content and semantics. The boundary proposed is that the catalog's JSON Schema lives in
/schemas/config/sensor_catalog/and is registered bytwinflow-sensorsthrough the sections entry point. Confirm that split so neither section writes the other's schema. -
Tick resolution default. 1 microsecond is proposed. Sub-millisecond resolution matters for E36's "safety interlocks under 100 ms" latency budget and for conveyor encoder physics; it costs nothing in memory (integers) but does mean sim-time arithmetic on a 30-day horizon uses values around 2.6e12, which is fine for int64 and awkward to read in raw logs. Nanosecond resolution would make E46's RF timing modeling exact. Confirm 1 microsecond, or choose nanosecond and accept 2.6e15 tick values in the logs.
-
Webhook retry timing diverges between modes. In simulation mode retry jitter is drawn from a seeded stream and is deterministic; in production it is drawn from the same stream but against a real clock, so wall-clock timing differs while the sequence of delays does not. This is correct but subtle. Confirm that "deterministic delay sequence, real wall-clock execution" is the intended production behavior rather than true randomness.
-
Third-party determinism is a moving target. DET-7 pins each stochastic dependency, but a dependency can become nondeterministic in a patch release. The proposal is that
determinism_exclusions.yamlentries need an owner and a linked issue and are printed in CI output, so the exclusion set is visible rather than silent. The open question is the policy when a library cannot be fixed: exclude its outputs from the byte-identity gate (current proposal), pin the library forever, or replace it. Optuna, the RL dispatcher of E11, and any torch-backed model in E28/E31/E33 are the likely cases. -
Purdue segmentation test cost in CI. TIER-2 needs docker compose with three networks and an exec into a container, which is feasible on GitHub-hosted runners but takes minutes. The spec puts it on path-filtered PRs plus nightly. Confirm that a change to
deploy/growth/**is the right trigger, or whether the Purdue assertion is important enough to run on every PR despite the budget. -
No published vector set covers two of twinflow's BLAKE2b parameterizations. VAL-F9a covers
content_hashat a 256-bit digest through RFC 7693's Appendix E self-test, which exercises digest lengths 20, 32, 48, and 64 bytes. It does not cover the 16-byte personalised name hash of R2, and it does not cover the 8-bytestable_hash64, because personalisation is outside RFC 7693's scope and neither length is a truncation of a longer digest. VAL-F9b compares against the BLAKE2 reference implementation rather than against a published table, which is a weaker footing than every other gate in 7.5. The options are to accept it and say so in the gate description, to movestable_hash64to a 256-bit digest truncated by an explicitly twinflow-owned rule so the primitive itself is RFC-covered, or to drop personalisation and mix the personalisation string into the message instead, which brings the derivation entirely inside RFC 7693. The third would change the RNG derivation, whichdocs/design/variability-and-faults.mdsection A.1 fixes, so it needs deciding before Phase 0 rather than after. -
The DET-2 cross-platform tolerance has no value yet. 5.4 says the number is set from the first ten green runs across the ubuntu and macos runners, at ten times the largest divergence those runs show. Until those runs exist the key is absent and DET-2 asserts only its integer and quantised half. Two things need confirming: that ten runs is enough evidence to set the number from, given that a runner image change can shift the underlying libm, and what the policy is when a runner image bump moves the divergence, since the gate is written to refuse to widen itself and a legitimate platform change is indistinguishable at the gate from a regression.
-
The metric registry's expression field is a Phase 0 hole with a Phase 6 filling. 5.15 ships the envelope, the grammar, and the identifier registry in Phase 0 with
expression: nullon every entry, and the AI-layer section supplies the expressions with E26b. The design assumes that no expression syntax decision made in Phase 6 will force a change to the envelope. That holds if expressions stay opaque strings, and it fails if they need structured operands, a dependency graph between metrics, or a per-metric evaluation window declared alongside the definition. Confirm the opaque-string assumption with the AI-layer section before Phase 0 freezes the schema, becausemetrics.yamlis a validated config and widening it later is a facility schema MINOR at best and a MAJOR if the field type changes.