Status: Accepted · Audience: architects, contributors, reviewers This is the primary design document. Every other document in
docs/elaborates one building block described here.
FlowX is a .NET application platform that executes business flows. It provides a single programming model for request/response APIs, event-driven integration, stream processing, scheduled work and AI-agent actions, and it resolves the orchestration graph at compile time.
| Role | Concern |
|---|---|
| Application engineer | express a use case in minutes, test it without infrastructure |
| Principal architect | prevent drift between design and implementation |
| SRE | uniform operability across every service in the estate |
| Security engineer | provable authorisation at the business-operation boundary |
| Platform team | extend transports and stores without forking |
| AI/agent engineer | typed, policy-guarded action surface |
| # | Quality goal | Scenario | Measure | Priority |
|---|---|---|---|---|
| Q1 | Predictable low latency | 4-step ephemeral flow, warm process, single node | p99 platform overhead ≤ 5 µs, ≤ 1 alloc/step | 1 |
| Q2 | Durable correctness | node killed mid-flow at any step boundary | flow resumes on another node, zero duplicate side effects for idempotent capabilities, p99 checkpoint ≤ 15 ms @ 5 000 flows/s/node | 1 |
| Q3 | Static knowability | any build | 100 % of flows/capabilities/policies/events present in manifest; breaking contract change fails CI | 1 |
| Q4 | Transport portability | move a chain from HTTP to a broker, an outbox feed or a schedule | 1 adapter step; 0 lines of the chain below it changed | 2 |
| Q5 | Operational uniformity | any FlowX service | golden signals + trace + replay available with no user instrumentation | 2 |
| Q6 | Extensibility | add a new transport | implemented against public contracts, 0 changes to FlowX.Runtime |
2 |
| Q7 | Startup & footprint | container cold start, NativeAOT | ≤ 200 ms to ready, ≤ 60 MB RSS idle | 3 |
| Q8 | Multi-tenant isolation | one tenant saturates its quota | other tenants' p99 degrades ≤ 10 % | 3 |
Q1–Q3 are the architecture-defining goals. Where a design choice trades one of them away, an ADR must record it.
| # | Constraint | Type | Implication |
|---|---|---|---|
| C1 | .NET 10+, C# 14 | Technical | Roslyn incremental generators; ref struct interfaces available |
| C2 | Must run under NativeAOT | Technical | No reflection, no dynamic codegen, no System.Text.Json reflection mode |
| C3 | Must host inside ASP.NET Core | Technical | Cannot own the process lifecycle or the DI container |
| C4 | No 2-phase commit | Technical | Consistency is saga-based; outbox for atomic publish. The constraint now describes the system. .Emit<T>() on a Durable flow stages its event in the same transaction as the step row, a refused commit discards it, and PostgresOutboxPublisher delivers it at-least-once in per-partition_key order. The far end is no longer missing: plugins/FlowX.Redis implements IEventPublisher over Redis Streams (WP-56b), one stream per partition_key, and PublisherConformance holds it and the recording double to one contract. FLOWX1024 survives, narrowed to an Ephemeral flow and to a contract no serialiser context declares |
| C5 | OpenTelemetry is the only telemetry API | Technical | No proprietary metrics interface |
| C6 | Apache-2.0, no copyleft dependencies | Legal | Vets every transitive dependency (ADR-0012) |
| C7 | Public contracts follow SemVer with a 2-minor deprecation window | Organisational | Breaking changes are batched into majors |
| C8 | Documentation-first: no feature merges without its doc section and ADR | Organisational | This repository is the spec |
flowchart LR
dev(["👤 Application Engineer"])
ops(["👤 SRE / Operator"])
agent(["🤖 AI Agent"])
client(["👤 End User / Client System"])
subgraph boundary[" "]
flowx["FlowX Platform<br/><i>[System]</i><br/>Compiles and executes business flows"]
end
ide["IDE / CI<br/><i>[External System]</i><br/>Roslyn build, quality gates"]
brokers["Message Brokers<br/><i>[External System]</i><br/>Kafka, Service Bus, RabbitMQ, MQTT"]
stores["Data Stores<br/><i>[External System]</i><br/>PostgreSQL, Redis, blob"]
otel["Observability Backend<br/><i>[External System]</i><br/>OTLP collector, Prometheus, Tempo"]
idp["Identity Provider<br/><i>[External System]</i><br/>OIDC / OAuth2"]
llm["LLM Provider<br/><i>[External System]</i>"]
dev -- "authors flows and capabilities" --> flowx
ide -- "compiles, emits manifest" --> flowx
client -- "invokes [HTTPS/gRPC]" --> flowx
agent -- "calls capabilities as tools [MCP]" --> flowx
ops -- "observes, replays, rolls out" --> flowx
flowx -- "consumes / publishes events" --> brokers
flowx -- "journals state, reads/writes data" --> stores
flowx -- "exports traces, metrics, logs [OTLP]" --> otel
flowx -- "validates tokens [OIDC]" --> idp
flowx -- "AI-assisted docs, tests, review" --> llm
style flowx fill:#1168bd,color:#fff
style brokers fill:#999,color:#fff
style stores fill:#999,color:#fff
style otel fill:#999,color:#fff
style idp fill:#999,color:#fff
style llm fill:#999,color:#fff
style ide fill:#999,color:#fff
| Interface | Direction | Protocol | Contract owner | Failure mode |
|---|---|---|---|---|
| HTTP ingress | in | HTTP/1.1, HTTP/2, HTTP/3 | FlowX.Http plugin | RFC 7807 + Retry-After |
| gRPC ingress | in | HTTP/2 | FlowX.Grpc plugin | status codes per §4 mapping |
| Bus ingress/egress | both | Kafka, AMQP, MQTT | transport plugin | dead-letter + poison queue |
| Journal | both | ADO.NET / Redis / custom | FlowX.Abstractions/Durability/; store plugins implement it (FlowX.Postgres is the first) |
lease loss → instance re-leased |
| Telemetry | out | OTLP gRPC | OpenTelemetry SDK | drop, never block the flow |
| Identity | in | OIDC discovery + JWKS | ASP.NET Core auth | fail closed |
| Agent surface | in | MCP over stdio/HTTP | FlowX.Ai plugin | policy denial → structured refusal |
| Quality goal | Strategy | Where |
|---|---|---|
| Q1 latency | Compile the flow graph into a static execution plan; pooled context; struct step frames; listener-gated telemetry | ADR-0002, 06 |
| Q2 durability | Per-flow execution profile; append-only journal with step-boundary checkpoints; lease-based ownership; deterministic replay | ADR-0003, ADR-0006, 11 |
| Q3 knowability | Source generator emits flowx.manifest.json; flowx diff gates CI; architecture fitness tests |
ADR-0005, 13 |
| Q4 portability | Trigger attributes are metadata only; flows are transport-free by analyzer rule; the claim is stated over the capability chain, one adapter step in | ADR-0004, ADR-0062, 09 |
| Q5 uniformity | Runtime owns spans/metrics because it owns the graph; replay from journal | 12 |
| Q6 extensibility | Everything above FlowX.Core is a plugin against published contracts |
ADR-0009, 17 |
| Q7 startup | Zero reflection; generated registration; AOT smoke test in CI | ADR-0002 |
| Q8 isolation | Tenant is ambient in context; admission-stage quotas; partitioned durable state | 16 |
The one-sentence strategy: move orchestration from run time to build time, and make the build's output — the manifest — the thing everything else is derived from.
This is the same strategy the table above states, drawn. Read it top-down: anything can trigger a flow, one runtime executes it, six abstractions are all a developer learns, and everything touching infrastructure is a plugin. The precise structure follows in §5 — this picture is orientation, the diagrams below are the specification.
flowchart TB
dev(["👤 Engineer"])
client(["👤 Client"])
subgraph platform["FlowX Platform [System Boundary]"]
direction TB
subgraph buildtime["Build time"]
compiler["FlowX.Compiler<br/><i>[Container: Roslyn generators + analyzers]</i><br/>Graph resolution, codegen, manifest, diagnostics"]
cli["FlowX.Cli<br/><i>[Container: dotnet tool]</i><br/>new · graph · diff · verify · replay"]
end
subgraph runtime["Run time (in the application process)"]
hosting["FlowX.Hosting<br/><i>[Container: composition root]</i><br/>Options, DI, health, lifecycle"]
engines["FlowX.Runtime<br/><i>[Container: engines]</i><br/>Trigger · Flow · Capability · Policy · Event"]
durable["FlowX.Runtime.Durable<br/><i>[Container]</i><br/>Journal · Lease · Replay · Timers"]
plugins["Plugins<br/><i>[Container: Http, Kafka, Cron, Stream, Ai, ...]</i>"]
end
subgraph offproc["Operated separately (optional)"]
studio["FlowX Studio<br/><i>[Container: web app]</i><br/>Topology, replay, impact analysis"]
control["FlowX Control Plane<br/><i>[Container: service]</i><br/>Manifest registry, rollout, quotas"]
end
end
app[("Application code<br/>flows + capabilities")]
stores[("Journal store<br/>PostgreSQL / Redis")]
broker[("Broker")]
otel["OTLP collector"]
dev --> app --> compiler
compiler -- "generated plan + manifest" --> engines
compiler -- "manifest" --> cli
cli -- "publishes manifest" --> control
client --> plugins --> engines
hosting --> engines
engines --> durable --> stores
engines --> plugins --> broker
engines -- OTLP --> otel
control --> studio
studio -- "reads manifest + traces" --> otel
style compiler fill:#1168bd,color:#fff
style engines fill:#1168bd,color:#fff
style durable fill:#1168bd,color:#fff
style hosting fill:#1168bd,color:#fff
style plugins fill:#1168bd,color:#fff
style cli fill:#1168bd,color:#fff
style studio fill:#3a7bd5,color:#fff
style control fill:#3a7bd5,color:#fff
Container responsibilities and reference direction
FlowX.Abstractions ← FlowX.Core ← FlowX.Runtime ← FlowX.Runtime.Durable
↑ ↑ ↑ ↑
└───────── FlowX.Compiler ─────────┘ FlowX.Hosting
↑ ↑
plugins ────────────────────────────────────────────────────┘
FlowX.Abstractions— contracts only. Zero package dependencies. This is what user code references, and what plugin authors implement.FlowX.Core— graph model,Result<T>,FlowContext, policy model. No I/O.FlowX.Runtime— the engines. No transport knowledge.FlowX.Runtime.Durable— replay and timers. Optional package, and unbuilt. This read "journal, leases, replay, timers", and the first two went elsewhere: the contracts are inFlowX.Abstractions/Durability/(WP-51), the store that implements them isplugins/FlowX.Postgres(WP-53), and the lease a node executes under isDurableLeaseinFlowX.Runtime(WP-55). §5.3 has the reasoning.FlowX.Compiler— analyzers + incremental source generators. Ships as an analyzer package; never a runtime dependency.FlowX.Hosting— the single composition root.- Plugins — reference
Abstractionsonly, neverRuntimeinternals.
flowchart TB
subgraph rt["FlowX.Runtime [Container]"]
direction TB
te["Trigger Engine<br/><i>[Component]</i><br/>Normalises envelopes, admission, dedup,<br/>binds envelope → flow input"]
fe["Flow Engine<br/><i>[Component]</i><br/>Walks the compiled plan, owns the<br/>state machine and compensation"]
pe["Policy Engine<br/><i>[Component]</i><br/>Executes the compile-composed<br/>policy chain per stage"]
ce["Capability Engine<br/><i>[Component]</i><br/>Resolves and invokes capabilities,<br/>generated zero-reflection dispatch"]
ee["Event Engine<br/><i>[Component]</i><br/>Outbox write, publication,<br/>schema registry check"]
se["Scheduler Engine<br/><i>[Component]</i><br/>Timers, cron, delayed signals"]
st["Stream Engine<br/><i>[Component]</i><br/>Windows, watermarks, checkpoints,<br/>backpressure"]
oe["Observability Engine<br/><i>[Component]</i><br/>Spans, metrics, log scopes, replay feed"]
pl["Plugin Host<br/><i>[Component]</i><br/>Lifecycle + capability negotiation"]
end
plan[("Generated Execution Plan<br/>static readonly data")]
caps[("User capabilities")]
j["FlowX.Runtime.Durable"]
trig["Trigger plugins"]
sink["Transport plugins"]
trig --> te --> fe
plan -.->|"read once at startup"| fe
fe --> pe --> ce --> caps
fe --> ee --> sink
fe --> se
fe --> j
st --> fe
fe -.-> oe
ce -.-> oe
pl --> trig
pl --> sink
style te fill:#1168bd,color:#fff
style fe fill:#0b4f9e,color:#fff
style pe fill:#1168bd,color:#fff
style ce fill:#1168bd,color:#fff
style ee fill:#1168bd,color:#fff
style se fill:#1168bd,color:#fff
style st fill:#1168bd,color:#fff
style oe fill:#1168bd,color:#fff
style pl fill:#1168bd,color:#fff
Engine contracts are detailed in 06-Execution-Engine. Each engine is independently testable and has exactly one reason to change.
src/
├── FlowX.Abstractions/ # ICapability, IFlow, attributes, Result<T>, contracts
│ ├── Durability/ # IFlowJournal, ILeaseStore, FencingToken, record shapes
│ └── (zero package references)
├── FlowX.Core/ # StepGraph, FlowContext, PolicyModel, Error taxonomy
├── FlowX.Compiler/
│ ├── Analyzers/ # FLOWX1001..1099 diagnostics
│ ├── Generators/ # FlowPlanGenerator, DispatchGenerator, ManifestGenerator
│ └── Model/ # compile-time graph, symbol resolution
├── FlowX.Runtime/
│ ├── Trigger/ Flow/ Policy/ Capability/ Event/ Scheduler/ Stream/ Observability/
├── FlowX.Runtime.Durable/ # planned — does not exist; see the note below
│ └── Replay/ # deterministic re-execution — WP-61
├── FlowX.Hosting/ # AddFlowX(), options, health checks, graceful drain
├── FlowX.Cli/ # new · graph · diff · verify · replay · bench
└── plugins/
├── FlowX.Http/ FlowX.Postgres/ FlowX.Grpc/ FlowX.Kafka/ FlowX.RabbitMq/
├── FlowX.AzureServiceBus/ FlowX.Cron/ FlowX.Stream/ FlowX.SignalR/ FlowX.GraphQL/ FlowX.Ai/
tests/
├── FlowX.Architecture.Tests/ # fitness functions — written FIRST (see §12)
├── FlowX.Compiler.Tests/ # generator snapshot + diagnostic tests
├── FlowX.Runtime.Tests/ # engine unit + integration (Testcontainers)
├── FlowX.Conformance.Tests/ # the suite every plugin must pass
└── FlowX.Benchmarks/ # CI-gated budgets from docs/14-Performance.md
Important
This tree put IFlowJournal and ILeaseStore in FlowX.Runtime.Durable, and that
was wrong — not merely out of date. It contradicted
ADR-0009: a plugin may reference
FlowX.Abstractions and nothing else, so a store author who can only see
FlowX.Abstractions could not have implemented a contract declared one layer up.
Either the contracts move down or the plugin rule is a fiction. The contracts shipped
in FlowX.Abstractions/Durability/ (WP-51) and the tree above now says so.
The first adapter did not land in FlowX.Runtime.Durable either. This box said
that project "keeps what it was always for — the adapters". WP-53 shipped the
PostgreSQL journal and lease store as plugins/FlowX.Postgres, referencing
FlowX.Abstractions and nothing else — which is the same argument one paragraph up,
applied one layer further: a store that needs nothing from Core, Runtime or
Hosting is a plugin, and putting it under src/ would claim a coupling that is not
there. Lease renewal and fencing-token issue did not land there either — they are
DurableLease and LeasePolicy in FlowX.Runtime (WP-55), because they are how the
engine holds a lease rather than how a store issues one. So FlowX.Runtime.Durable
does not exist and the only thing left in it is deterministic re-execution, which is
WP-61; the layering rule still carries a row for it as a forward declaration, described
in §12.
tests/FlowX.Conformance.Tests exists and is a test project, not a package. This
box said it was "deliberately not packable until a second store exists to be held to
it"; that store exists (WP-53) and the project is still not packable. The condition the
csproj actually states is the second and third — WP-53 and WP-54 — so a shape that
one adapter has pushed back on is not yet a shape two have agreed on. Redis is WP-54.
Where do I add a use case? src/<App>.Application/<FlowName>/ — one folder
containing the flow, its capabilities, its contracts and its tests. Nothing else.
sequenceDiagram
autonumber
participant C as Client
participant H as FlowX.Http
participant TE as Trigger Engine
participant FE as Flow Engine
participant PE as Policy Engine
participant CE as Capability Engine
participant OE as Observability
C->>H: POST /api/v1/orders (Idempotency-Key)
H->>TE: TriggerEnvelope{Http, body, headers}
TE->>TE: bind + validate input (generated binder)
TE->>FE: Execute(plan#order.place, ctx)
FE->>OE: start span "flow order.place"
loop each step in the compiled plan
FE->>PE: run stages 1..5 for step
PE->>CE: invoke capability (generated switch, no reflection)
CE-->>PE: Result<T>
PE-->>FE: Result<T> (post-stages applied)
FE->>OE: step event + histogram
end
FE-->>TE: FlowResult.Success(value)
TE-->>H: 200 + payload
H-->>C: 200 OK
Note over FE,CE: zero allocations beyond user payloads — context returned to pool
sequenceDiagram
autonumber
participant C as Client
participant H as FlowX.Http
participant FE as Flow Engine
participant PE as Policy Engine
participant CE as Capability Engine
participant PAY as Payment Provider
C->>H: POST /api/v1/orders
H->>FE: Execute(plan#order.place)
FE->>PE: step payment.capture
PE->>PE: Timeout policy arms 2s deadline
PE->>CE: invoke
CE->>PAY: capture
PAY--xCE: timeout
CE-->>PE: Error{payment.gateway_timeout, Unavailable}
PE->>PE: Retry 1/3 (exp backoff + jitter, deadline-aware)
PE->>CE: invoke
CE->>PAY: capture
PAY--xCE: timeout
PE->>PE: breaker failure ratio > 0.5 → Open
PE-->>FE: Error{Unavailable}
FE->>FE: compensate in reverse: inventory.release
FE-->>H: FlowResult.Failure
H-->>C: 503 RFC7807 {type:.../payment.gateway_timeout, traceId} + Retry-After
Note over FE: flow ends Compensated — no partial reservation leaked
sequenceDiagram
autonumber
participant K as Kafka
participant N1 as Node A
participant J as Journal (Postgres)
participant N2 as Node B
participant CE as Capability
K->>N1: orders.requested
N1->>J: INSERT instance(Pending) + lease(A, ttl 30s)
N1->>CE: step 1 validate
N1->>J: APPEND step1 committed (state snapshot)
N1->>CE: step 2 reserve inventory
N1->>J: APPEND step2 committed
N1->>CE: step 3 capture payment
Note over N1: 💥 node A crashes before step 3 commits
N2->>J: scan expired leases (fencing token n+1)
J-->>N2: instance #42, last committed step 2
N2->>J: acquire lease(B, token n+1)
N2->>N2: rehydrate context from journal (no re-execution of steps 1-2)
N2->>CE: step 3 capture payment (idempotency key = instanceId:step3)
N2->>J: APPEND step3 committed, state=Completed
N2->>K: commit consumer offset
Note over J,N2: at-least-once delivery + capability idempotency = effectively once
sequenceDiagram
autonumber
participant FE as Flow Engine
participant J as Journal
participant INV as inventory.release
participant DLQ as Dead Letter
FE->>J: step3 payment.capture FAILED (retries exhausted)
FE->>J: state = Compensating
FE->>INV: compensate step2 (own retry policy)
INV--xFE: Error{Unavailable}
FE->>FE: compensation retry 1/5 exponential
INV--xFE: Error{Unavailable}
FE->>J: state = CompensationFailed
FE->>DLQ: publish flow.compensation_failed {instanceId, step, error}
FE->>FE: emit alert metric flowx_flow_compensation_failed_total
Note over DLQ: operator replays via `flowx replay --instance 42 --from step2`
Compensation failure is the one case FlowX cannot resolve automatically. The design makes it loud — dedicated metric, dead letter, and a CLI recovery path — rather than silently retrying forever.
flowchart TD
A["User writes Flow + [HttpTrigger] + [KafkaTrigger]"] --> B["FlowX.Compiler:<br/>resolve symbols"]
B --> C{"All steps resolve to<br/>a capability?"}
C -- no --> D["FLOWX1002 error:<br/>step type is not a capability"]
C -- yes --> E{"Graph acyclic?<br/>Does every step's input<br/>come from somewhere?"}
E -- no --> F["FLOWX1021 / FLOWX1020 error"]
E -- yes --> G["Emit ExecutionPlan (static data)"]
G --> H["Emit dispatch switch (no reflection)"]
H --> I["Emit trigger bindings:<br/>endpoints, consumers, cron entries"]
I --> J["Emit flowx.manifest.json"]
J --> K{"flowx diff vs baseline:<br/>breaking change?"}
K -- yes --> L["CI fails — requires major bump or ADR"]
K -- no --> M["Build succeeds"]
flowchart TB
subgraph edge["Edge"]
ing["Ingress / API Gateway<br/>TLS, WAF, global rate limit"]
end
subgraph k8s["Kubernetes cluster"]
subgraph ns["namespace: ordering"]
api["order-api<br/><i>Deployment 3..30, HPA on RPS+p99</i><br/>FlowX ephemeral flows"]
wrk["order-worker<br/><i>Deployment 2..20, KEDA on lag</i><br/>FlowX durable + stream flows"]
sched["order-scheduler<br/><i>Deployment 2</i><br/>cron + timer flows"]
end
otelc["OTel Collector<br/><i>DaemonSet</i>"]
end
subgraph data["Managed data services"]
pg[("PostgreSQL<br/>journal + outbox<br/>HA, PITR")]
redis[("Redis<br/>leases, idempotency, cache")]
kafka[("Kafka<br/>events + streams")]
end
subgraph obs["Observability"]
tempo["Traces"]
prom["Metrics"]
loki["Logs"]
end
control["FlowX Control Plane<br/>manifest registry, rollout, quotas"]
ing --> api
api --> pg
api --> redis
api --> kafka
wrk --> pg
wrk --> redis
wrk --> kafka
sched --> pg
api -. OTLP .-> otelc
wrk -. OTLP .-> otelc
sched -. OTLP .-> otelc
otelc --> tempo
otelc --> prom
otelc --> loki
api -. "publishes manifest at deploy" .-> control
Deployment rules
| Rule | Reason |
|---|---|
| API and worker are separate deployments of the same image | Different scaling signals; identical code and manifest |
The rule as written was a design, and its second clause did not follow from its first: a leader that has lost its lease and not noticed fires anyway, and a leader that dies at 01:59 takes the 02:00 firing with it until a successor is elected. The order-scheduler box above remains a legitimate deployment shape — a schedule that fires heavy work is worth isolating — but it is a capacity decision now, not a correctness one, and replica ≥ 2 buys availability rather than exclusivity |
|
terminationGracePeriodSeconds ≥ max flow step budget + 10 s |
Graceful drain: stop accepting, finish in-flight, release leases |
| Journal DB is regional, not global | Cross-region durable flows need explicit design (11) |
| Manifest is published at deploy, not at build | The registry records what is running, not what was compiled |
Rollout strategy, KEDA scalers and drain semantics in 18-Cloud-Native.
| Concept | Rule | Detail |
|---|---|---|
| Error handling | Expected outcomes are Result<T>; exceptions mean defects or infrastructure faults. The runtime never converts an exception into a business error silently — it records Internal and logs the exception with its trace ID |
04 §8 |
| Validation | Input validation is stage 3 (Integrity), generated from contract annotations; business rules are the first flow step | 10 |
| Logging | Structured only. Data as fields, never interpolated. flow.id, flow.instance_id, step.id, capability.id, tenant.id, trace_id on every record |
12 |
| Persistence | FlowX owns only journal + outbox + idempotency store. Business persistence is inside capabilities and is none of FlowX's business | 11 |
| Serialisation | System.Text.Json source-generated contexts only (C2 AOT). Journal payloads carry a schema version |
ADR-0008 |
| Time | Capabilities must obtain time from ctx.UtcNow, never DateTime.UtcNow — replay determinism. Enforced since WP-58 by FLOWX1007: Warning, and Error where the compilation shows the code on a durable flow's replay path. This row read "Unenforced: FLOWX1007 does not exist". The property name is UtcNow, not Clock |
06 §5 |
| Randomness / IDs | ctx.NewId() and ctx.Random are journaled on first use so replay reproduces them. The journalling exists since WP-52 and is persisted since WP-53; the rule exists since WP-58. A Durable flow captures the ids minted and Random's seed per step boundary, and FLOWX1008 reports the ambient alternatives. This row said FLOWX1008 did not exist and that no store persisted a capture; both have expired. What still holds: nothing replays a capture back into execution (WP-61), so the reproduction is specified and unproven |
06 §5 |
| Configuration | Selects adapters and tunes policy parameters. It can never change the graph | Manifesto §"What we refuse" |
| Security | Deny-by-default at the capability boundary; STRIDE per trust boundary | 15 |
| Tenancy | TenantId is ambient in FlowContext, enforced at admission and at the journal partition key |
16 |
| Versioning | Flows and capabilities carry SemVer; running instances pin the version they started with | 07 |
| ADR | Decision | Status |
|---|---|---|
| 0001 | Flow + Capability as the only two user primitives | Accepted |
| 0002 | Compile-time orchestration via Roslyn generators, no runtime reflection | Accepted |
| 0003 | Per-flow execution profiles instead of always-durable | Accepted |
| 0004 | One trigger abstraction for all transports | Accepted |
| 0005 | Manifest is a first-class build artifact | Accepted |
| 0006 | Journal + fenced leases for durable execution | Accepted |
| 0007 | Result<T> for business outcomes, exceptions for defects |
Accepted |
| 0008 | Source-generated STJ + versioned schemas | Accepted |
| 0009 | Plugins depend only on FlowX.Abstractions, with a conformance suite |
Accepted |
| 0010 | C# fluent DSL as the source of truth; YAML is export only | Accepted |
| 0011 | Fixed policy stage order, not user-composed pipelines | Accepted |
| 0012 | Apache-2.0 licence | Accepted |
| 0013 | DSL vocabulary takes precedence over CA1716 | Accepted |
| 0014 | Keep the derived error catalogue; re-express the build-overhead budget | Proposed |
This index stopped at 0012 while two more ADRs were written. The authoritative list, with each record's "Revisit when", is adr/README.md; this table is a convenience copy and drifted because nothing checks that the two agree.
| # | Source | Stimulus | Environment | Response | Measure |
|---|---|---|---|---|---|
| QR1 | Client | 10 000 req/s to a 4-step ephemeral flow | 4-core pod, warm | Flow executes | platform overhead p99 ≤ 5 µs; 0 alloc/step |
| QR2 | Chaos | SIGKILL a worker mid-flow |
3 nodes, durable profile | Flow resumes elsewhere | resume p99 ≤ 45 s (lease TTL 30 s); 0 duplicate non-idempotent effects |
| QR3 | Engineer | Adds a step with an incompatible contract | build | Build fails | FLOWX1020 with symbol + fix, < 1 s added build time |
| QR4 | Engineer | Changes a capability's output shape | CI | flowx diff fails |
breaking change detected 100 % for removed/retyped members |
| QR5 | Operator | Needs to know why instance 42 failed | production | Full causal replay available | every step's input/output/error retrievable for the retention window |
| QR6 | Tenant B | Tenant A floods its quota | shared cluster | Tenant A throttled at admission | tenant B p99 degradation ≤ 10 % |
| QR7 | Platform team | Adds an MQTT trigger | dev | Works without touching runtime | 0 files changed in FlowX.Runtime; conformance suite passes |
| QR8 | Ops | Cold-starts a pod | AOT image | Ready to serve | ≤ 200 ms; ≤ 60 MB RSS idle |
| QR9 | Security | Reviews who can run payment.capture |
audit | Single answer from the manifest | 100 % of capabilities have an explicit authorisation stance |
| # | Risk | Impact | Likelihood | Mitigation | Owner |
|---|---|---|---|---|---|
| R1 | Source-generator complexity becomes the platform's own legacy — generators are hard to debug and slow builds | High | High | Generators emit readable C# to obj/generated; snapshot tests on every emitted file; build-time budget gate (≤ 8 %); generator logic kept in a pure, unit-testable model layer separate from Roslyn plumbing |
Compiler team |
| R2 | Determinism leaks in durable flows — a capability uses DateTime.UtcNow, Guid.NewGuid() or ambient statics, so replay diverges |
High | High | Live since WP-52, and mitigated since WP-61 — see below. All three named mitigations now exist: the journal records non-deterministic values on first use, FLOWX1007/1008/1009 are raised, and ReplayDeterminismTests replays a corpus of eight shapes against their own journals and compares them row for row. Three residual gaps are measured rather than assumed, each pinned by a test that goes red when it is closed: an overlapping Parallel does not replay, a compensation's ambient reads are captured by nothing, and the engine's own deadline check is not replayed. Replay of control flow still rests additionally on FLOWX1011's coverage |
Runtime team |
| R3 | Abstraction leak under real transports — a universal trigger model cannot express Kafka rebalance, HTTP streaming, MQTT QoS | Medium | High | Untested: there is one transport. Planned escape hatch: ITriggerSource exposes transport-specific options outside the flow — the interface is not declared anywhere in src/ — plus a conformance suite defining the minimum semantics. A conformance project now exists (WP-51), but its three suites — JournalConformance, LeaseStoreConformance, RecoveryIndexConformance — are all durability contracts and none is a trigger suite, so this mitigation is untouched. What holds today: documented non-goals per transport (09 §12). The risk cannot be evaluated until P3 adds a second transport |
Plugin team |
| R4 | Adoption cliff — teams must rewrite to gain value | High | Medium | Incremental adoption path: FlowX hosts inside existing ASP.NET Core apps; a capability can wrap an existing service; MediatR bridge plugin for step-by-step migration |
DevRel |
| R5 | Journal becomes the bottleneck at high durable throughput | High | Medium | Reachable since WP-53, and unmeasured. Ephemeral remains the default, so durability is opt-in, and that is the only one of these mitigations that exists. plugins/FlowX.Postgres writes one step row, one instance update and its outbox rows in one transaction per step, with no group commit and no partitioning; the benchmark gate QR2 is B7, which has no harness (WP-50), so the ceiling quoted in 14 §5 and ADR-0006 is still a literature figure |
Runtime team |
| R6 | Fixed policy stage order is too rigid for a legitimate case | Medium | Medium | Documented escape: a capability may declare PolicyStage.Custom handlers within its own stage; revisit ADR-0011 after 3 real counterexamples |
Architecture |
| R7 | Manifest drift between build and deploy (config changes behaviour) | Medium | Low | Configuration is structurally forbidden from changing the graph; control plane records the deployed manifest hash; flowx verify --runtime compares |
Platform |
| R8 | Ecosystem thinness — a platform is only as good as its plugins | High | Medium | Ship 8 first-party plugins at v1; publish the conformance suite as a NuGet package so third parties can self-certify. Begun, and not yet a mitigation: two of the six suites are written, and WP-53 showed the mechanism travels — tests/FlowX.Postgres.Tests inherits both unmodified from another assembly and runs them against PostgreSQL 16.13. The project is still deliberately not packable: this cell gave the condition as "until a second store exists", and that store now does; the csproj's condition is the second and third (WP-53, WP-54), one adapter's push-back not being agreement. So there is still nothing published and nothing outside this repository can self-certify against anything |
DevRel |
Important
R2 went live at WP-52 (2026-07-31); its third and last named mitigation landed at WP-61, the same day. This box said the mitigations "did not" land — that a risk whose mitigation is fictional is an unmitigated risk that has stopped being reviewed — and then that "prevention and proof are different claims, and only the first of them exists". All three rows below have now changed, and the second claim has one. The table is kept because what a mitigation covers is narrower than its name, and the residual is worth more than the tick.
| Named mitigation | State | Evidence |
|---|---|---|
Analyzers FLOWX1007/1008/1009 as errors in durable flows |
exists — WP-58 | This row said "does not exist": none of the three was a descriptor FlowXDiagnostics declared, because the catalogue holds only ids something reports. All three are now declared and raised — DeterminismAnalyzer and AmbientReads in src/FlowX.Compiler/Analysis/, both directions pinned by DeterminismAnalyzerTests. Not quite "as errors in durable flows": the stance was re-decided for the whole determinism set as Warning by default, Error where the compilation can prove the code is on a durable flow's replay path, and a capability reached only from a referenced assembly gets the Warning. Reasoning on the diagnostics index |
| Replay conformance test asserting byte-identical outputs | exists — WP-61 | This row said "does not exist: no test in the solution named ReplayDeterminismTest or anything like it; no test replays anything", and called itself the row that had not moved. It has. ReplayDeterminismTests runs each of eight shapes twice — once against the world, once against the journal the first run wrote, on a clock a hundred days away so a fresh read cannot be mistaken for a replayed one — and compares every action the flow took against every row the journal holds, capture included. Six of its tests exist only to prove the comparison can go red, because a determinism gate that cannot fail is worth nothing |
| Journal records non-deterministic values on first use | exists — written by WP-52, read by WP-61 | This row said "there is no journal type in the solution", then "nothing writes one", then that the only journal was an in-memory reference that had never met a database. All three have stopped being true, in that order. WP-51 added IFlowJournal and NondeterminismCapture; WP-52 writes one — a Durable flow captures ctx.UtcNow, the ids ctx.NewId() minted and Random's seed per step boundary — and WP-53 persists it, in plugins/FlowX.Postgres against PostgreSQL 16.13. This row also said "written, never read" and "nothing replays a capture back into execution". WP-61 added the read half — FlowExecutionContext.ReplayNondeterminism — so a step can be handed the instant, the ids and the seed its row records. Inside a Parallel the attribution is still best-effort: one pooled context is shared by every branch, so a captured id can land on a sibling's row. It is no longer harmless, because something replays it now: WP-61 pinned the exact interleaving and measured the consequence rather than buying the per-branch context, so a fork whose branches overlap does not replay |
The risk was previously unreachable rather than mitigated, because
FlowX.Runtime never read ExecutionProfile and a flow declared Durable ran
the identical ephemeral path, so a determinism leak had nowhere to diverge.
That is no longer the case. A Durable flow now journals, persists and can be
resumed, which means a leak can produce a divergence. R2 has moved from
unreachable through live and unmitigated and live and partly mitigated to
live and mitigated: the analyzers stop the ordinary ways a leak is written, and
WP-61's corpus detects one that got past them — through a capability whose durable
caller is in another assembly, through a suppression, or through anything
FLOWX1011 provably cannot see — for any shape the corpus covers.
What is left is three measured gaps rather than an unknown. A Parallel whose
branches genuinely overlap does not replay, because one pooled context is shared by
every branch; a compensation's ambient reads are captured by nothing, so an undo that
reads the clock cannot be replayed at all; and the engine's own deadline check reads
the replaying node's clock, because the step loop offers no per-step hook a replay
driver could use. Each is pinned by a test that goes red the day it is closed, which
is the only form of "known limitation" note that survives contact with a codebase.
The first is the per-branch context
ADR-0015#what-wp-52-landed-and-what-it-did-not)
named as WP-61's to buy; WP-61 measured its absence instead, and said so.
The analyzers were supposed to land with the journal rather than six work packages after it: 20-Roadmap §3 lists them in P2's Must and §6 makes any replay divergence a stop-the-phase trigger.
FLOWX1028 no longer covers this. It warned on every
flow declaring a profile the runtime does not implement — removing the false
comfort rather than mitigating anything — and WP-52 narrowed it to
Streaming, because Durable became implemented. This paragraph then said the
author of a durable flow "is now told nothing at all", which was the gap WP-58
closed: a durable flow whose capabilities read the ambient clock, mint an id or
hold mutable state is now told, by three rules and at Error.
A further mitigation exists and is not in the table above:
FLOWX1011 covers the flow's deterministic
zone — conditions, selectors, projections and step input maps may read only
the flow context, the flow input and prior step results. It carries more
weight than it was designed for. ADR-0015 originally required the journal to
record the branch a Switch took; it has no field for one, and
the amendment#amendments-the-first-implementation-forced-wp-52)
resolved that by replaying the selector against the restored state bag —
so replay of control flow rests on this rule. This paragraph said it was
"specified to become an Error under Durable at WP-58"; what WP-58 decided is that
it already had the right severity and the rest of the set should match it — Warning
outside a provable durable replay path, Error on one. Its own limits are unchanged
and are the ones that matter here: it reads builder delegates, so it says nothing
about capability bodies, which is where R2's example lives, and
its page lists what it
provably cannot see.
Accepted technical debt for v1: no dynamic/interpreted flows (P4 trade-off), no cross-region durable flows, no human-task/BPM model, no visual editing round-trip in Studio (read-only visualisation first).
Every rule above is an executable gate. Most are tests in
tests/FlowX.Architecture.Tests; two are CI jobs, because what they assert is a
build, not an assertion about one. The "Lives in" column is the point of this
table — it used to name fourteen tests of which seven existed nowhere, and a
reader who saw the name stopped looking for the rule.
| Test | Rule enforced | Fails when | Lives in |
|---|---|---|---|
AbstractionsHasNoDependencies |
§5.1 | FlowX.Abstractions gains any package reference |
DependencyRuleTests |
LayersPointInward |
§5.1 | Core references Runtime; Runtime references a plugin |
DependencyRuleTests |
NoCyclicDependencies |
P4 | any project or namespace cycle appears | DependencyRuleTests |
NoReflectionOnHotPath |
P4 | System.Reflection, Activator or the runtime binder is used in FlowX.Abstractions, FlowX.Core or FlowX.Runtime |
RuntimeIsolationTests |
RuntimeHasNoMutableStatics |
P7 | a static field in FlowX.Runtime is neither readonly nor const |
RuntimeIsolationTests |
FlowsAreTransportFree |
P3 | a flow's closure — including the generated half — reaches a transport or a plugin namespace | TransportIsolationTests |
CapabilitiesDoNotCallCapabilities |
§2 | an ICapability implementation reaches another one, from a dependency or a method body |
TransportIsolationTests |
EveryCapabilityDeclaresAuthorization |
P11 | a capability lacks an authorisation stance | SecurityFitnessTests |
EveryPublicContractIsVersioned |
C7 | a flow, capability, event, manifest or shipped package carries a version that is not SemVer | PublishedContractTests |
ManifestIsComplete |
Q3 | a declared flow or capability is missing from the manifest, or a step names one the manifest never describes | PublishedContractTests |
PluginsPassConformance |
Q6 | a plugin fails the shared conformance suite | not written — see below. A conformance project now exists; it has no trigger suite |
SuppressionsAreAccountable |
§6.1 | a suppression cites no registered, unexpired FLOWX-DEBT id |
DebtAccountabilityTests |
DependencyLicencesAreCompatible |
C6 | a declared or resolved package has no row in the dependency licence register, or carries a licence Apache-2.0 redistribution does not permit (ADR-0012) | DependencyLicenceTests |
EveryDiagnosticIsHelpful |
P12 | a FLOWX* diagnostic lacks title, fix, or help URI |
FlowX.Compiler.Tests |
| (job, not a test) | Q1, Q7 | > 5 % regression against baseline.json — B1, B3 and B12 in isolation |
Benchmark budgets job, performance.yml |
AllocationBudgetTests, EngineAllocationTests |
Q7 | any allocation on the linear, conditional or switch path | Allocation budget (B2) job, performance.yml |
| (job, not a test) | C2 | PublishAot=true fails, emits trim warnings, or the published binary does not serve a request |
NativeAOT smoke test job, ci.yml |
CI runs these on every pull request. A red fitness function is a build failure, not a discussion.
NoReflectionOnHotPath and RuntimeHasNoMutableStatics read IL, because
P4 and
P7 say so and because the failure they exist to
catch arrives through a generator or an extension method, not through a using directive.
The one exemption is MemberInfo.Name: typeof(T).Name compiles to a call on a
System.Reflection type, it is how the runtime says which contract a step failed to
produce, and it discovers nothing. Everything that looks a member up is still caught.
ManifestIsComplete does not check policies or events, and the row above is written as
though it checked everything. The reason has changed since this paragraph was written and
the paragraph did not: it used to say "no attribute applies a policy to a step, and the
generator emits no policies section", and both halves of that are now false.
.WithPolicy(PolicySet) attaches one, FlowAnalyzer reads the set well enough to raise
FLOWX1014 and FLOWX1018 off its contents, and ManifestWriter.WritePolicies emits a
policies array per step with each policy's fixed stage.
What used to be true here was that no policy executed at all, so a completeness check over
policies would pass vacuously. Both halves have expired. samples/banking declares
seven policy sets, so the emission path runs against a shipped assembly on every build; and
FlowX.Runtime executes every stage a PolicySet can declare into. Timeout, Retry,
CircuitBreaker and Bulkhead were the first (PolicyStage.Resilience); RateLimit
admits or refuses the caller at stage 1, an Idempotency window claims and replays at
stage 3, a Cache is consulted at stage 5 and an Audit record is written after the step's
commit at stage 7. Nothing is declared-and-inert, which is why FLOWX1032 — the rule that
reported exactly that — is deleted rather than narrowed a third time. The staging argument is
ADR-0025.
events is no longer the same case, and that is the change worth stating. .Emit<T>()
reaches the plan, the manifest and the outbox: a Durable flow's emitted event is staged
by the step's own commit and drained by PostgresOutboxPublisher, so a completeness check
over events would not pass vacuously. FLOWX1024 is no longer
raised on every .Emit — only on the two that still cannot be staged, an Ephemeral flow
and a contract outside every source-generated JsonSerializerContext. This paragraph then
said the network remained unproved because IEventPublisher had no implementation but a
recording test double; that expired at WP-56b, when RedisStreamEventPublisher shipped and
PublisherConformance began holding both to one contract.
The stale wording was duplicated verbatim in the ManifestIsComplete XML doc comment in
tests/FlowX.Architecture.Tests/PublishedContractTests.cs. That comment carries the same
correction, so the two do not disagree.
PluginsPassConformance is blocked, not overlooked, and the reason has narrowed. This
paragraph used to say "there is no conformance suite to run", and that is no longer true:
WP-51 added tests/FlowX.Conformance.Tests with JournalConformance and
LeaseStoreConformance. What it does not contain is a suite for the extension point this
gate is about — there is no TriggerSourceConformance, and ITriggerSource is still not
declared in src/ — so R3 and
R8, which both name publishing one as the mitigation, are
still unmet. Two further things keep the gate honest rather than merely unwritten: the
conformance project is not packable, so no third party can run it, and there is one
transport plugin, FlowX.Http, so "every plugin agrees on the minimum semantics" has
nothing to compare. This sentence read "there is one plugin"; plugins/FlowX.Postgres
is a second one (WP-53), and it agrees with JournalConformance and
LeaseStoreConformance — neither of which is the suite this gate would run. Writing it
against the single transport that exists would produce a test that restates
FlowX.Http.Tests under a name claiming ecosystem coverage. Recorded in
21-Quality-Gates §2.4 with
what it is waiting for.
LayersPointInward names one project that does not exist, on purpose. Its subject list
is maintained by hand and includes FlowX.Runtime.Durable, which is planned (§5.3) and
unbuilt; RuntimeDoesNotReferenceAnyPlugin allows the same name for the same reason. The
row is inert — the theory returns early when the project is absent — so it asserts nothing
today and starts asserting the moment the project appears, which is the point of writing
the rule before the code. What it is not is coverage: a reader who sees the name must
not infer the project exists. EverySourceProjectIsCoveredByTheLayeringRule checks that
every project under src/ is named by the list; it deliberately does not check the reverse,
because a row naming nothing cannot hide a project — a mistyped row leaves the real project
uncovered and that test catches it.
The enforced set, in full, is CHECKLIST §4.
Next: 06 — Execution Engine · Decisions: ADR index

