diff --git a/AGENTS.md b/AGENTS.md
index d54db8f9..87d489ad 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -16,6 +16,13 @@ The repository is organized into two main Python packages:
- **`sentry_streams_k8s/`** - Kubernetes integration and deployment automation for Sentry Streams (pure Python package)
- See [sentry_streams_k8s/AGENTS.md](./sentry_streams_k8s/AGENTS.md) for package-specific development instructions
+## Architecture documents
+
+Overall structure of the streaming platform is in the [README](./README.md).
+It contains links to the specific module.
+
+The architecture of the platform itself — subsystems, decisions, invariants, guarantees
+and known debt — is in [sentry_streams/docs/architecture](./sentry_streams/docs/architecture/README.md).
### Other Directories
diff --git a/README.md b/README.md
index ea3556ba..2cb5b565 100644
--- a/README.md
+++ b/README.md
@@ -21,9 +21,6 @@ The main features are:
- Support for stateful and stateless transformations. The state storage is
provided by the platform rather than being part of the application.
-- Distributed execution. The primitives used to build the application can
- be distributed on multiple nodes by configuration.
-
- Hide the Kafka details from the application. Like commit policy and topic
partitioning.
@@ -34,7 +31,72 @@ The main features are:
- Support for multiple runtimes.
-[Streams Documentation](https://getsentry.github.io/streams/)
+[Streams User Documentation](https://getsentry.github.io/streams/)
+
+## Architecture of a Streaming Application
+
+The three main components of a streaming application built with this library are:
+
+* The pipeline itself written via the Pipeline DSL. This represents the application logic.
+
+* The runtime. This is the binary that executes the pipeline primitives and processes data.
+
+* The Kubernetes infrastructure to deploy the pipeline (when on Kubernetes).
+
+```mermaid
+flowchart TB
+ Kafka[Kafka]
+
+ subgraph StreamingApp["Streaming Application"]
+ subgraph deployment["Kubernetes deployment"]
+ subgraph Pipeline["Pipeline"]
+ direction LR
+ source[source] --> transform[transform] --> sink[sink]
+ end
+
+ Runtime[Runtime]
+ Runtime -->|executes| Pipeline
+
+ Config[Pipeline ConfigMap]
+ end
+
+ subgraph K8s["Kubernetes Infra"]
+ Macro["sentry-kube macro"]
+ Operator[Operator]
+ end
+ end
+ K8s -- manages --> deployment
+ K8s -- manages --> Config
+ Kafka --> source
+```
+
+Pipeline and runtime architecture docs are [here](./sentry_streams/docs/architecture/README.md)
+
+The pipeline DSL allows the user to define the streaming pipeline through a set
+of dataflow primitives chained together. This is a Python DSL now.
+
+The application logic is separate from the infrastructure configuration which
+is provided as a separate yaml file. The config file covers aspects like
+observability, scale, parallelism, tuning, etc. This is meant to separate
+the application logic from infra.
+
+The Runtime consumes the pipeline defined above and the config file, manages
+the connectivity with Kafka and executes the processing in a supposedly optimized
+way.
+
+The platform is provided as a Python package that contains also a native
+portion as the runner. This package (`sentry_streams`) is imported by the application
+code. The application defines the pipeline in its own code base and then uses
+the runner provided by the library for the execution.
+
+This system provides the infrastructure needed to run the application in Kubernetes.
+There are two options:
+
+* A [Sentry Kube](https://github.com/getsentry/sentry-infra-tools#sentry-kube) macro.
+ This can be used to generate Deployments and Configmap manifests to deploy manually
+
+* An experimental operator that manages a pipeline as a CRD.
+
## to develop in this repo
diff --git a/sentry_streams/AGENTS.md b/sentry_streams/AGENTS.md
index ddbbeb78..9f0ad58d 100644
--- a/sentry_streams/AGENTS.md
+++ b/sentry_streams/AGENTS.md
@@ -24,6 +24,10 @@ the runner CLI script and the Arroyo adapter to run the application on single no
- **`integration_tests/`** - Integration tests
- **`docs/`** - Sphinx documentation
+## Architecture documents
+
+Architecture is described [here](./docs/architecture/README.md)
+
## Quick Start
**Recommended:** Use the repository root Makefile commands documented in [../AGENTS.md](../AGENTS.md):
diff --git a/sentry_streams/Cargo.toml b/sentry_streams/Cargo.toml
index 07847758..e7780d55 100644
--- a/sentry_streams/Cargo.toml
+++ b/sentry_streams/Cargo.toml
@@ -6,7 +6,7 @@ edition = "2021"
[dependencies]
pyo3 = { version = "0.29.0" }
serde = { version = "1.0", features = ["derive"] }
-sentry_arroyo = { version = "2.40.0", features = ["ssl"] }
+sentry_arroyo = { version = ">=2.40.0, <2.44.0", features = ["ssl"] }
chrono = "0.4.40"
tracing = "0.1.40"
tracing-subscriber = "0.3.20"
diff --git a/sentry_streams/README.md b/sentry_streams/README.md
index 388c4a2f..086329c8 100644
--- a/sentry_streams/README.md
+++ b/sentry_streams/README.md
@@ -6,3 +6,14 @@ is designed to handle real-time unbounded data streams.
This is built primarily to allow the creation of Sentry ingestion pipelines
though the api provided is fully independent from the Sentry product and can
be used to build any streaming application.
+
+## Documentation
+
+- **[Architecture](./docs/architecture/README.md)** — the subsystems, the decisions behind
+ them, the rules that must hold, and what is unfinished. Start there whether you are building
+ on the platform or taking ownership of it.
+- [Adding a step](./docs/architecture/guides/adding-a-step.md) — the procedure for extending
+ the DSL and the runtime.
+- [Deployment configuration](./docs/reference/deployment-config.md) — the config file format.
+- [User documentation](https://getsentry.github.io/streams/) — the published Sphinx docs.
+- [AGENTS.md](./AGENTS.md) — development environment, tests, type checking.
diff --git a/sentry_streams/docs/architecture/README.md b/sentry_streams/docs/architecture/README.md
new file mode 100644
index 00000000..49b17ea3
--- /dev/null
+++ b/sentry_streams/docs/architecture/README.md
@@ -0,0 +1,189 @@
+# Sentry Streams Architecture
+
+This directory describes the architecture of the `sentry_streams` package: the roles of
+the main subsystems, the decisions behind them, the rules that must hold, and what we
+know is unfinished. The decisions themselves, one per page, are in the
+[decision log](./decisions/README.md).
+
+# The model
+
+This is the conceptual model the rest of the documentation assumes. Vocabulary is in
+the [glossary](./README.md#glossary).
+
+## The three layers
+
+A streaming application built on this platform is made of three independent layers.
+The whole design of the package follows from keeping them separate.
+
+**The pipeline DSL** is how a product team describes what their application does. It is
+a pure, declarative description of a dataflow graph: a source, a series of
+transformations, one or more sinks. It contains no reference to Kafka brokers, thread
+counts, consumer groups, or the engine that will run it. An application is a Python
+file that builds a `Pipeline` object and assigns it to a module level variable named
+`pipeline`.
+
+**The adapter** turns that description into something executable on a specific runtime.
+Each adapter implements a common interface, one method per primitive, and is free to
+build whatever runtime-specific structure it wants as those methods are called.
+
+**The runner** is the glue and the entry point. It loads the application file and the
+deployment configuration, instantiates the requested adapter, walks the pipeline graph
+handing each step to the adapter, and tells the adapter to run.
+
+Alongside these there is **the deployment configuration**: a YAML file carrying
+everything the pipeline description deliberately left out. It is described once, in
+[Deployment configuration](../reference/deployment-config.md).
+
+```mermaid
+flowchart TB
+ subgraph authored["Authored by the product team"]
+ app["application.py (Pipeline DSL)"]
+ cfg["config.yaml (deployment config)"]
+ end
+
+ runner["Runner sentry_streams/runner.py"]
+
+ subgraph adapters["Adapters"]
+ rust["RustArroyoAdapter"]
+ py["ArroyoAdapter (Python)"]
+ other["...custom adapter"]
+ end
+
+ runtime["Running consumer"]
+
+ app -- "pipeline graph" --> runner
+ cfg -- "infrastructure config" --> runner
+ runner -- "translate_step() per step" --> adapters
+ adapters -- "run()" --> runtime
+```
+
+The key property of this split is that the same application file can be deployed
+against different brokers, topics, parallelism settings and even different runtimes
+without being modified.
+
+A fourth layer, the Kubernetes deployment automation, lives in the `sentry_streams_k8s`
+package. The seam between the two is described in
+[Deployment configuration](../reference/deployment-config.md#the-seam-with-sentry_streams_k8s).
+
+## The primitives
+
+The DSL is built from a small set of primitives, in a few families:
+
+- **Sources** produce messages into the pipeline. `StreamSource` reads from a Kafka
+ topic. A pipeline has exactly one root source.
+- **Transforms** are 1:1 or 1:N operations: `Map`, `PredicateFilter`, `HeadersFilter`,
+ `FlatMap`.
+- **Reduces** accumulate multiple messages into one over a window: `Batch` for plain
+ batching, `Aggregate` for windowed, optionally keyed, accumulator based aggregation.
+- **Branching steps** split the stream: `Router` sends each message to exactly one
+ downstream branch, `Broadcast` sends a copy to every branch. Both take fully defined
+ sub-pipelines built with `branch()`.
+- **Sinks** terminate a branch: `StreamSink` produces to Kafka, `GCSSink` writes objects
+ to GCS, `DevNullSink` discards. Every branch must end in a sink; the runner validates
+ this before anything is built.
+- **Complex steps** are higher level primitives that are syntactic sugar over the simple
+ ones: `Parser`, `Serializer`, `BatchParser`, `ParquetSerializer`, `Reducer`. See
+ [Pipeline DSL and runner](./pipeline-dsl-and-runner.md#complex-steps).
+
+Refer to [pipeline.py](../../sentry_streams/pipeline/pipeline.py) to find documentation
+on each of the implemented steps.
+
+A pipeline is built by chaining these together. Each step has a name, which is also the
+key the deployment config uses to configure that step.
+
+```python
+from sentry_kafka_schemas.schema_types.ingest_metrics_v1 import IngestMetric
+
+from sentry_streams.examples.transform_metrics import filter_events, transform_msg
+from sentry_streams.pipeline.pipeline import (
+ Map,
+ Parser,
+ PredicateFilter,
+ Serializer,
+ StreamSink,
+ streaming_source,
+)
+
+pipeline = streaming_source(name="myinput", stream_name="ingest-metrics")
+
+(
+ pipeline.apply(Parser[IngestMetric]("parser"))
+ .apply(PredicateFilter("filter", function=filter_events))
+ .apply(Map("transform", function=transform_msg))
+ .apply(Serializer("serializer"))
+ .sink(StreamSink("mysink", stream_name="transformed-events"))
+)
+```
+
+Notice what is absent: no broker address, no consumer group, no parallelism, no runtime.
+`ingest-metrics` and `transformed-events` are logical stream names the deployment config
+can override with the physical topics of a given environment.
+
+This snippet is the canonical example. Other documents refer to it rather than repeating
+it.
+
+## Build, then run
+
+Building and running are two distinct phases, and the adapter interface is shaped around
+that distinction.
+
+During the **build phase** the runner traverses the pipeline graph from the root source,
+calling one adapter method per step through a `RuntimeTranslator`. The traversal follows
+data flow rather than a list: each method receives the handle of the stream it attaches
+to and returns the handle it produces, and branching steps return one handle per branch.
+Nothing executes; the adapter accumulates.
+
+During the **run phase** the runner calls `run()` on the adapter, which blocks until the
+consumer shuts down.
+
+That these are two separate functions is an invariant, not an implementation detail —
+see [Contracts](./contracts.md#the-runner).
+
+## Multiple adapters
+
+The adapter interface is generic in the type of the stream handle, so adapters can
+represent "a stream" however their runtime requires. The runner knows nothing about what
+flows through it.
+
+The adapter used in production is the **Rust Arroyo adapter** (`rust_arroyo`), documented
+in [The Rust Arroyo adapter](./rust-arroyo/README.md).
+
+
+# Reading paths
+
+**Evolving the platform**
+
+2. [Adding a step](./guides/adding-a-step.md) — the procedure, with anchors and a skeleton.
+3. [Contracts and invariants](./contracts.md) — the rules a new step must not break.
+4. The [Rust runtime](./rust-arroyo/README.md) page for the area you are touching.
+
+**Architecture**
+
+1. [Decision log](./decisions/README.md) — the choices this design rests on, and why.
+2. [Guarantees and failure modes](./guarantees.md) — what the system promises.
+
+
+# Glossary
+
+Several of these words are used in more than one sense elsewhere in the industry, and
+two of them are used in more than one sense in this repository.
+
+| Term | Meaning here |
+| --- | --- |
+| **Step** | A node in the pipeline the application author writes: `Map`, `Batch`, `StreamSink`. |
+| **Primitive** | A step type the adapter interface has a method for (`source`, `map`, `filter`, `reduce`, `sink`, `router`, `broadcast`, `flat_map`). |
+| **Complex step** | A step that is syntactic sugar: it `convert()`s to simple steps before an adapter sees it. `Parser`, `Serializer`, `Reducer`. |
+| **Adapter** | The component that turns a pipeline description into something a specific runtime can execute. |
+| **Handle** | Whatever an adapter uses to represent "a stream" while the graph is being walked. Opaque to the runner. For the Rust adapter it is a `Route`. |
+| **Route** | A source name plus an ordered list of waypoints. Identifies a branch of the pipeline. |
+| **Waypoint** | One branch name appended to a route by a `Router` or `Broadcast`. |
+| **Operator** (`RuntimeOperator`) | A Rust-side *descriptor* of something to build. One operator may cover several DSL steps. |
+| **Strategy** | An Arroyo `ProcessingStrategy`. What an operator is built into at run time. |
+| **Chain** / **fusion** | Consecutive `Map` steps accumulated by the adapter and composed into a single function. |
+| **Delegate** | A Python object implementing `RustOperatorDelegate`, driven by the Rust `PythonAdapter` strategy. |
+| **Segment** (config list) | An entry in `pipeline.segments`, selected with `--segment-id`. The deployment-level unit: one Kubernetes workload per entry. |
+| **Segment** (`starts_segment`) | A boundary between chains *within* one process. Marks where fusion stops and a new parallelism setting applies. |
+| **Watermark** | A periodic control message carrying accumulated offsets. Commits are driven by watermarks, not by data messages. |
+| **Committable** | The set of `(topic, partition) → offset` entries a message or watermark is responsible for. |
+| **`RawMessage`** | A payload the runtime understands: bytes, readable from Rust without the GIL. |
+| **`PyAnyMessage`** | A payload only the application understands: an opaque reference to a Python object. |
diff --git a/sentry_streams/docs/architecture/contracts.md b/sentry_streams/docs/architecture/contracts.md
new file mode 100644
index 00000000..77d77015
--- /dev/null
+++ b/sentry_streams/docs/architecture/contracts.md
@@ -0,0 +1,64 @@
+# Contracts and invariants
+
+Rules that hold across the platform. Each one is load-bearing: something breaks, usually
+subtly, if it stops holding. Use this as a review checklist.
+
+Behavioural guarantees to the outside world — delivery semantics, failure policy — are in
+[Guarantees](./guarantees.md).
+
+## The runner
+
+| Invariant | Why |
+| --- | --- |
+| Loading the runtime and running it are separate functions | The Rust CLI embeds a Python interpreter, calls the loading function to obtain the built runtime, and calls `run()` itself from Rust. Merging them breaks that entry point |
+| Exceptions from product code propagate | A Sentry SDK initialised by the application has to be able to capture them |
+| Every branch terminates in a sink, validated before anything is built | A dangling branch is nearly always a bug; catching it after startup is far more expensive |
+
+## Configuration
+
+| Invariant | Why |
+| --- | --- |
+| `override_config()` then `validate()`, called by the adapter at translation time, and nowhere else | Before it, the step holds what the application wrote; after it, what this deployment wants. A step valid as written may be invalid once overridden, so validation must follow the override |
+| No other component reads config keys off a step | One place to look when a value is wrong |
+| Configuration is plain data, re-applied per process | It has to survive being pickled into a multiprocessing worker. A live backend object would not |
+| Only non-semantic values are overridable | Functions, graph shape and types belong to the application; topics, brokers, consumer groups, batch sizes and parallelism belong to the deployment |
+
+## The adapter
+
+| Invariant | Why |
+| --- | --- |
+| The fused function of a closed chain is checked for picklability, even when multiprocessing is off | Otherwise a pipeline works in a single-process deployment and fails when someone enables a pool in production |
+| Parallelism is configured only on a step that starts a segment | Parallelism belongs to the segment; a step in the middle of a chain is not its own execution unit |
+| Nothing executes during the build phase | The graph traversal only accumulates; the runtime is assembled in `run()` |
+| Operator descriptors are kept, not consumed | The strategy chain is rebuilt from them on every rebalance |
+
+## The message model
+
+| Invariant | Why |
+| --- | --- |
+| A strategy checks the route first and forwards untouched on mismatch, without taking the GIL | Every message physically traverses every strategy; this is what makes that affordable |
+| A strategy forwards watermarks unless it buffers messages, in which case it holds them until the buffered work is out | A committed offset must be a processed offset |
+| A `PyWatermark` is never submitted back into a Python operator | Directional conversion invariant of `WatermarkMessage` |
+| A `PyWatermark` is never handed to the Kafka sink | Same |
+| A step handles every payload variant: supports what it can, panics loudly on what it cannot | Silently forwarding an unsupported payload corrupts data downstream instead of failing at the bug |
+| Payloads are not converted unless they are read | An opaque payload is a pointer move; a converted one is a copy plus a GIL acquisition |
+| Messages are immutable; replacing a payload produces a new message | Enforced by Rust, a convention in Python |
+
+## Extending the DSL
+
+Adding a primitive is a **breaking change to every adapter**, by construction: the adapter
+interface has one abstract method per primitive, and `RuntimeTranslator` dispatches on a
+closed `StepType` enum. That is a deliberate trade.
+
+What it obliges you to do:
+
+1. Every adapter must implement the new method. An adapter that will not support the
+ primitive raises `NotImplementedError` — that is the sanctioned way to decline, and is
+ how `flat_map` is handled today in the Rust adapter.
+2. `RuntimeTranslator.translate_step` must gain a branch, or `assert_never` fails the type
+ check.
+3. If the primitive is not a map, its adapter methods must close the chain (see above).
+4. Prefer a `ComplexStep` if the behaviour can be composed from existing primitives. It
+ costs no adapter changes and adapters may still override it natively.
+
+The procedure is in [Adding a step](./guides/adding-a-step.md).
diff --git a/sentry_streams/docs/architecture/decisions/0001-separate-application-from-deployment.md b/sentry_streams/docs/architecture/decisions/0001-separate-application-from-deployment.md
new file mode 100644
index 00000000..7c4d9ede
--- /dev/null
+++ b/sentry_streams/docs/architecture/decisions/0001-separate-application-from-deployment.md
@@ -0,0 +1,49 @@
+# 0001 — An application describes a dataflow; a separate config describes its deployment
+
+
+## Decision
+
+A streaming application is a pure, declarative description of a dataflow graph: a source, a
+series of transformations, one or more sinks. It contains no broker address, no consumer
+group, no topic, no parallelism setting and no reference to the runtime that will execute
+it. Everything the description leaves out lives in a separate deployment configuration
+file, which addresses each step by its name.
+
+The split is a hard line, not a convention: only non-semantic values are overridable
+(see [Configuration](../contracts.md#configuration)), and step names are the only coupling
+between the two artefacts.
+
+
+## Rationale
+
+The decision is meant to simplify the separation between infrastructure and application
+which is needed to build a streaming platform.
+
+Everything in the config file is specific to how the pipeline is operated:
+
+* Different by environment (Kafka hosts)
+
+* Scale configuration
+
+* Optimizations
+
+Separating these concepts allows the product engineers to focus on how the
+application works while platform engineers can ensure the application runs in
+a scalable and performant way.
+
+Enforcing a separation ensures also that platform engineers would have the same
+parameters to tune for all pipelines independently on what the application
+does.
+
+## Consequences
+
+- The same application file can be deployed against different brokers, topics, parallelism
+ settings and even different runtimes without being modified.
+- Renaming a step is a breaking change to its deployment configuration.
+- Anything a deployment needs to vary must be modelled as an overridable key on a step;
+ there is no escape hatch.
+
+## See also
+
+[The model](../README.md#the-model) · [Pipeline DSL and runner](../pipeline-dsl-and-runner.md) ·
+[Deployment configuration](../../reference/deployment-config.md)
diff --git a/sentry_streams/docs/architecture/decisions/0002-adapters-as-the-runtime-boundary.md b/sentry_streams/docs/architecture/decisions/0002-adapters-as-the-runtime-boundary.md
new file mode 100644
index 00000000..e1835ab6
--- /dev/null
+++ b/sentry_streams/docs/architecture/decisions/0002-adapters-as-the-runtime-boundary.md
@@ -0,0 +1,37 @@
+# 0002 — Runtimes are pluggable adapters with one method per primitive
+
+## Decision
+
+The execution runtime sits behind an adapter interface with one abstract method per
+primitive (`source`, `map`, `filter`, `reduce`, `sink`, `router`, `broadcast`, `flat_map`).
+The interface is generic in the type of the stream handle, so an adapter represents "a
+stream" however its runtime requires, and the runner never inspects it. Dispatch goes
+through `RuntimeTranslator` over a closed `StepType` enum ending in `assert_never`.
+
+Adding a primitive is therefore a **breaking change to every adapter**, by construction. An
+adapter that will not support a primitive declines explicitly with `NotImplementedError`,
+as the Rust adapter does for `flat_map`.
+
+
+## Rationale
+
+At the time of writing the team is not settled on which runtime should be used
+at scale. This abstraction layer allows the streaming team to experiment with
+different runtimes while not breaking existing applications.
+
+Having one method per primitive is meant to reduce the complexity of the
+adapter.
+
+## Consequences
+
+- A new primitive cannot be added silently: the type checker fails until every adapter and
+ the translator handle it.
+- Pressure toward [complex steps](./0004-complex-steps-as-sugar.md), which cost no adapter
+ changes.
+- The runner stays thin; runtime knowledge lives entirely in the adapter.
+
+## See also
+
+[Extending the DSL](../contracts.md#extending-the-dsl) ·
+[Adding a step](../guides/adding-a-step.md) ·
+[From pipeline to consumer](../pipeline-dsl-and-runner.md#from-pipeline-to-consumer)
diff --git a/sentry_streams/docs/architecture/decisions/0004-complex-steps-as-sugar.md b/sentry_streams/docs/architecture/decisions/0004-complex-steps-as-sugar.md
new file mode 100644
index 00000000..29532462
--- /dev/null
+++ b/sentry_streams/docs/architecture/decisions/0004-complex-steps-as-sugar.md
@@ -0,0 +1,46 @@
+# 0004 — Higher level steps are sugar that converts to primitives, with a native override hook
+
+## Decision
+
+Recurring combinations of primitives — `Parser`, `Serializer`, `BatchParser`,
+`ParquetSerializer`, `Reducer` — are `ComplexStep`s. Each implements `convert()`, returning
+plain simple steps, usually a `Map` with a function partial-applied. The translator calls
+`convert()` transparently, so by the time an adapter sees the pipeline a `Parser` is just a
+`Map`.
+
+An adapter that has a faster native implementation of a complex step declares it through
+`complex_step_override()` and receives the original step instead of its conversion.
+Adapters that do not care return an empty mapping.
+
+## Alternatives
+
+- Make every useful step a primitive, and require every adapter to implement it.
+- Keep them as plain helper functions in application code.
+
+## Rationale
+
+There are many common variants of basic steps that customers would have to implement
+on their own starting from basic building block.
+
+If we made a primitive for each of them we would have to implement them on each
+runtime in depth.
+
+If we asked product teams to implement each we would have a lot of cases where
+we reinvent the wheel.
+
+This allows us to provide composite steps quickly without the overhead of
+implementing them in each adapter.
+
+
+## Consequences
+
+- The set of operations an adapter must implement stays small.
+- Complex steps are the first thing to reach for when extending the DSL; only what cannot be
+ composed becomes a primitive.
+- A complex step's per-step configuration and metrics are attributed to the step it converts
+ into, unless an adapter overrides it natively.
+
+## See also
+
+[Complex steps](../pipeline-dsl-and-runner.md#complex-steps) ·
+[Decide what you are actually adding](../guides/adding-a-step.md#0-decide-what-you-are-actually-adding)
diff --git a/sentry_streams/docs/architecture/decisions/0005-statically-typed-dsl.md b/sentry_streams/docs/architecture/decisions/0005-statically-typed-dsl.md
new file mode 100644
index 00000000..f0ec6dc8
--- /dev/null
+++ b/sentry_streams/docs/architecture/decisions/0005-statically-typed-dsl.md
@@ -0,0 +1,35 @@
+# 0005 — The DSL is statically typed end to end
+
+## Decision
+
+`Pipeline[TOut]` carries the type of the messages flowing out of the last step added, and
+every primitive is generic in its input and output types. Chaining a step whose input type
+does not match the pipeline's current output type is a type error, caught by mypy in strict
+mode rather than at runtime.
+
+## Alternatives
+
+- Untyped payloads, with mismatches surfacing as runtime failures in the consumer.
+- Runtime schema checks at step boundaries instead of static types.
+
+## Rationale
+
+This is meant to reduce the chances of runtime errors though it has a cost:
+errors are not very easy to spot.
+
+We should still consider validating the types at startup or during tests.
+
+## Consequences
+
+- Structural mistakes are caught before deployment — for instance sinking parsed objects
+ into a Kafka sink with no serializer in between, which would otherwise panic at runtime
+ (see [Messages](../rust-arroyo/messages.md#conversion-is-limited-on-purpose)).
+- Every new step class must declare its generic parameters correctly for the chain to keep
+ type checking.
+- Rust `pyclass` types cannot be made generic to the type checker, which is one reason
+ Python code sees the `Message` wrappers rather than the Rust types.
+
+## See also
+
+[Principles](../pipeline-dsl-and-runner.md#principles) ·
+[Declare the step in the DSL](../guides/adding-a-step.md#2-declare-the-step-in-the-dsl)
diff --git a/sentry_streams/docs/architecture/decisions/0009-rust-runs-python-builds.md b/sentry_streams/docs/architecture/decisions/0009-rust-runs-python-builds.md
new file mode 100644
index 00000000..9962f4c4
--- /dev/null
+++ b/sentry_streams/docs/architecture/decisions/0009-rust-runs-python-builds.md
@@ -0,0 +1,70 @@
+# 0009 — Rust owns the runtime loop; Python builds the pipeline
+
+## Decision
+
+The production runtime is an Arroyo consumer written in Rust, exposed to Python as a PyO3
+extension module. The process starts as Python, imports the extension, and hands control
+over: **Python builds** a description of the pipeline and passes it to Rust operator by
+operator; **Rust runs**, owning the main loop, polling Kafka, submitting through the
+strategy chain and committing; **Rust calls back into Python** for application logic.
+
+The governing rule for what goes where is that **any primitive that can be executed
+entirely in Rust is implemented in Rust**, because crossing the boundary costs a GIL
+acquisition, serialises the step against every other Python interaction in the process, and
+may cost a copy.
+
+## Alternatives
+
+- A pure Python runtime on Python Arroyo (the pre-existing adapter, still in the tree).
+- A pure Rust process with no embedded interpreter, requiring application logic in Rust.
+- A Rust runtime that starts the Python interpreter and calls into Python
+ for Python logic.
+
+## Rationale
+
+This platform is architected to support Python applications, Rust applications
+and hybrid ones. Managing the main loop in Rust allows us to get the best
+performance specifically from Rust applications.
+
+This allows us to support these scenarios:
+
+* For pure Rust applications data would never move into Python memory and the
+ GIL is never used. This is not fully implemented though.
+
+* There can be two types of Python hybrid applications, one where each message
+ moves to Python memory and the other where the logic is written in Python
+ but the processing happens in Rust.
+
+The last scenario is particularly important, it can be implemented this way:
+
+- Use a declarative query language to define the application logic in Python
+ with DataFusion
+
+- The processing, though, happens fully in Rust and is done by DataFusion.
+
+Running the loop in Rust allows us not to move data to Python for DataFusion
+processing.
+
+Making the runner a Python process is done for expedience though. We built the
+DSL and the first runtime in Python, most applications are written in Python
+or are in Python code bases.
+
+Having a Python DSL is desirable to support those. We could still have a Rust
+runner to replace the Python one (which will be desirable), it is just not implemented
+yet.
+
+## Consequences
+
+- Kafka consumption, offset management, the strategy chain, backpressure, DLQ routing,
+ watermarks, route dispatch, batching, header filtering, broadcast and all sinks are Rust.
+- Application maps, filters and routing functions, parsing and serialisation, windowed
+ reduce and multiprocessing remain Python.
+- Every design question in this directory — the message model, routes, the delegate
+ interface — exists because of this boundary.
+- A middle ground is available: a function written in Rust, exposed as a Python callable,
+ releasing the GIL internally.
+
+## See also
+
+[Execution model](../rust-arroyo/README.md#execution-model) ·
+[What runs where](../rust-arroyo/README.md#what-runs-where)
diff --git a/sentry_streams/docs/architecture/decisions/0010-routes-for-branching.md b/sentry_streams/docs/architecture/decisions/0010-routes-for-branching.md
new file mode 100644
index 00000000..86e94747
--- /dev/null
+++ b/sentry_streams/docs/architecture/decisions/0010-routes-for-branching.md
@@ -0,0 +1,46 @@
+# 0010 — Branching is expressed as routes on a single linear strategy chain
+
+**Status:** accepted · **Area:** Rust runtime
+
+## Decision
+
+Arroyo pipelines are linear: a strategy has exactly one next strategy. The DSL has `Router`
+and `Broadcast`. Rather than building a tree of strategies, every message carries a
+`Route` — its source name plus the ordered waypoints of the branches it has been sent
+down — and every strategy is built with the route it belongs to and forwards untouched
+anything whose route differs. The chain stays physically linear and holds the steps of
+every branch one after another.
+
+Consequently the Rust adapter's stream handle **is** a `Route`: there is no stream object to
+hand around, so what the runner passes to an adapter method is "which branch", and what it
+gets back is the branch the output belongs to — one route per branch for router and
+broadcast.
+
+## Alternatives
+
+- Extend Arroyo with real branching strategies that own several next steps.
+- One consumer process per branch.
+
+## Rationale
+
+Keeping a single arroyo chain of steps simplified considerably commit management
+to guarantee at least once delivery.
+
+Having one single chain ensures that there is a single last step that receives
+messages from all branches and can evaluate which offsets have to be committed.
+
+## Consequences
+
+- A message physically traverses every strategy in the consumer, including branches it will
+ never enter. This is affordable only because the route check is cheap and GIL-free, which
+ makes "check the route first, forward on mismatch, take no GIL" the first rule for every
+ step author.
+- Commit correctness no longer follows from reaching the end of the chain, which is what
+ [0011](./0011-watermarks-drive-commits.md) exists to fix.
+- More than one consumer per process is not supported today, though the adapter's structure
+ anticipates it.
+
+## See also
+
+[Routes](../rust-arroyo/messages.md#routes) ·
+[The stream handle is a route](../rust-arroyo/adapter.md#the-stream-handle-is-a-route)
diff --git a/sentry_streams/docs/architecture/decisions/0011-watermarks-drive-commits.md b/sentry_streams/docs/architecture/decisions/0011-watermarks-drive-commits.md
new file mode 100644
index 00000000..badaf74f
--- /dev/null
+++ b/sentry_streams/docs/architecture/decisions/0011-watermarks-drive-commits.md
@@ -0,0 +1,50 @@
+# 0011 — Commits are driven by periodic watermarks, not by data messages
+
+## Decision
+
+A `WatermarkEmitter` at the head of the chain records the offsets of every message passing
+through it and, every few seconds (10 by default), injects a watermark control message
+carrying the accumulated committable. Watermarks travel down the pipeline like data
+messages and go through the same branching logic. At the end of the chain the commit policy
+ignores data messages entirely: it counts watermark copies, and commits the carried offsets
+only once it has seen that watermark arrive from every branch.
+
+Steps that hold messages back hold watermarks with them; every other step forwards them.
+
+## Alternatives
+
+- Arroyo's default policy: commit a message's offsets when it reaches the end of the chain.
+- Track every in-flight offset and its per-branch completion.
+
+## Rationale
+
+Watermarks make it much easier to calculate the offset to commit and when to commit
+in scenarios where the pipeline has branches.
+
+The last step of the pipeline receives all the watermarks propagated through all
+branches and is able to ensure that all branches reached a specific offset
+before committing.
+
+This also allows us to drop messages (via filters for example) and it allows each
+step to decide when it is done processing up a certain offsets just by holding
+the offsets instead of propagating them.
+
+## Consequences
+
+- With a broadcast in the pipeline, committing on the first copy to arrive would commit work
+ that has not happened; counting copies is what makes at-least-once hold under branching.
+- The bookkeeping is bounded: a handful of in-flight watermarks rather than a set of
+ in-flight offsets. Trackers that never receive all their copies are dropped after a
+ timeout.
+- Commit latency is bounded below by the watermark interval, not by message throughput.
+- Every step author must handle watermarks explicitly, and buffering steps must not let them
+ overtake the data.
+- A watermark crossing into Python becomes a `PyWatermark`, with directional invariants:
+ never submitted back into a Python operator, never handed to the Kafka sink.
+- Watermarks carry the newest data message timestamp, which is how end-to-end consumer
+ latency is measured at commit time.
+
+## See also
+
+[Watermarks](../rust-arroyo/messages.md#watermarks) ·
+[The message model](../contracts.md#the-message-model)
diff --git a/sentry_streams/docs/architecture/decisions/0013-embed-python-strategies-via-a-pull-delegate.md b/sentry_streams/docs/architecture/decisions/0013-embed-python-strategies-via-a-pull-delegate.md
new file mode 100644
index 00000000..fa7a6649
--- /dev/null
+++ b/sentry_streams/docs/architecture/decisions/0013-embed-python-strategies-via-a-pull-delegate.md
@@ -0,0 +1,44 @@
+# 0013 — Existing Python Arroyo strategies are embedded through a pull-based delegate, not reimplemented
+
+## Decision
+
+Where the thing that must run in Python is not a function but a *step* — with its own
+buffering, timing and output cadence — it runs behind `RuntimeOperator::PythonAdapter`, a
+Rust strategy that delegates to a Python object. Two such steps exist today, the
+multiprocessing step and the windowed reduce; both are pre-existing Python Arroyo
+strategies wrapped unmodified rather than rewritten in Rust.
+
+The Python side does not implement an Arroyo strategy. It implements a deliberately simpler
+interface, `RustOperatorDelegate`: `submit()` accepts and stores work, `poll()` processes
+and **returns** results, `flush()` drains. Rust forwards whatever `poll` returned to the next
+strategy, so the delegate never holds a reference to anything downstream. Delegates are
+created by a `RustOperatorFactory` rather than passed in directly.
+
+## Alternatives
+
+- Reimplement multiprocessing and windowed reduce natively in Rust.
+- Give the Python side a real Arroyo strategy interface with a handle to the next Rust
+ strategy.
+
+## Rationale
+
+This is meant to be a temporary solution to support more primitives quicker.
+Ideally this will not survive when more native rust strategies are implemented.
+
+## Consequences
+
+- Returning instead of pushing makes the interface inherently asynchronous: a delegate may
+ be 1:1, 1:N, N:1 or N:0, which a per-input return signature cannot express.
+- Exceptions out of `submit` are the delegate's control channel — `MessageRejected` becomes
+ backpressure, `InvalidMessage` routes to the DLQ, anything else panics.
+- The factory is where state too expensive to rebuild lives — notably a pre-initialised
+ process pool — because Arroyo tears down and rebuilds the chain on every rebalance.
+- Output messages are Arroyo "any messages", so a failure downstream of this operator cannot
+ be attributed to a specific offset.
+- Payloads must pickle to reach a worker process, and the Rust `pyclass` types cannot, which
+ is why Python code sees the `Message` wrappers that build the Rust object lazily.
+
+## See also
+
+[The Python operator](../rust-arroyo/python-operator.md) ·
+[Rebalance and shutdown](../guarantees.md#rebalance-and-shutdown)
diff --git a/sentry_streams/docs/architecture/decisions/0014-fuse-maps-into-segments.md b/sentry_streams/docs/architecture/decisions/0014-fuse-maps-into-segments.md
new file mode 100644
index 00000000..e7fab441
--- /dev/null
+++ b/sentry_streams/docs/architecture/decisions/0014-fuse-maps-into-segments.md
@@ -0,0 +1,49 @@
+# 0014 — Consecutive maps are fused into a chain; parallelism is a property of the segment
+
+**Status:** accepted · **Area:** Rust runtime
+
+## Decision
+
+The adapter does not translate steps one to one. Consecutive `Map` steps accumulate into a
+chain, kept per route so each branch accumulates independently. Any non-map step closes the
+open chain before it is added, keeping operator order faithful to pipeline order. A closed
+chain becomes a single operator: a native `RuntimeOperator::Map` holding the fused function,
+or — when the segment is configured with `multi_process` — a `PythonAdapter` wrapping
+Arroyo's multiprocessing strategy, whose pool executes the fused function.
+
+Parallelism is configured only on a step that starts a segment; configuring it mid-chain is
+rejected. The fused function is checked for picklability when the chain is finalised, **even
+when multiprocessing is off**.
+
+## Alternatives
+
+- One operator per step, with a GIL acquisition and a message hop each.
+- Check picklability only when a pool is actually configured.
+
+## Rationale
+
+This is meant to avoid going back and forth between Rust and python when the
+back and forth is not needed. Moving messages around requires taking and releasing
+the GIL, it is not the most efficient operation.
+
+It is also critical when we run steps across multiple processes as each parallel
+step would need to have its own shared memory and processing pool. Chaining
+stateless operations is a no brainer there.
+
+## Consequences
+
+- A run of maps costs one boundary crossing instead of one per step.
+- A non-map step in the middle of what looks like one logical segment splits it: inserting a
+ filter between two maps produces two chains, two operators and potentially two pools.
+ Segmentation follows the pipeline's shape as much as the configuration.
+- A pipeline cannot pass in a single-process deployment and then fail when someone enables a
+ pool in production; unpicklable functions are a build-time error naming the offending
+ steps.
+- "Segment" means two different things — a config-list entry (one Kubernetes workload) and a
+ fusion boundary within a process. Both are in the [glossary](../README.md#glossary).
+
+## See also
+
+[Chaining and starts_segment](../rust-arroyo/adapter.md#chaining-and-starts_segment) ·
+[The adapter](../contracts.md#the-adapter) ·
+[The multiprocess step](../rust-arroyo/python-operator.md#the-multiprocess-step)
diff --git a/sentry_streams/docs/architecture/decisions/0018-symmetric-buffered-metrics.md b/sentry_streams/docs/architecture/decisions/0018-symmetric-buffered-metrics.md
new file mode 100644
index 00000000..f4a480b9
--- /dev/null
+++ b/sentry_streams/docs/architecture/decisions/0018-symmetric-buffered-metrics.md
@@ -0,0 +1,45 @@
+# 0018 — Metrics are symmetric across the language boundary and buffered, not sampled
+
+## Decision
+
+Step-level metrics are produced by an equivalent buffering component on each side of the
+language boundary — `PipelineStats` in Python, thread-local state in Rust — with the same
+semantics, the same metric names, the same `step` tag and the same ten-second flush
+interval. The adapter wraps every application function it registers so entry, duration and
+exceptions are recorded around the call.
+
+Aggregation is by buffering, not sampling.
+
+## Alternatives
+
+- Emit each metric as it happens.
+- Sample in hot paths.
+- Let each side report in whatever shape suits it.
+
+## Rationale
+
+Buffering is critical for metrics. Initially we were producing metrics at each
+step, that ended up representing more than 90% of the CPU usage of the application.
+
+Moreover, as we process tens of thousands of messages per second per consumer
+and we process each of them in multiple steps, even the data structure to
+accumulate metrics is critical.
+
+PipelineStats is intentionally bare bone. Making it heavier can have disproportionate
+effect.
+
+## Consequences
+
+- A step reports identically whether it was fused into a native operator or executed as a
+ Python function, so one set of dashboards reads a pipeline end to end. A new Rust strategy
+ is expected to keep that property.
+- Rare events are kept rather than discarded, which sampling would not guarantee, and metrics
+ produced in tight loops do not cost a significant fraction of the work being measured.
+- Metrics are delayed by up to the flush interval.
+- Worker processes inherit nothing, so the pool initializer reconfigures metrics in each one
+ — which requires the config to be plain data rather than a live backend object
+ (an [invariant](../contracts.md#configuration)).
+
+## See also
+
+[Metrics](../rust-arroyo/metrics.md)
diff --git a/sentry_streams/docs/architecture/decisions/README.md b/sentry_streams/docs/architecture/decisions/README.md
new file mode 100644
index 00000000..37eb0ef2
--- /dev/null
+++ b/sentry_streams/docs/architecture/decisions/README.md
@@ -0,0 +1,43 @@
+# Decision log
+
+One page per architectural decision that shaped the platform. Each page states the
+decision, what it rules out, and the consequences we live with. The **rationale** section
+is where the reasoning behind the decision is recorded — why this option, at the time, over
+the alternatives.
+
+These are records, not proposals. A decision that is later reversed keeps its page and gains
+a superseded status, so the history of why the system looks like it does stays readable.
+
+The mechanics of each decision live in the architecture pages; a decision page links to them
+rather than repeating them.
+
+## The platform shape
+
+| # | Decision |
+| --- | --- |
+| [0001](./0001-separate-application-from-deployment.md) | An application describes a dataflow; a separate config describes its deployment |
+| [0002](./0002-adapters-as-the-runtime-boundary.md) | Runtimes are pluggable adapters with one method per primitive |
+| [0004](./0004-complex-steps-as-sugar.md) | Higher level steps are sugar that converts to primitives, with a native override hook |
+| [0005](./0005-statically-typed-dsl.md) | The DSL is statically typed end to end |
+
+## The Rust runtime
+
+| # | Decision |
+| --- | --- |
+| [0009](./0009-rust-runs-python-builds.md) | Rust owns the runtime loop; Python builds the pipeline |
+| [0010](./0010-routes-for-branching.md) | Branching is expressed as routes on a single linear strategy chain |
+| [0011](./0011-watermarks-drive-commits.md) | Commits are driven by periodic watermarks, not by data messages |
+| [0013](./0013-embed-python-strategies-via-a-pull-delegate.md) | Existing Python Arroyo strategies are embedded through a pull-based delegate, not reimplemented |
+| [0014](./0014-fuse-maps-into-segments.md) | Consecutive maps are fused into a chain; parallelism is a property of the segment |
+
+## Observability
+
+| # | Decision |
+| --- | --- |
+| [0018](./0018-symmetric-buffered-metrics.md) | Metrics are symmetric across the language boundary and buffered, not sampled |
+
+## Writing a new one
+
+Copy the shape of an existing page: a one-line status, **Decision**, **Alternatives**,
+**Rationale**, **Consequences**, **See also**. Keep the decision to a few sentences and put
+the reasoning in the rationale. Number sequentially; never renumber an existing page.
diff --git a/sentry_streams/docs/architecture/guarantees.md b/sentry_streams/docs/architecture/guarantees.md
new file mode 100644
index 00000000..1abb92ca
--- /dev/null
+++ b/sentry_streams/docs/architecture/guarantees.md
@@ -0,0 +1,69 @@
+# Guarantees and failure modes
+
+What the platform promises, and how it behaves when things go wrong. This is the page to
+read before an incident, not during one.
+
+## Delivery semantics
+
+**At-least-once.** Offsets are committed only after the work they represent has been done:
+the commit policy sits at the end of the strategy chain and commits the offsets a watermark
+carries once that watermark has arrived from every branch. A crash between processing and
+commit therefore replays. There is no deduplication and no transactional producer, so
+duplicates are visible to sinks.
+
+Consequences worth stating plainly:
+
+- A Kafka sink may produce the same record more than once. This is not actually
+ working today.
+- A GCS sink may write the same object more than once.
+- Application steps must tolerate being run twice on the same input.
+
+
+## Failure policy
+
+The runtime distinguishes three kinds of failure, deliberately:
+
+| Kind | Trigger | Behaviour |
+| --- | --- | --- |
+| **Invalid message** | A Python step raises `InvalidMessageError` (`pipeline/exception.py`); a delegate raises Arroyo's `InvalidMessage` | Offset and partition are read from the message and it is routed to the DLQ |
+| **Backpressure** | A delegate raises `MessageRejected` | The message is handed back to the caller to be retried; pressure propagates upstream |
+| **Bug** | Any other exception from a Python step; an unsupported payload variant reaching a step; a `PyAnyMessage` reaching the Kafka sink | Panic. Continuing would mean silently dropping or corrupting data |
+
+Two important qualifications:
+
+- **With no DLQ configured, an invalid message stops processing.** The consumer logs
+ `DLQ not configured, invalid messages will cause processing to stop` at startup. A DLQ is
+ configured per source via the `dlq` key.
+- **An invalid message on an `AnyMessage` panics** rather than going to the DLQ, because
+ there is no offset to attribute it to. Messages produced downstream of the Python operator
+ are any-messages, which is why a failure there cannot be DLQ'd.
+
+DLQ limits are currently Arroyo's defaults — no rate limit and no cap on buffered messages.
+That is a known gap, not a deliberate decision.
+
+## Backpressure
+
+Backpressure is Arroyo's: a strategy that cannot accept a message returns it, and the
+processor stops polling until it can make progress. The Python operator participates
+properly — `MessageRejected` from a delegate becomes Arroyo backpressure, and the drain of
+`poll` results stops at the first rejection, preserving order.
+
+There is no load shedding and no queue with a bounded drop policy. A pipeline that cannot
+keep up lags; it does not lose data.
+
+## Rebalance and shutdown
+
+- On **shutdown** (SIGINT/SIGTERM) the processor handle signals shutdown and the main loop
+ exits. `shutdown()` on the Python adapter is not implemented; shutdown is driven from Rust.
+- On **rebalance**, Arroyo tears down and rebuilds the strategy chain. This is why the
+ consumer keeps `RuntimeOperator` descriptors rather than consuming them, and why delegates
+ are created by a factory: state too expensive to rebuild — notably the multiprocessing
+ pool — lives in the factory and survives.
+- **Unverified:** behaviour when a multiprocessing worker dies mid-batch.
+
+## Health
+
+When `write_healthcheck` is enabled in the adapter config, the `HealthCheck` strategy
+touches a file on every poll, which the Kubernetes liveness probe reads. The signal means
+"the main loop is turning"; it does not mean the pipeline is making progress, and it will
+keep succeeding while the consumer is backpressured.
diff --git a/sentry_streams/docs/architecture/guides/adding-a-step.md b/sentry_streams/docs/architecture/guides/adding-a-step.md
new file mode 100644
index 00000000..616a1101
--- /dev/null
+++ b/sentry_streams/docs/architecture/guides/adding-a-step.md
@@ -0,0 +1,253 @@
+# Adding a step
+
+End-to-end procedure for adding a new step to the pipeline DSL and implementing it in the
+Rust runtime. Read [Messages → rules for step authors](../rust-arroyo/messages.md#rules-for-step-authors)
+first; everything below assumes them.
+
+## 0. Decide what you are actually adding
+
+Cheapest first. Only go down the list if the option above genuinely cannot express it.
+
+```mermaid
+flowchart TB
+ q1{"Can it be composed from existing primitives?"}
+ q2{"Does it need to run application (Python) code?"}
+ q3{"Does it buffer, or produce on its own cadence?"}
+ q4{"Does a Python Arroyo strategy already do it?"}
+
+ complex["ComplexStep convert() to simple steps. No adapter changes."]
+ native["Native Rust strategy + RuntimeOperator variant."]
+ pycall["Rust strategy that calls a Python callable (like Filter)."]
+ delegate["RustOperatorDelegate driven by PythonAdapter."]
+ wrap["ArroyoStrategyDelegate wrapping it unmodified."]
+
+ q1 -- yes --> complex
+ q1 -- no --> q2
+ q2 -- no --> native
+ q2 -- yes --> q3
+ q3 -- no --> pycall
+ q3 -- yes --> q4
+ q4 -- no --> delegate
+ q4 -- yes --> wrap
+```
+
+A `ComplexStep` costs nothing outside the DSL and is the right answer more often than it
+looks. A new **primitive** is the expensive case: it is a breaking change to every adapter
+(see [Contracts](../contracts.md#extending-the-dsl)). Everything below covers that case.
+
+## Touchpoints
+
+| # | What | Where |
+| --- | --- | --- |
+| 1 | New `StepType` member | `sentry_streams/pipeline/pipeline.py:58` |
+| 2 | Step dataclass | `sentry_streams/pipeline/pipeline.py` (next to `Map:427`, `Filter:444`) |
+| 3 | DSL entry point, if not reachable via `apply()` | `Pipeline.apply:128`, `.sink:136`, `.broadcast:142`, `.route:154` |
+| 4 | Abstract method on the adapter interface | `sentry_streams/adapters/stream_adapter.py:44` |
+| 5 | Dispatch branch | `RuntimeTranslator.translate_step`, `stream_adapter.py:173` |
+| 6 | Implementation in **every** adapter | `adapters/arroyo/rust_arroyo.py:288+`, `adapters/arroyo/adapter.py`, `dummy/dummy_adapter.py` |
+| 7 | New `RuntimeOperator` variant | `src/operators.rs:33` |
+| 8 | Arm in the operator dispatch | `src/operators.rs:125` (`build`) |
+| 9 | The strategy itself | new `src/.rs`, registered in `src/lib.rs` |
+| 10 | Tests | `tests/` (Python), `#[cfg(test)]` in your module (Rust) |
+
+You do **not** need to touch `sentry_streams/config.json`: `steps_config` allows additional
+properties, so new per-step config keys are legal without a schema change.
+
+## 1. Choose the base class
+
+| Base | Use for |
+| --- | --- |
+| `Transform[TIn, TOut]` | A 1:1 or 1:N transformation |
+| `Filter[TIn]` | Same type in and out, may drop |
+| `Sink[TIn]` | Terminates a branch |
+| `Source[TOut]` | Roots a pipeline |
+| `WithInput[TIn]` | Branching or control steps that fit none of the above |
+| `ComplexStep[TIn, TOut]` | Sugar over the above; implement `convert()` |
+
+## 2. Declare the step in the DSL
+
+```python
+@dataclass
+class Dedupe(Transform[TIn, TIn], Generic[TIn]):
+ """One-line docstring: this is the reference documentation for the step."""
+
+ window_size: int = 1000
+ step_type: StepType = StepType.DEDUPE
+
+ def override_config(self, loaded_config: Mapping[str, Any]) -> None:
+ if "window_size" in loaded_config:
+ self.window_size = loaded_config["window_size"]
+
+ def validate(self) -> None:
+ if self.window_size <= 0:
+ raise InvalidPipelineError(f"{self.name}: window_size must be positive")
+```
+
+Notes that save time:
+
+- It is a `@dataclass`, so fields with defaults must follow fields without them — including
+ the inherited ones. `step_type` always carries a default.
+- Generics are load-bearing: `Pipeline[TOut]` is re-typed by `apply()`, so declaring the wrong
+ parameters shows up as a chaining type error in application code, not here. Run
+ `make typecheck`, which includes `tests/test_mypy_integration.py`.
+- `override_config` reads only keys it understands; `validate()` runs after it, never before.
+- Put the per-primitive documentation in the docstring. The architecture docs deliberately do
+ not duplicate it.
+
+## 3. Wire the translator
+
+Add the `StepType` member, then a branch in `RuntimeTranslator.translate_step`. The dispatch
+ends in `assert_never(step_type)`, so mypy fails until you do — that is the intended
+enforcement, not an obstacle to work around.
+
+## 4. Implement it in every adapter
+
+Add the abstract method to `StreamAdapter`, then implement it everywhere. An adapter that will
+not support the step declines explicitly:
+
+```python
+def dedupe(self, step: Dedupe[Any], stream: Route) -> Route:
+ logger.info(f"Adding dedupe: {step.name} to pipeline")
+ raise NotImplementedError
+```
+
+In `RustArroyoAdapter`, follow the five-step pattern in
+[the adapter in depth](../rust-arroyo/adapter.md#what-each-method-does). The step that is easy
+to forget is **closing the open transformation chain**, which every non-map method must do
+before adding its own operator:
+
+```python
+def dedupe(self, step: Dedupe[Any], stream: Route) -> Route:
+ step_config = self.__get_step_config(step.name)
+ step.override_config(step_config)
+ step.validate()
+ self.__close_chain(stream) # required — see Contracts
+ self.get_consumer(stream.source).add_step(
+ RuntimeOperator.Dedupe(stream, step.name, step.window_size)
+ )
+ return stream
+```
+
+## 5. Implement the Rust strategy
+
+Add the `RuntimeOperator` variant (`src/operators.rs:33`) and its arm in `build`
+(`src/operators.rs:125`), then write the strategy. This skeleton applies all six step-author
+rules; `src/filter_step.rs` is the closest real example.
+
+```rust
+use crate::messages::RoutedValuePayload;
+use crate::pipeline_stats::get_stats;
+use crate::routes::{Route, RoutedValue};
+use crate::utils::traced_with_gil;
+use sentry_arroyo::processing::strategies::{
+ CommitRequest, ProcessingStrategy, StrategyError, SubmitError,
+};
+use sentry_arroyo::types::{InnerMessage, Message};
+use std::time::{Duration, Instant};
+
+pub struct MyStep {
+ pub next_step: Box>,
+ pub route: Route,
+ pub step_name: String,
+}
+
+impl ProcessingStrategy for MyStep {
+ fn poll(&mut self) -> Result