Streamform
Streamform is a workflow for building, testing, running, and upgrading stateful streaming applications from declarative SQL models. You write SQL, declare where the input comes from and how the result should be materialized, and Streamform compiles that into a continuous dataflow it can test in-process and run on Apache Flink.
It is one Rust binary, streamform, and a project is a directory of plain files. There is no JVM on your machine, no service to run, and nothing generated that you cannot read.
The one idea everything follows from
Take the most ordinary aggregation there is:
SELECT customer_id, SUM(amount) AS total_spend
FROM source('orders')
GROUP BY customer_id
Over a stream, its input is append-only: an order for customer 42 of 10, then another of 18. But its output is not a growing list. It is a relation that changes:
insert customer_id=42 total_spend=10
update customer_id=42 total_spend=28
A SQL statement says nothing about that. It does not say whether orders ends, that the result is keyed by customer_id, that computing it needs the runtime to remember every customer’s total, or what should happen to that memory when you change the query and deploy again. Today that knowledge lives in the person who knows Flink.
Streamform makes it part of the program. A model is the SQL plus three declarations the SQL cannot make:
| The question | Where it is answered | |
|---|---|---|
| Input semantics | Does the input end? A file does; a Kafka topic does not. | sources.yml |
| Computation | What transformation happens? | models/*.sql |
| Materialization | How do changing results appear: append, or upsert by a key? | streamform.yml |
From those, Streamform derives the rest: whether each step is bounded, what changelog it emits, what state it holds, and a stable identity for that state so a later version can be compared against it.
The workflow
streamform check validate the project against its compiled plan
streamform explain read a model as a streaming plan
streamform test run fixtures through the simulator, assert the changelog
streamform build generate the application as Flink SQL
streamform apply submit it through the Flink SQL Gateway
streamform inspect-plan see the identity and digests an upgrade compares
streamform plan compare the running plan with the desired one and classify every change
Every command compiles the project to the same plan and works from it, so what you test is what you deploy and what you inspect.
Where to start
- Install the binary and run the first project in about a minute.
- Read the concepts if you want to know what
explainis telling you. - The reference is exact about every command, file, and exit code.
- Why Streamform holds the founding documents, for the reasoning behind the design.
Status
Streamform is early and says so. Version 0.4 ships single- and multi-model applications with append and upsert changelogs, the deterministic simulator, the Flink backend, a stable plan identity for every node, and streamform plan, which classifies every change before it is deployed. Built since, and shipping as version 0.5: event time, with watermarks, late events that a fixture must acknowledge, and retention. Not yet: windows, joins, job lifecycle, dbt and Python frontends. The roadmap is ordered by dependency and evidence, not by date, and each capability lands whole (project format, simulator, Flink, plan diff, documentation) before it is called shipped. The project format is versioned and unknown fields are rejected, so an older build never silently misreads a newer project.
Install
Streamform is a single binary, free to use locally and in production with no account (what you may do with it). Nothing else is needed to check, explain, and test a project; Apache Flink is needed only when you build for it and apply.
Prebuilt binaries
Each release on the releases page ships tarballs for macOS (Apple silicon and Intel) and Linux x86_64, plus a checksums.txt. Verify the download against it, unpack, and put streamform on your PATH. On Apple silicon, for the latest release:
curl -fsSL https://github.com/glyf-data/streamform-releases/releases/latest/download/streamform-aarch64-apple-darwin.tar.gz | tar xz
sudo mv streamform /usr/local/bin/
streamform --version
Replace the target with x86_64-apple-darwin or x86_64-unknown-linux-gnu as needed; pin a version by replacing latest/download with download/<tag>, for example download/v0.5.0.
The example projects
The projects this site walks through are published beside the binary, together with a local Kafka and Flink environment for streamform apply:
curl -fsSL https://github.com/glyf-data/streamform-releases/releases/latest/download/streamform-examples.tar.gz | tar xz
cd streamform-examples
checksums.txt covers this archive too.
Homebrew, signatures, containers
Planned, and not available yet: brew install, signed release artifacts, a container image, and builds for Windows and aarch64 Linux. Until they exist, the tarball and its SHA-256 checksum are the install path.
What else you might want
| For | You need |
|---|---|
streamform check, explain, graph, test, inspect-plan | Nothing beyond the binary. The simulator runs in-process. |
streamform build --backend flink | Nothing beyond the binary; it writes SQL. |
streamform apply | A Flink cluster with the SQL Gateway and the Kafka SQL connector on its classpath. |
| The bundled local environment | Docker. flink-local/docker-compose.yml in the examples archive runs Kafka and Flink 2.0 for you. |
Working from the repository (maintainers)
The source repository is private. It uses mise to pin the toolchain and run tasks:
git clone https://github.com/glyf-data/streamform
cd streamform
mise run setup # toolchain, dependencies, git hooks
mise run cli -- --version # run the CLI from source
mise run check # everything CI runs
Without mise, install Rust 1.97 with rustfmt and clippy and use cargo directly.
What you may do with it
Streamform is three things, and each has one rule.
| Open source | The free binary | Hosted | |
|---|---|---|---|
| What it is | The file formats (streamform-spec), published with each release. Planned, and not existing yet: the testing library (streamform-test), the Flink runner image (streamform-flink-runner), the GitHub Action (streamform-action) | The streamform command: compiler, simulator, backends, plan diff, and everything that ships in the binary | Streamform Cloud: shared deployment and plan history, hosted analytics — optional, arriving later |
| Terms | Apache License 2.0, in each repository | Free to use locally and in production, with no account, no seat, node, application, or time limit, and no feature behind a login. Closed source. Terms of use. | Free tier first; terms published with it |
| Your data | — | Makes no network call except the commands whose purpose is to connect. Today that is apply, which talks to the Flink SQL Gateway you name and to nothing else; login, sync, and version --check are reserved for the hosted service and do not exist yet. Records, SQL, event payloads, and message bodies never leave your machine under any setting. | Receives metadata only — digests, versions, verdicts, counts, timings — and only after you opt in. streamform sync --dry-run prints exactly what would be sent. |
| What will not change | The formats stay open and versioned; a newer document version is refused, never guessed | The binary stays free for local and production use; a version you have downloaded keeps the terms it came with | The data rule above holds on every tier |
Why it is drawn this way
Everything public is a recipe, a schema, or a client of the binary — never a piece of it. The formats are published so that your tools, your CI, and the open testing library can read what Streamform writes without needing its source. The engine stays closed because it is the work; it stays free because a tool for developing streaming applications is only useful if every developer on the team can run it.
Where things are
- Downloads, checksums, the example projects, and the changelog (signatures are planned):
glyf-data/streamform-releases. Issues and questions go there too. - The formats:
glyf-data/streamform-spec, mirrored from the binary’s own source on every release, starting with v0.5.streamform schema <document>prints the same schemas from the binary you have. - The testing library, the runner image, and the Action: planned, with no repository yet.
Your first project
Every release publishes the example projects beside the binary. customer-metrics is the smallest project that shows the whole idea: one source, one model, one fixture. Everything below is real output from that project.
curl -fsSL https://github.com/glyf-data/streamform-releases/releases/latest/download/streamform-examples.tar.gz | tar xz
cd streamform-examples/customer-metrics
Three files
The source: a Kafka topic, so the input never ends. The schema names the columns and their types.
# sources.yml
sources:
orders:
connector: kafka
topic: orders
format: json
schema:
customer_id: int64
amount: decimal
The model: plain SQL. source('orders') reads the source declared above.
-- models/customer_metrics.sql
SELECT
customer_id,
SUM(amount) AS total_spend
FROM source('orders')
GROUP BY customer_id
The project file: the model’s result is keyed by customer_id and materialized as an upsert. Streamform checks that this agrees with what the SQL produces; a GROUP BY without upsert is an error, not a guess.
# streamform.yml
name: customer-metrics
version: 1
models:
customer_metrics:
materialized: upsert
key:
- customer_id
Check it
streamform check
customer-metrics (schema version 1)
models
customer_metrics upsert(customer_id) models/customer_metrics.sql
sources
orders kafka/json topic=orders 2 columns
fixtures
customer_metrics tests/customer_metrics.yml 2 given, 2 expect
ok: 1 model, 1 source, 1 fixture, 0 errors, 0 warnings
check compiles every model to its streaming plan and reports anything that does not plan: an unsupported construct, a ref() to a model that does not exist, a declared key that disagrees with the GROUP BY, a fixture that names a column the model does not output.
Read what the SQL means
streamform explain customer_metrics
customer_metrics
Source
name=orders
connector=kafka
time=processing
boundedness=Unbounded
changelog=Append
state=Stateless
↓
Aggregate
key=customer_id
aggregates=sum(amount)
boundedness=Unbounded
changelog=Upsert(customer_id)
state=Keyed
fingerprint=aggregate key=[customer_id] accumulators=[sum(amount): decimal(38,9)] retention=unbounded
digest=4aead023aa01
↓
Project
columns=customer_id, sum(amount) AS total_spend
boundedness=Unbounded
changelog=Upsert(customer_id)
state=Stateless
↓
Sink
model=customer_metrics
materialized=upsert
boundedness=Unbounded
changelog=Upsert(customer_id)
state=Stateless
Read it top to bottom: the source never ends, grouping it needs keyed state (one accumulator per customer, a decimal(38,9) running total), and from the aggregate onward the result is an updating stream keyed by customer_id. The concepts section explains each line.
Test it
The fixture gives the model two events and states the exact changelog it must emit.
# tests/customer_metrics.yml
model: customer_metrics
given:
- customer_id: 42
amount: 10
- customer_id: 42
amount: 18
expect:
- op: insert
customer_id: 42
total_spend: 10
- op: update
customer_id: 42
total_spend: 28
streamform test
customer_metrics
PASS
2 events processed
2 changelog records emitted
ok: 1 fixture passed
That ran through the in-process simulator: no Kafka, no cluster, no wall clock, and the same result every time. Change the second expectation to total_spend: 27 and the failure shows expected and actual side by side, with the first difference named:
# expected actual
1 insert customer_id=42 total_spend=10 insert customer_id=42 total_spend=10
2 update customer_id=42 total_spend=27 update customer_id=42 total_spend=28
^ total_spend: expected 27, got 28
Next
- Project layout for what each file may contain.
- Testing with fixtures for multi-model fixtures and the exact comparison rules.
- Running on Apache Flink to build and apply this same project.
Project layout
A project is a directory. Every file in it is a contract Streamform keeps: the formats are versioned, and a change to any of them is a documented change.
streamform.yml project name, format version, per-model materialization, targets
sources.yml the external inputs and their schemas
models/*.sql one SQL model per file; the file stem is the model name
tests/*.yml fixtures, one per file
build/ output of `streamform build`; not source, keep it out of version control
streamform.yml
Names the project, pins the format version (1), and configures models and targets. A model exists because models/<name>.sql exists; the entry under models: only configures it. A model with no entry is append with no key.
name: order-pipeline
version: 1
models:
customer_metrics:
materialized: upsert
key: [customer_id]
big_customers:
materialized: upsert
key: [customer_id]
sink:
connector: kafka
topic: big-customers
format: json
targets:
local:
backend: flink
flink:
gateway: http://localhost:8083
kafka:
bootstrap_servers: kafka:9092
materializedisappend(the default) orupsert, and must agree with what the SQL produces: aGROUP BYproduces an updating result and requiresupsertwith akeyequal to the grouping columns.sinkadds an external destination for the model’s changelog. Every model ends in a Sink in its plan whether or not it declares one; the Sink is what fixtures assert on and whatref()in another model reads. Asink:block is what makes it leave the application.targetsname the places an application is built for and applied to. See Running on Apache Flink.
sources.yml
Each source has a connector, a location, a format, and a schema. The connector decides whether the input ends: kafka is unbounded, file is bounded. That is derived, never guessed from the SQL.
sources:
orders:
connector: kafka
topic: orders
format: json
schema:
customer_id: int64
amount: decimal(12,2)
Types: int32, int64, float64, decimal (which is decimal(38,9)), decimal(precision,scale), string, boolean, timestamp. The declared scale of a decimal is what state keeps: a SUM over decimal(10,2) accumulates decimal(38,2).
models/*.sql
One SELECT per file. Read a source with source('<name>') and another model with ref('<model>'); a plain table name is not valid, because the dependency graph would not know about it.
The supported surface today: column references, literals, comparison and arithmetic, AND/OR/NOT, IS [NOT] NULL, CAST, WHERE, GROUP BY over columns, HAVING, and sum, count, min, max. Anything else fails at check naming the construct, rather than reaching a backend that would run it differently.
tests/*.yml
A fixture names a model, gives it events, and states the exact changelog it must emit. Testing with fixtures walks through it; the project format reference is exact about every field and every comparison rule.
The streaming plan
Everything Streamform does goes through one representation: the streaming plan. check validates against it, explain prints it, test executes it in the simulator, build generates Flink SQL from it, and inspect-plan describes its identity. Because every command reads the same plan, what you test is what you deploy.
From SQL to a plan
A model’s SQL is parsed and planned by DataFusion, then immediately lowered into Streamform’s own intermediate representation and DataFusion is not consulted again. The IR is deliberately small: five operators and a seven-variant expression tree covering exactly what Streamform supports today. SQL that does not fit fails at check, naming the construct, instead of reaching a backend that would run it differently from the simulator.
| Operator | What it does | Holds state |
|---|---|---|
| Source | Reads a declared source | no |
| Filter | Keeps rows matching a predicate (WHERE, HAVING) | no |
| Project | Computes output columns | no |
| Aggregate | Groups rows and maintains one accumulator set per key (GROUP BY) | yes |
| Sink | Ends every model: its materialized relation, with an optional external destination | no |
What every node knows
Each node carries three properties that are first-class in Streamform rather than details of an engine:
- Boundedness: whether the node’s output ever ends. Derived from the source’s connector and carried through. See Boundedness.
- Changelog mode: how the output changes over time:
Append,Upsert(key), orRetract. See Changelogs. - State requirement:
Stateless, orKeyedwith a fingerprint of exactly what is stored. See State.
explain prints them under each operator:
Aggregate
key=customer_id
aggregates=sum(amount)
boundedness=Unbounded
changelog=Upsert(customer_id)
state=Keyed
fingerprint=aggregate key=[customer_id] accumulators=[sum(amount): decimal(38,9)] retention=unbounded
digest=4aead023aa01
One plan per application
A project with several models compiles to one plan. Models appear in dependency order, each as a chain ending in its Sink, and ref('m') is an edge from a model’s first operator to m’s Sink. A changelog mode crosses that edge: a model reading an upsert model receives updates, not fresh facts, and its own operators are planned accordingly. See Applications of many models.
Nothing engine-specific inside
The plan contains nothing that belongs to Flink, and the Flink backend is generated from it alone. That is a design rule with a test behind it: if a feature ever needs an engine concept inside the plan, the plan is what gets revisited, not the backend. It is also what makes the simulator an honest oracle for Flink, and what will let a native runtime consume the same plan later.
Boundedness
Does the input end? A file does. A Kafka topic does not. The same SQL means different things over each, so Streamform records the answer on every node of the plan and refuses SQL whose meaning depends on an ending that never comes.
Where it comes from
Boundedness is derived from the source’s connector, never guessed from the SQL:
| Connector | Boundedness |
|---|---|
kafka | Unbounded: the stream never ends |
file | Bounded: the input is finite |
Every operator downstream carries its input’s boundedness forward. explain shows it on each node as boundedness=Unbounded or boundedness=Bounded.
What it changes
A global ORDER BY needs the whole input to produce a final ordering. Over a bounded file that is a sort; over an unbounded topic it can never finish, so Streamform refuses it at check:
error: could not compile model `sorted`: model `sorted`: ORDER BY cannot produce a final
ordering over an unbounded input; remove it, or sort downstream of a bounded materialization
The same model over a file source is accepted. This is the first and simplest of the semantics a SQL statement alone cannot express, and it is why sources are declared rather than inferred.
Later
Windows, watermarks, and event time (roadmap phases 7 and 8) are the next set of meanings that depend on time and on the input never ending. They arrive as first-class plan properties in the same way.
Changelogs
Streaming is about change. A model’s output is not a table you read once; it is a sequence of changes to a relation, and what kind of changes it emits is a property of the model that Streamform tracks on every node.
Three modes
| Mode | Meaning | Where it comes from |
|---|---|---|
Append | Every row is a new, independent fact. | Sources; filters and projections over append input. |
Upsert(key) | Rows are identified by a key; a later row for a key supersedes the earlier one. | GROUP BY (keyed by the grouping columns); a declared upsert model with a key. |
Retract | Changes are emitted as retractions followed by additions. | Not produced by any operator yet; reserved. |
A GROUP BY is where the mode changes. Its input is facts; its output is a relation that changes as facts arrive, keyed by the grouping columns. A global aggregate (no GROUP BY) is Upsert with an empty key: one row that keeps changing.
Three operations
The changelog a model emits is a sequence of records, each with an operation:
insert: a row appears.update: a row identified by its key takes new values. Fixtures assert the after-image, the values after the change.delete: a row leaves.
The first order for customer 42 is an insert of their total; the second is an update. A filter over an updating model turns a value crossing the threshold into an insert when it enters and a delete when it leaves:
-- big_customers.sql
SELECT customer_id, total_spend
FROM ref('customer_metrics')
WHERE total_spend > 100
insert customer_id=42 total_spend=130
update customer_id=42 total_spend=140
delete customer_id=42 total_spend=140 -- a refund took the total under 100
Declared and derived must agree
streamform.yml declares how a model is materialized; the plan derives what the SQL produces; check requires the two to agree.
- A
GROUP BYmodel must declarematerialized: upsertwithkeyequal to its grouping columns. A missing key, a different key, or a key on a global aggregate is an error that names the disagreement. - An
appendmodel over an updating input is an error: the updates have nowhere to go. upsertover an append stream is allowed when the model declares akey; the key then defines row identity for the sink.
This is deliberate. The materialization is the contract a downstream consumer relies on, and a contract that the compiler can check is worth more than one that is documented.
Where the changelog goes
The Sink at the end of every model carries the model’s changelog. In the simulator, fixtures assert it record by record. On Flink, an upsert model with a Kafka sink becomes an upsert-kafka table with a primary key, so an update is a new value for the key and a delete is a tombstone. Another model reading through ref() receives the same changelog inside the job, and is planned with that mode as its input.
Not yet
Aggregating over an updating input (a GROUP BY over a ref() to an upsert model) is refused today: folding an update into an accumulator means retracting the value it replaces, which is the Retract mode nothing produces yet. It arrives with the roadmap’s retraction work.
State
GROUP BY customer_id asks the runtime to remember something: every customer’s running values. That memory is part of the program, not an implementation detail, and Streamform describes it precisely on the node that holds it.
What a node stores
An Aggregate node keeps, per key, one accumulator per aggregate call. explain shows the description:
state=Keyed
fingerprint=aggregate key=[customer_id] accumulators=[sum(amount): decimal(38,9)] retention=unbounded
- Key: the grouping columns, in order. State is partitioned by it.
- Accumulators: one per call, named by Streamform’s rendering of the call (
sum(amount),count(*)) and typed by what is stored. - Retention: how long a key’s state is kept after its last event, in event time.
unboundedunless the model declaresretention(see Time); expiry is silent, and the key’s next event starts over.
Every other operator today is Stateless.
Accumulator types are Streamform’s rule
The stored type is derived from the argument’s declared type, by a rule Streamform owns:
| Call | Argument type | Accumulator |
|---|---|---|
count(...), count(*) | anything | int64 |
sum(x) | int32, int64 | int64 |
sum(x) | decimal(p,s) | decimal(38,s): the widest precision at the declared scale, because a running total outgrows its inputs |
min(x), max(x) | any | the argument’s type |
The simulator stores exactly these types and a test asserts it, so state inference and execution cannot drift. The scale is yours: declare amount: decimal(12,2) and the total is kept to two places.
Why the description is so careful
Two versions of an application must be able to compare what they store, to decide whether the running version’s state can be kept. That comparison is only honest if the description depends on what you wrote and on Streamform’s rules alone, never on how the SQL planner happened to name or widen a column in this release. So the fingerprint uses Streamform’s own names for accumulators and its own vocabulary for types, and its digest is a SHA-256 of that text. See Identity.
The simulator’s state is the same state
streamform test executes the plan with an in-memory keyed store shaped exactly like the fingerprint says: a map from key to accumulators of the recorded types. Nothing is approximated, which is what lets the simulator be the oracle the Flink backend is checked against.
Identity
A streaming application is persistent. Change the SQL and deploy again, and the new version either reuses the state the old one built or rebuilds it from the beginning of the input. Deciding which needs two things from every version of the plan: a way to say “this is the same node”, and a way to say “it stores the same thing”. Streamform gives every node both.
Node ids
Every node has a stable id: <model>::<kind>::<ordinal>.
customer_metrics::source::0
customer_metrics::aggregate::0
customer_metrics::project::0
customer_metrics::sink::0
The ordinal counts operators of that kind within the model. Identity is positional on purpose, and independent of what the node does: change the aggregate’s key or its accumulators and it is still customer_metrics::aggregate::0, the same node, changed. That is what lets an upgrade say “the aggregate changed its key” rather than “one node removed, one node added, rebuild”.
What moves an id: renaming the model, or inserting a second operator of the same kind before an existing one. What does not: whitespace, column order, a changed key, an added WHERE, a changed accumulator.
Digests
Every stateful node has a state digest: the SHA-256 of its fingerprint text (aggregate key=[customer_id] accumulators=[sum(amount): decimal(38,9)] retention=unbounded). Every node has a schema digest: the SHA-256 of its output columns by name and canonical type, in order. Text output shows the first twelve characters; the full 64 are what is compared.
Both are computed from Streamform’s own renderings, never from a dependency’s output, so upgrading the compiler’s dependencies or the Rust toolchain cannot move a digest. A digest moves exactly when meaning moves. The digest of the customer_metrics example, 4aead023aa01…, is pinned by a test; changing it is a deliberate act.
| Edit | id | state digest |
|---|---|---|
| Reorder projection columns, reformat the SQL | same | same |
SUM(amount) becomes COUNT(*) | same | different |
GROUP BY customer_id becomes GROUP BY customer_id, country | same | different |
| Rename the model | different | same text, but under a new id |
The manifest
streamform inspect-plan --json prints all of it for the whole application as a versioned JSON document, the plan manifest, and streamform build writes the same document to build/plan.json. A reader refuses a manifest from another format version or with fields it does not know. See Preparing for upgrades for the workflow and the plan identity reference for the document.
What this is for
streamform plan compares the running manifest with the desired one and classifies every change before anything is deployed: SAFE, COMPATIBLE, STATE MIGRATION REQUIRED, BACKFILL REQUIRED, STATE INCOMPATIBLE. Identity is what lets it say “the aggregate in customer_metrics changed its key” instead of “one node removed, one added”, and it exists early on purpose: identity that is bolted on after users have running state cannot be changed without invalidating that state. See Upgrading a running application and the plan diff reference.
Time
A batch query has no clock: it reads everything, computes, and finishes. A streaming application runs for months, and when an event happened is different from when it arrived. Streamform names both and lets you choose which one a model runs on.
Processing time is the default
A source without event_time runs on processing time: events are processed in arrival order, and a timestamp column is just a column. explain says so on the Source: time=processing. Nothing is late, nothing expires, and a fixture’s given is simply the order of delivery.
Event time gives the plan a clock
Declare event_time: ordered_at on a source and three things follow:
- The time attribute. Every model downstream carries
ordered_atas its time attribute for as long as it keeps the column — through a filter, through a projection that selects it (under its alias), and not through an aggregate, which groups it away.explainprintstime attribute=…on each node that has one. - The watermark. The source’s watermark is the largest event time seen so far minus the declared
watermarkdelay, and it never moves backwards. It is the plan’s statement of “everything before this instant has arrived”.watermark: 0ssays events are in order;watermark: 5mallows five minutes of disorder. - Lateness. An event behind the watermark when it is delivered is late.
late_events: drop(the default) discards and counts it;keepdelivers it as if on time. Dropping is a choice a fixture must acknowledge, so no test loses an event silently.
Retention lets state end
retention: 30d on a model says a key’s state is dropped once the watermark reaches its last event plus thirty days — silently, with no retraction, so the next event for the key starts its aggregate over and an upsert sink replaces the stale row rather than deleting it. Retention is enforced in event time, which is why it needs an event-time source upstream. It fills the retention= of the state fingerprint, so declaring it changes the model’s state digest, and streamform plan classifies the change as STATE MIGRATION REQUIRED.
The simulator is the definition
One watermark per source; late detection at the moment of delivery; expiry after every advancement; no wall clock anywhere. Fixtures can advance the watermark explicitly with - watermark: <timestamp> and must list dropped events under late:. Flink gets the same semantics where Flink SQL can express them — a WATERMARK FOR clause, a late-dropping view, a STATE_TTL hint — and build warns where it can only approximate: retention on Flink is wall-clock time, which is wrong under replay.
The walkthrough with three runnable fixtures is Event time.
Testing with fixtures
A fixture delivers events to a model and asserts the exact changelog the model emits. streamform test runs every fixture under tests/ through the deterministic simulator, in-process, with no services.
Anatomy of a fixture
model: big_customers
given:
- customer_id: 42
amount: 60
- customer_id: 42
amount: 70
- customer_id: 42
amount: 10
- customer_id: 42
amount: -50
expect:
- op: insert
customer_id: 42
total_spend: 130
- op: update
customer_id: 42
total_spend: 140
- op: delete
customer_id: 42
total_spend: 140
model names the model under test.
given lists input events, delivered in order to the source at the top of the model’s chain. big_customers reads ref('customer_metrics'), which reads ref('clean_orders'), which reads source('orders'); the events are orders rows, and the changelog asserted is big_customers’ own, after every model in between has run. Columns the source declares but an event omits are null; a column the source does not declare is an error.
expect is the changelog, record by record, in order. The comparison is exact: the same number of records, and record i must have the op and every output column value of expect[i]. An update is asserted by its after-image. Each record names exactly the model’s output columns, and streamform check reports a misspelled or missing column before anything runs.
Values are typed against the schema: an integer 28 matches a decimal column holding 28.000000000; 0.1234 against decimal(38,2) is an error, not a rounding; null matches only null.
What the fixture above proves
Customer 42’s total climbs to 130 (they enter the filter: insert), then 140 (update), then a refund takes it to 90 and they leave (delete). That third record is the reason changelogs are first-class: a WHERE over an updating input must emit a delete when a row stops matching, and a consumer of the big-customers topic depends on receiving it.
Running
streamform test # every fixture
streamform test --model big_customers
Each fixture prints its model, PASS or FAIL, the events processed, and the records emitted. A failure adds a side-by-side listing with the first difference named:
customer_metrics
FAIL
--> tests/customer_metrics.yml
2 events processed
2 changelog records emitted
# expected actual
1 insert customer_id=42 total_spend=10 insert customer_id=42 total_spend=10
2 update customer_id=42 total_spend=27 update customer_id=42 total_spend=28
^ total_spend: expected 27, got 28
failed: 1 of 1 fixture
Exit codes: 0 when every fixture passes, 1 when any fails, 2 when the project cannot be tested at all (it does not load, check would report errors, or --model names a model that does not exist).
Determinism
The simulator is single-threaded and has no wall clock. The same fixture and the same plan produce the same changelog on every run; the repository’s own test suite runs the acceptance fixture a hundred times and asserts identical output. That is what makes a fixture a specification rather than a flaky observation, and what lets the simulator serve as the oracle the Flink backend is compared against.
Practical notes
- A project with no
tests/directory passes honestly:ok: no fixtures under tests/. - Fixtures are checked before they run.
streamform checkcompiles every model and validates every expectation’s shape against the model’s output columns, so a typo fails fast with the column list in the message. - The
- event: {…}form for an item ingivenreads well beside- watermark: …items, which advance an event-time source’s watermark explicitly. See below.
Late events and time
On a source that declares event_time, a fixture also controls the clock. Timestamps are RFC 3339 instants (2026-08-30T10:00:00Z); - watermark: <timestamp> items advance the watermark between events; and an event that arrives behind the watermark is late — dropped and counted under the default late_events: drop, and then it must be listed under late:, exactly and in order, or the fixture fails:
model: customer_metrics
given:
- event: { customer_id: 42, amount: 10, ordered_at: "2026-08-30T10:00:00Z" }
- event: { customer_id: 42, amount: 20, ordered_at: "2026-08-30T10:10:00Z" }
- event: { customer_id: 42, amount: 5, ordered_at: "2026-08-30T10:01:00Z" } # behind the 10:05 watermark
expect:
- { op: insert, customer_id: 42, total_spend: 10 }
- { op: update, customer_id: 42, total_spend: 30 }
late:
- { customer_id: 42, amount: 5, ordered_at: "2026-08-30T10:01:00Z" }
2 events processed
2 changelog records emitted
1 late event dropped
Leave the late: section out and the failure names the event and the two ways to resolve it — acknowledge it, or set late_events: keep on the source. A model with retention expires a key’s state once the watermark passes its last event plus the retention, silently; a fixture shows it by the key’s next event arriving as an insert rather than an update. The three canonical fixtures — a late event, the watermark advancing, state expiring — are walked through in Event time.
Applications of many models
A model reads another model with ref('<model>'), and Streamform plans the whole graph as one application. order-pipeline in the examples archive is the reference: three models in a chain, ending in a sink.
The example
-- models/clean_orders.sql
SELECT customer_id, amount
FROM source('orders')
WHERE customer_id IS NOT NULL
-- models/customer_metrics.sql
SELECT customer_id, SUM(amount) AS total_spend
FROM ref('clean_orders')
GROUP BY customer_id
-- models/big_customers.sql
SELECT customer_id, total_spend
FROM ref('customer_metrics')
WHERE total_spend > 100
# streamform.yml (models section)
models:
customer_metrics:
materialized: upsert
key: [customer_id]
big_customers:
materialized: upsert
key: [customer_id]
sink:
connector: kafka
topic: big-customers
format: json
streamform graph prints the application:
order-pipeline
orders source kafka topic=orders
└─ clean_orders append
└─ customer_metrics upsert(customer_id)
└─ big_customers upsert(customer_id) → kafka topic=big-customers
--format dot emits Graphviz for dot -Tsvg.
What crosses a ref()
ref('m') binds to m’s Sink: its output columns and its changelog mode. That mode is what makes the last model interesting. customer_metrics emits updates, so big_customers is a filter over an updating input, and its changelog has inserts when a customer crosses 100, updates while they stay above it, and a delete when a refund takes them under. Its fixture asserts exactly that; see Testing with fixtures.
streamform explain big_customers shows the upstream model on the first operator:
big_customers
Filter
from=customer_metrics
predicate=(total_spend > 100)
boundedness=Unbounded
changelog=Upsert(customer_id)
state=Stateless
explain with no model prints every model in dependency order.
What check catches
The dependency graph is validated before anything is planned, and each problem names the models involved:
- a
ref()to a model that does not exist (with a suggestion when a close name exists); - a model that references itself, or a cycle, listed in full;
- a name used by both a source and a model, since the two share one namespace;
- a plain table name in
FROM, which would bypass the graph.
Fixtures across models
A fixture for big_customers gives events to orders, the source at the top of its chain, and asserts big_customers’ changelog after clean_orders and customer_metrics have run. You test the model you care about; Streamform runs its upstream closure.
What a chain becomes on Flink
One plan for the graph means one job for the graph. Each model is a CREATE TEMPORARY VIEW built on the view of the model it reads, so the chain runs inside a single Flink job with no topic between models; only a declared sink: writes out. A model with neither a sink nor a reader is planned but nothing on Flink runs it, and build warns.
Limits today
A model reads exactly one relation: no joins or unions yet. GROUP BY over an updating model is refused, because folding an update into an accumulator needs retractions the runtime does not produce yet. Both are on the roadmap.
Running on Apache Flink
The Flink backend runs the same plan the simulator runs. It generates Flink SQL, which you can read and diff, and submits it through Flink’s SQL Gateway. Streamform never links a Flink client and generates no Java or Python.
Declare a target
A target is a place the application is built for and applied to: a SQL Gateway and the Kafka it reads and writes.
# streamform.yml
targets:
local:
backend: flink
flink:
gateway: http://localhost:8083
kafka:
bootstrap_servers: kafka:9092
startup: earliest
bootstrap_servers is as Flink sees it (inside the bundled Compose network that is kafka:9092); gateway is as your machine sees it. build and apply take --target NAME, defaulting when the project declares exactly one.
Build
streamform build --backend flink # writes build/flink/application.sql and build/plan.json
streamform build --backend flink --stdout # prints the SQL, writes nothing
For examples/customer-metrics:
CREATE TABLE `orders` (
`amount` DECIMAL(38, 9),
`customer_id` BIGINT
) WITH (
'connector' = 'kafka',
'topic' = 'orders',
'properties.bootstrap.servers' = 'kafka:9092',
'format' = 'json',
'scan.startup.mode' = 'earliest-offset'
);
CREATE TEMPORARY VIEW `customer_metrics` AS
SELECT `customer_id`, `sum(amount)` AS `total_spend` FROM (SELECT `customer_id`, SUM(`amount`) AS `sum(amount)` FROM `orders` GROUP BY `customer_id`) AS t3;
CREATE TABLE `sink_customer_metrics` (
`customer_id` BIGINT,
`total_spend` DECIMAL(38, 9),
PRIMARY KEY (`customer_id`) NOT ENFORCED
) WITH (
'connector' = 'upsert-kafka',
'topic' = 'customer-metrics',
'properties.bootstrap.servers' = 'kafka:9092',
'key.format' = 'json',
'value.format' = 'json',
'key.json.encode.decimal-as-plain-number' = 'true',
'value.json.encode.decimal-as-plain-number' = 'true'
);
EXECUTE STATEMENT SET
BEGIN
INSERT INTO `sink_customer_metrics` SELECT * FROM `customer_metrics`;
END;
The rules are few and each follows from the plan: every source is a CREATE TABLE; every model is a CREATE TEMPORARY VIEW built from its operator chain; a model with a sink: block also gets a sink_<model> table and an INSERT INTO in the single statement set. An upsert model uses upsert-kafka with a primary key on its declared key, so a delete in the changelog is a tombstone on the topic. A file sink cannot carry an upsert changelog and is refused with exit code 1. The Flink backend reference lists the type mapping and every rule.
Apply
streamform apply --target local
applying customer-metrics to target `local` (http://localhost:8083)
ok: source orders
ok: model customer_metrics
ok: sink customer_metrics
ok: statement set → job 9d1f2c4b7a3e5f6081c2d3e4f5a6b7c8
ok: applied
apply builds, opens a gateway session, runs each statement in order, prints the job id, and closes the session. If Flink rejects a statement, apply prints the statement it was on and Flink’s reason without the stack trace, stops with exit 1, and no job is started. An unreachable gateway is exit 2.
apply submits and reports; it does not remember the job. Stopping, upgrading, and reconciling a running application are not built yet; Preparing for upgrades covers what exists today toward that.
A local cluster in one command
The examples archive ships flink-local/docker-compose.yml: one Kafka broker (KRaft) and a Flink 2.0.2 cluster (JobManager, TaskManager, SQL Gateway) with the Kafka SQL connector on its classpath. From the streamform-examples directory:
docker compose -f flink-local/docker-compose.yml up -d --build --wait
docker exec streamform-kafka /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic orders
streamform apply --project customer-metrics
printf '%s\n' '{"customer_id": 42, "amount": 10}' '{"customer_id": 42, "amount": 18}' \
| docker exec -i streamform-kafka /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server localhost:9092 --topic orders
docker exec streamform-kafka /opt/kafka/bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic customer-metrics --from-beginning --property print.key=true
docker compose -f flink-local/docker-compose.yml down -v
The consumer prints two records for key {"customer_id":42} with totals 10 and 28, written at the column’s scale as 10.000000000 and 28.000000000: the simulator’s changelog, on Kafka. JSON sinks always write a decimal as a plain number ('json.encode.decimal-as-plain-number'); left to its default, Flink would write 10 as 1E+1. The Flink UI is at http://localhost:8081.
Two things the real cluster teaches
- Create source topics before
apply. Flink’s Kafka source lists a topic’s partitions at startup and fails if there are none; do not rely on the first produced event to create the topic. - A filter over an aggregate runs in Flink’s retract mode. An update to a row that stays inside the filter reaches an
upsert-kafkatopic as a tombstone followed by the new value for the same key, where the simulator’s changelog has oneupdate. The keyed state a consumer holds is identical once both have arrived; a consumer that reacts to every record will see the key vanish and reappear.
The differential test
In the source repository, mise run integration brings the environment up and runs the differential test: for each example fixture whose model declares a sink, it applies the project, produces the fixture’s given events to the source topic, reads the sink topic, and compares the (key, value | tombstone) sequence with what the simulator’s changelog implies. Both backends run the same plan, so they must agree, and big_customers proves the delete arrives as a tombstone. The tests are #[ignore] so the ordinary check never needs Docker; CI runs them on demand and weekly.
Upgrading a running application
A running aggregation remembers every customer’s total. Change the model and deploy, and the question is whether that memory still means anything. streamform plan answers it before anything is deployed: it compares the plan that is running with the plan the project now describes and classifies every change. This guide walks through the workflow on the example project.
Apply, and let Streamform remember what ran
streamform apply
plan: nothing is recorded as applied to target `local`; the application starts fresh
applying customer-metrics to target `local` (http://localhost:8083)
ok: source orders
ok: model customer_metrics
ok: sink customer_metrics
ok: statement set → job 9d1f2c4b7a3e5f6081c2d3e4f5a6b7c8
ok: applied
ok: applied plan recorded in .streamform/applied/local.json
The last line is new. apply now writes the plan it applied, with the time, the Streamform version, and the job id, to .streamform/applied/<target>.json. That file is the running plan as far as streamform plan is concerned. Commit it or keep it in your CI cache, as you prefer; it holds nothing secret.
Without a Flink to apply to, streamform build --backend flink writes build/plan.json, and plan --against build/plan.json compares with that instead. Everything below works the same way.
Ask before you change anything
Edit nothing and run:
streamform plan
plan: customer-metrics against target `local`
running: applied 2026-08-27T10:12:03Z by streamform 0.4.0, job 9d1f2c4b7a3e, application digest 7b0d9542cf02
desired: application digest 7b0d9542cf02
customer_metrics SAFE
verdict: SAFE
Exit code 0. Now the edits. Add an accumulator:
SELECT customer_id, SUM(amount) AS total_spend, COUNT(*) AS orders
FROM source('orders')
GROUP BY customer_id
customer_metrics changed
customer_metrics::aggregate::0 Aggregate changed
definition: key=[customer_id] aggregates=[sum(amount)] → key=[customer_id] aggregates=[sum(amount), count(*)]
schema: [customer_id: int64, sum(amount): decimal(38,9)] → [customer_id: int64, sum(amount): decimal(38,9), count(*): int64]
accumulators: [sum(amount): decimal(38,9)] → [sum(amount): decimal(38,9), count(*): int64]
state digest: 4aead023aa01 → a5771fc8a5b2
BACKFILL REQUIRED: accumulator `count(*)` added; it starts empty for every existing key, replay history to fill it
customer_metrics::project::0 Project changed
definition: customer_id, sum(amount) AS total_spend → customer_id, sum(amount) AS total_spend, count(*) AS orders
schema: [customer_id: int64, total_spend: decimal(38,9)] → [customer_id: int64, total_spend: decimal(38,9), orders: int64]
COMPATIBLE: stateless; no stored value is affected
customer_metrics::sink::0 Sink changed
schema: [customer_id: int64, total_spend: decimal(38,9)] → [customer_id: int64, total_spend: decimal(38,9), orders: int64]
COMPATIBLE: readers of `customer-metrics` see the new columns; rows already written keep the old shape
BACKFILL REQUIRED: replay history through the new plan
verdict: BACKFILL REQUIRED (1 model to backfill)
note: the flink backend does not carry state across a changed query; on this target the action is a rebuild, and the classification tells you what a rebuild costs
Exit code 1. The aggregate is the same node (customer_metrics::aggregate::0, same key) with one more accumulator. Its existing state could be loaded, but every customer’s count(*) would start at zero while their sum(amount) carries history: not what a fresh run would produce. Hence BACKFILL REQUIRED. The projection and the sink change too, but they hold no state, so they are COMPATIBLE, and the sink’s message says what readers will see.
Now change the grouping key instead (GROUP BY customer_id, amount, and key: [customer_id, amount] in streamform.yml):
customer_metrics changed
customer_metrics::aggregate::0 Aggregate changed
definition: key=[customer_id] aggregates=[sum(amount)] → key=[customer_id, amount] aggregates=[sum(amount)]
schema: [customer_id: int64, sum(amount): decimal(38,9)] → [customer_id: int64, amount: decimal(38,9), sum(amount): decimal(38,9)]
changelog: upsert(customer_id) → upsert(customer_id, amount)
key: [customer_id] → [customer_id, amount]
state digest: 4aead023aa01 → d982cde38013
STATE INCOMPATIBLE: the grouping key changed; existing state cannot be reused
...
customer_metrics::sink::0 Sink changed
...
key: [customer_id] → [customer_id, amount]
BACKFILL REQUIRED: rows already written to `customer-metrics` are keyed the old way; rebuild the sink
STATE INCOMPATIBLE: rebuild required
verdict: STATE INCOMPATIBLE (1 model to rebuild)
Same node, different partitioning: nothing maps old keys to new, so the state cannot be reused at all, and the sink’s compacted topic is keyed the old way as well.
The change that hides upstream
The diff is most useful where the edit is far from the state. In examples/order-pipeline, customer_metrics aggregates clean_orders, a filter. Tighten the filter (WHERE customer_id IS NOT NULL AND amount > 0) and ask:
clean_orders changed
clean_orders::filter::0 Filter changed
definition: (customer_id IS NOT NULL) → ((customer_id IS NOT NULL) AND (amount > 0))
COMPATIBLE: stateless; no stored value is affected
COMPATIBLE: redeploy; existing state continues
customer_metrics changed
customer_metrics::aggregate::0 Aggregate changed
BACKFILL REQUIRED: input definition changed upstream at `clean_orders::filter::0`; existing state was accumulated under the previous definition
BACKFILL REQUIRED: replay history through the new plan
big_customers SAFE
verdict: BACKFILL REQUIRED (1 model to backfill, 1 model changed)
Nothing about the aggregate itself changed; its state digest is the same. But every total in it was accumulated from orders the new filter would have dropped, so continuing from that state gives numbers a fresh run would never produce. The diff names the upstream node. big_customers, a stateless filter over customer_metrics, is SAFE: it holds nothing.
This rule is conservative on purpose. Streamform does not yet know which columns an aggregate reads, so adding an unused column upstream triggers it too. The plan diff reference has the full rule table.
Renames and restructures
Ids are positional (customer_metrics::aggregate::0), so renaming the model to customer_totals makes the diff report one model removed and another added, with its aggregate BACKFILL REQUIRED. Tell it what happened:
models:
customer_totals:
materialized: upsert
key: [customer_id]
renamed_from: customer_metrics
customer_totals SAFE
verdict: SAFE
The same works for one node, when a restructure shifts an ordinal: nodes: { aggregate.0: { was: customer_metrics::aggregate::1 } }. Hints only choose which running node to compare with; they do not change ids. Once the rename is applied, the record holds the new names and plan warns that the hint is unused: delete it.
What apply does with the verdict
streamform apply runs the same comparison first and prints the verdict; when the application cannot continue from its state it prints the whole diff, then applies anyway, saying so. It cannot stop or restore a Flink job yet, so today every apply starts a new job with empty state whatever the verdict, and refusing would only teach people to bypass the refusal. Use plan’s exit code as the gate in CI:
streamform plan --target production || exit 1
What the verdict does not do
The classification is about the plan: what existing state would need in order to continue. Flink SQL restores state only for an unchanged query, so on Flink every change above SAFE is a rebuild in practice, and plan says so in its last line. Nothing performs a migration or a backfill yet. The plan diff reference is exact about the rules, the record, and the hints; the plan identity reference is exact about what is compared.
Commands
Every command takes --project DIR (default: the current directory) and compiles the whole project to one streaming plan before doing its job. Exit codes follow one convention: 0 success, 1 the command ran but the project or the fixtures are not valid, 2 the command itself could not run (the project does not load, a model is unknown, a service is unreachable).
streamform check Load and validate a Streamform project
streamform explain Print the streaming plan for a model
streamform test Run every fixture in tests/ through the simulator and assert its changelog
streamform graph Print the model dependency graph: sources, models, and sinks
streamform build Generate the application for a backend, without submitting it
streamform apply Build the application and submit it to a target's Flink SQL Gateway
streamform inspect-plan Print the stable identity of the plan: node ids, digests, schemas, and contracts
streamform plan Compare the running plan with the desired plan and classify every change
streamform check
streamform check [--project DIR] [--strict] [--json]
Loads the project, validates every file, builds the dependency graph, compiles every model, validates targets, and checks every fixture’s expectations against its model’s output columns. Prints a summary of models (with their materialization and file), sources, and fixtures, then each diagnostic with its file and a help: line, then a count.
--json prints the check report instead — check_version: 1, loaded, passed, strict, the project, the three tables as records, and diagnostics with severity, path, message, help — and nothing else on stdout. When the project cannot be loaded the report is still printed, with loaded: false and the reason as its one diagnostic, before the exit 2. streamform schema check-report prints its schema.
order-pipeline (schema version 1)
models
big_customers upsert(customer_id) models/big_customers.sql
clean_orders append models/clean_orders.sql
customer_metrics upsert(customer_id) models/customer_metrics.sql
sources
orders kafka/json topic=orders 2 columns
fixtures
big_customers tests/big_customers.yml 4 given, 3 expect
clean_orders tests/clean_orders.yml 3 given, 2 expect
customer_metrics tests/customer_metrics.yml 3 given, 2 expect
ok: 3 models, 1 source, 3 fixtures, 0 errors, 0 warnings
A diagnostic:
error: model `downstream` reads ref('other_model'), but there is no models/other_model.sql
--> models/downstream.sql
help: ref() names another model in this project
--strict treats warnings as errors. Exit 0 with no errors, 1 with errors, 2 if the project cannot be loaded at all.
streamform explain
streamform explain [MODEL] [--project DIR] [--json]
Prints one model’s plan as its operator chain, or every model in dependency order when no model is given. Each operator shows its definition, then boundedness, changelog, and state; a keyed operator adds its fingerprint and the first twelve characters of its state digest. A model reading another model shows from=<model> on its first operator.
--json prints the model’s slice of the plan manifest (manifest_version, model, nodes), the same document inspect-plan --json prints for the whole application. Exit 2 for an unknown model or a model that does not compile, with the compile error.
streamform test
streamform test [--project DIR] [--model MODEL] [--json]
Runs every fixture, or only those for --model, through the simulator and asserts the exact changelog. Prints per fixture: the model, PASS or FAIL, events processed, records emitted; a failure adds the fixture path and a side-by-side listing of expected and actual with the first difference named. A project with no fixtures prints ok: no fixtures under tests/ and exits 0.
Exit 0 when every fixture passes, 1 when any fails, 2 when the project cannot be tested (it does not load, check would report errors, or --model names a model that does not exist). See Testing with fixtures.
--json prints the test report instead: report_version: 1, passed, and one entry per fixture with model, path, passed, events_processed, changes_emitted, and rows — one per position, each with expected, actual, and problem (null when they match; the authority on whether the row passed). Values on both sides are rendered after typing against the model’s output schema, so a matching row looks matching: decimals are strings ("28", exact, trailing zeros trimmed as in fixtures), timestamps RFC 3339, integers and booleans themselves. An update’s actual carries before, the row as it was. A fixture the simulator refuses has error and no rows. With errors check would report, no document is printed and the exit is 2. streamform schema test-report prints its schema.
streamform graph
streamform graph [--project DIR] [--format text|dot] [--json]
Prints the application as a tree: each source, the models that read it, each model’s materialization, and declared sinks. --format dot emits Graphviz. --json prints the graph document: graph_version: 1, sources, models (each with materialized, key, sink, and what it reads), edges from source:<name> or model:<name> to model:<name>, the topological order, and cycles. A reference cycle is an error for the text and DOT forms (exit 2); the JSON form prints the document with the cycle in cycles and exits 1. streamform schema graph prints its schema.
order-pipeline
orders source kafka topic=orders
└─ clean_orders append
└─ customer_metrics upsert(customer_id)
└─ big_customers upsert(customer_id) → kafka topic=big-customers
streamform build
streamform build --backend flink [--target NAME] [--project DIR] [--stdout]
Compiles the project (refusing anything check would fail), picks the target (required when the project declares more than one), validates the application for the backend, and writes build/flink/application.sql and build/plan.json. --stdout prints the SQL and writes nothing. A model with neither a sink nor a reader produces a warning. Exit 1 when the backend cannot represent the application (the capability error is printed), 2 when the project does not pass check or the target is unknown. See Running on Apache Flink.
streamform apply
streamform apply [--target NAME] [--project DIR]
Builds the application and submits each statement in order to the target’s Flink SQL Gateway: opens a session, runs each CREATE TABLE and CREATE TEMPORARY VIEW, runs the EXECUTE STATEMENT SET, prints the job id, closes the session.
plan: nothing is recorded as applied to target `local`; the application starts fresh
applying customer-metrics to target `local` (http://localhost:8083)
ok: source orders
ok: model customer_metrics
ok: sink customer_metrics
ok: statement set → job 9d1f2c4b7a3e5f6081c2d3e4f5a6b7c8
ok: applied
ok: applied plan recorded in .streamform/applied/local.json
Before applying, apply compares the desired plan with the target’s applied record and prints the verdict (plan: SAFE against target local, applied 2026-08-27T10:12:03Z); when the verdict is above COMPATIBLE it prints the whole diff and applies anyway, saying so, because it cannot stop or restore a job yet. After the last statement is accepted it writes the applied record, .streamform/applied/<target>.json, atomically; a rejected apply leaves the previous record.
If no model declares a sink there is nothing to run: nothing to apply: no model declares a sink. If Flink rejects a statement, apply prints the statement and Flink’s reason, stops, and exits 1; no job is started and the record is unchanged. An unreachable gateway is exit 2; so is a record that could not be written after a successful apply, and the message says the apply succeeded. The gateway is plain HTTP.
streamform inspect-plan
streamform inspect-plan [MODEL] [--project DIR] [--json]
Prints the stable identity of one model, or of every model in dependency order followed by the application digest. Per model: materialization, declared key, output node. Per node: id, operator, canonical definition, output schema with its digest, boundedness and changelog contract, and state (stateless, or keyed with key, accumulators, retention, and state digest). --json prints the plan manifest: the whole application, or one model’s slice when a model is given. Exit 2 for an unknown model or a model that does not compile. See Upgrading a running application and the plan identity reference.
streamform plan
streamform plan [--target NAME] [--against FILE] [--project DIR] [--json]
Compares the running plan with the desired plan and classifies every change. The desired plan is compiled from the project, through the same checks as build. The running plan is the target’s applied record (.streamform/applied/<target>.json, written by apply) or, with --against, any file holding a plan manifest or an applied record, such as a build/plan.json from an earlier build. --target defaults when the project declares exactly one.
plan: customer-metrics against target `local`
running: applied 2026-08-27T10:12:03Z by streamform 0.4.0, job 9d1f2c4b7a3e, application digest 7b0d9542cf02
desired: application digest a5771fc8a5b2
customer_metrics changed
customer_metrics::aggregate::0 Aggregate changed
accumulators: [sum(amount): decimal(38,9)] → [sum(amount): decimal(38,9), count(*): int64]
state digest: 4aead023aa01 → a5771fc8a5b2
BACKFILL REQUIRED: accumulator `count(*)` added; it starts empty for every existing key, replay history to fill it
BACKFILL REQUIRED: replay history through the new plan
verdict: BACKFILL REQUIRED (1 model to backfill)
note: the flink backend does not carry state across a changed query; on this target the action is a rebuild, and the classification tells you what a rebuild costs
Per model, each changed node with the fields that differ (old → new), its classification (SAFE, COMPATIBLE, STATE MIGRATION REQUIRED, BACKFILL REQUIRED, STATE INCOMPATIBLE) and the reason; then the model’s verdict, the application’s verdict with counts, and a note when the target’s backend is Flink and the verdict is above SAFE. With nothing recorded and no --against, every model is new and the verdict is SAFE. --json prints the diff document (diff_version: 1). A hint in streamform.yml that names no running node is printed as a warning.
Exit 0 when the verdict is SAFE or COMPATIBLE, so the application can continue from its existing state; 1 for any other verdict; 2 when the project does not compile or the running side cannot be read (a record or manifest from another format version, unknown fields, a file that is neither). See Upgrading a running application and the plan diff reference.
streamform schema
streamform schema --list
streamform schema <document> [--version N]
Prints the JSON Schema (draft 2020-12) of a document this build reads or writes. The schema is generated from the same types the binary uses, so it cannot disagree with what check accepts or what plan --json prints. --list names every document with the version this build speaks and where the document appears:
project 1 streamform.yml
sources 1 sources.yml
fixture 1 tests/*.yml
plan-manifest 1 inspect-plan --json, explain --json, build/plan.json
applied-record 1 .streamform/applied/<target>.json
diff 1 plan --json
check-report 1 check --json
test-report 1 test --json
graph 1 graph --json
--version N is refused, with exit 2, when this build speaks another version of the document; use it in scripts to assert the version you were written against. Every schema carries an $id of the form urn:streamform:spec:<document>:v<N>.
Editors that use the YAML language server (VS Code with the YAML extension, Zed, Neovim) complete and validate a project file from the schema when the file names it:
# yaml-language-server: $schema=.streamform/schema/project.json
version: 1
name: customer-metrics
Write the file once with streamform schema project > .streamform/schema/project.json.
Streamform — Project format
An Streamform project is a directory. Every file in it is a user-facing contract: this document is the reference for what each file may contain, and any change to these formats is a documented change.
streamform.yml project name, schema version, per-model materialization
sources.yml external inputs and their schemas (optional)
models/*.sql one SQL model per file; the file stem is the model name
tests/*.yml fixtures, one per file
streamform.yml
name: customer-metrics
version: 1
models:
customer_metrics:
materialized: upsert
key:
- customer_id
| Field | Required | Meaning |
|---|---|---|
name | yes | The project name, for display. |
version | yes | The project file format version. See Schema versioning. |
models.<name>.materialized | no | append (default) or upsert. Must agree with what the model’s SQL produces: GROUP BY produces an updating result and requires upsert. |
models.<name>.key | for upsert | The columns that identify a row. Must equal the model’s GROUP BY columns when it aggregates; a global aggregate (no GROUP BY) declares no key; an upsert over a non-aggregating model needs a key, which then defines row identity for the sink. streamform check reports a key that disagrees with the plan. |
models.<name>.sink | no | Where the model’s changelog is written: connector (kafka or file), topic or path, and format (json or csv). Every model ends in a Sink in its plan, whether or not it declares one: the Sink is the model’s materialized relation, what fixtures assert on and what ref() in another model reads. A sink: block adds the external destination. |
models.<name>.renamed_from | no | The name this model had in the running plan. streamform plan then compares each of its nodes with the running node of the same kind and ordinal instead of reporting a removal and an addition. A hint for the diff only; ids do not change. check rejects the model’s own name or a model that still exists. See the plan diff. |
models.<name>.nodes.<kind>.<ordinal>.was | no | The id one of this model’s nodes had in the running plan, for a restructure that shifted an ordinal: nodes: { aggregate.0: { was: customer_metrics::aggregate::1 } }. Takes precedence over renamed_from for that node. check rejects a reference that is not <kind>.<ordinal>, a was that is not a node id, two hints on one running node, and a node the model’s plan does not have. |
models.<name>.retention | no | How long a key’s state is kept after its last event, in event time, as a duration (30d, 90m); unbounded when absent. Needs an event-time source upstream. Expiry is silent: the next event for the key starts over. See Event time. |
| targets.<name>.backend | for build/apply | The execution backend: flink. |
| targets.<name>.flink.gateway | for flink | The SQL Gateway REST endpoint streamform apply submits to, e.g. http://localhost:8083. |
| targets.<name>.kafka.bootstrap_servers | when Kafka is used | The bootstrap.servers list the generated source and sink tables connect to, as seen from Flink (inside the Compose network that is kafka:9092). |
| targets.<name>.kafka.startup | no | earliest (default) or latest: where a Kafka source starts reading when the application first runs. |
A model exists because a models/<name>.sql file exists. An entry under models: only configures it; a model without an entry is append with no key.
A target is a place the application is built for and applied to: a Flink SQL Gateway and the Kafka it reads and writes. streamform build --backend flink and streamform apply take --target NAME, defaulting when the project declares exactly one. See flink-backend.md.
targets:
local:
backend: flink
flink:
gateway: http://localhost:8083
kafka:
bootstrap_servers: kafka:9092
sources.yml
sources:
orders:
connector: kafka
topic: orders
format: json
schema:
customer_id: int64
amount: decimal
| Field | Required | Meaning |
|---|---|---|
connector | yes | kafka (an unbounded stream) or file (a bounded input). Boundedness is derived from this, never guessed from the SQL. |
topic / path | yes | The Kafka topic or the file path, per connector. |
format | no | json (default) or csv. |
schema | yes | Column names and types. Column types: int32, int64, float64, decimal, decimal(precision,scale), string, boolean, timestamp. |
event_time | no | The timestamp column that carries event time. Absent: the source runs on processing time. See Event time. |
watermark | no | Bounded out-of-orderness, a duration (0s, 500ms, 5m, 1h, 7d); default 0s. Needs event_time. |
late_events | no | drop (default) or keep: what happens to an event behind the watermark. Needs event_time. |
A bare decimal is decimal(38,9). decimal(precision,scale) declares the total number of digits (1 to 38) and the number of fractional digits (0 to the precision); the declared scale is what a model’s state keeps (a SUM over decimal(10,2) accumulates decimal(38,2)), so choose it deliberately.
models/*.sql
One SELECT statement per file, reading a source with source('<name>') or another model of the same project with ref('<model>'). The supported surface is: column references, literals, comparison and arithmetic operators, AND/OR/NOT, IS [NOT] NULL, CAST, WHERE, GROUP BY over columns, HAVING, and the aggregates sum, count, min, max. Anything else fails at streamform check naming the construct.
ref('m') reads model m’s output: its columns, and its changelog mode. A model reading an upsert model receives updates, and a WHERE over them emits an insert when a row crosses into the condition and a delete when it leaves; streamform explain shows the upstream model as from=m on the first operator. A model may read one relation only (no joins or unions yet), and GROUP BY over an updating model is refused, because folding an update into an aggregate needs retractions that nothing supports yet.
Models form a dependency graph. streamform check reports a ref() to a model that does not exist, a reference cycle, and a name used by both a source and a model (the two share one namespace). Sources are read with source(), models with ref(); a plain table name is not valid.
tests/*.yml
A fixture delivers events to one model and asserts the exact changelog the model emits.
model: customer_metrics
given:
- customer_id: 42
amount: 10
- customer_id: 42
amount: 18
expect:
- op: insert
customer_id: 42
total_spend: 10
- op: update
customer_id: 42
total_spend: 28
model
The model under test. It must exist.
given
The input events, delivered in order to the source at the top of the model’s chain: for a model that reads ref('clean_orders'), which reads source('orders'), events are orders rows and the changelog asserted is the model’s, after every model in between. Each event is a map of column name to value. Columns the source declares but the event omits are null; a column the source does not declare is an error.
Values are typed against the source schema: an integer or a decimal number fits a decimal column (0.1234 against decimal(38,2) is an error, not a rounding), a whole number fits an integer column, a quoted string fits a string column, true/false fits a boolean column, and a timestamp column takes an RFC 3339 instant in UTC (2026-08-30T10:00:00Z, optionally with a fraction) or an integer of milliseconds since the epoch. A bare time of day is refused.
An item may also be written as - event: {…}, which reads well beside the other kind of item: - watermark: <timestamp> advances the source’s watermark to that instant explicitly (never backwards). Watermark items mean something only on an event-time source; see Event time.
expect
The changelog the model must emit, record by record, in order. The comparison is exact: the sequences must have the same length, and record i must have the op and every column value of expect[i].
Each record has:
op—insert,update, ordelete. Anupdateis asserted by its after-image: the values after the change.- one entry per output column of the model, no more and no fewer.
streamform checkreports an expectation that misspells or omits a column before anything runs.
Values compare by type: an integer 28 matches a decimal 28.000000000; null matches only null.
late
On an event-time source with late_events: drop, the events that must be dropped as late, in delivery order, exactly — a dropped event that is not listed fails the fixture, and a listed event that was delivered fails it too. Each entry is the event as written in given. Absent when nothing is dropped. streamform test prints N late events dropped whenever N is not zero.
Running
streamform graph prints the application as a tree (sources, the models that read them, and declared sinks); streamform graph --format dot emits Graphviz for dot -Tsvg. streamform test runs every fixture; streamform test --model NAME runs the fixtures for one model. Each fixture prints its model name, PASS or FAIL, the number of events processed, and the number of changelog records emitted. A failure adds the fixture path and a side-by-side listing of expected and actual records, with the first difference named beneath each mismatching row:
customer_metrics
FAIL
--> tests/customer_metrics.yml
2 events processed
2 changelog records emitted
# expected actual
1 insert customer_id=42 total_spend=10 insert customer_id=42 total_spend=10
2 update customer_id=42 total_spend=27 update customer_id=42 total_spend=28
^ total_spend: expected 27, got 28
failed: 1 of 1 fixture
Exit codes: 0 when every fixture passes, 1 when any fails, 2 when the project cannot be tested at all — it does not load, streamform check would report errors, or --model names a model that does not exist.
The simulator is deterministic: the same fixture and the same model produce the same changelog on every run, with no external services.
Build output
streamform build --backend flink writes the generated application to build/flink/application.sql and the plan manifest to build/plan.json: a versioned JSON document recording every node’s id, definition, schema, changelog contract, and state digest (see plan identity). --stdout prints the application instead and writes nothing. build/ is output, not source; keep it out of version control.
Applied records
streamform apply writes the plan it applied to .streamform/applied/<target>.json, with the time, the Streamform version, the backend, and the job ids. streamform plan reads it back as the running plan; --against FILE compares with any manifest or record instead. The record is what apply last did on this machine, not a query of the target; whether to commit .streamform/ is the team’s choice. See the plan diff.
Schema versioning
version is required and a build of Streamform accepts exactly one value; this build accepts 1. The version is bumped only when a project that was valid before becomes invalid or means something different afterwards. Adding an optional field does not bump it.
Unknown fields are rejected everywhere. An older build reading a project written for a newer version fails on the first field it does not know, rather than silently ignoring a materialization or a key. There is no migration tooling until a second version exists.
Schemas
Every file described here has a JSON Schema this build generates from its own types: streamform schema project, streamform schema sources, streamform schema fixture (see the command reference); the documents the binary writes — check --json, test --json, graph --json, plan --json, the plan manifest, the applied record — have one too. Name it at the top of a file for editor completion and validation — # yaml-language-server: $schema=.streamform/schema/project.json — and the editor enforces the same rules check does, including unknown fields.
Streamform — Flink backend
The Flink backend runs the same Streaming IR the simulator runs, on Apache Flink. It generates Flink SQL; it does not generate Java or Python, and Streamform never links a Flink client. streamform build --backend flink writes the script and streamform apply submits it through Flink’s SQL Gateway.
What streamform build --backend flink generates
For examples/customer-metrics with the local target:
CREATE TABLE `orders` (
`amount` DECIMAL(38, 9),
`customer_id` BIGINT
) WITH (
'connector' = 'kafka',
'topic' = 'orders',
'properties.bootstrap.servers' = 'kafka:9092',
'format' = 'json',
'scan.startup.mode' = 'earliest-offset'
);
CREATE TEMPORARY VIEW `customer_metrics` AS
SELECT `customer_id`, `sum(amount)` AS `total_spend` FROM (SELECT `customer_id`, SUM(`amount`) AS `sum(amount)` FROM `orders` GROUP BY `customer_id`) AS t3;
CREATE TABLE `sink_customer_metrics` (
`customer_id` BIGINT,
`total_spend` DECIMAL(38, 9),
PRIMARY KEY (`customer_id`) NOT ENFORCED
) WITH (
'connector' = 'upsert-kafka',
'topic' = 'customer-metrics',
'properties.bootstrap.servers' = 'kafka:9092',
'key.format' = 'json',
'value.format' = 'json',
'key.json.encode.decimal-as-plain-number' = 'true',
'value.json.encode.decimal-as-plain-number' = 'true'
);
EXECUTE STATEMENT SET
BEGIN
INSERT INTO `sink_customer_metrics` SELECT * FROM `customer_metrics`;
END;
The rules, each derived from the plan rather than from anything Flink-specific in it:
-
Every source is a
CREATE TABLE. Akafkasource becomes thekafkaconnector with the target’s bootstrap servers, the source’s topic and format, andscan.startup.modefrom the target’skafka.startup. Afilesource becomes thefilesystemconnector. -
Every model is a
CREATE TEMPORARY VIEW, built from the model’s operator chain as nested subqueries.ref('m')reads the viewm, so a chain of models runs inside one job without a topic in between. -
A model with a
sink:block also gets a table namedsink_<model>and anINSERT INTOinside the singleEXECUTE STATEMENT SET.materialized: appenduses thekafkaconnector;materialized: upsertusesupsert-kafkawithPRIMARY KEY (…) NOT ENFORCEDon the model’s key, so a delete in the changelog is a tombstone on the topic. A file sink cannot carry an upsert changelog and is refused. -
Types map from the source vocabulary:
int32→INT,int64→BIGINT,float64→DOUBLE,decimal(p,s)→DECIMAL(p, s),string→STRING,boolean→BOOLEAN,timestamp→TIMESTAMP(3). -
Identifiers are backtick-quoted, including the names Streamform gives unaliased aggregate columns (
sum(amount)); the model’s own column names are what a reader sees. -
Event time (event time): a source with
event_timegetsWATERMARK FOR <column> AS <column> - INTERVAL '<delay>'(rendered in the largest whole unit:'5' MINUTE,'1' HOUR,'1.500' SECOND) and, for JSON,'json.timestamp-format.standard' = 'ISO-8601'. Withlate_events: dropthe table is created as<source>_rawand the name models read becomes aCREATE TEMPORARY VIEWkeeping only rows withCURRENT_WATERMARK(<column>) IS NULL OR <column> >= CURRENT_WATERMARK(<column>). A model’sretentionbecomes aSTATE_TTLhint on its aggregate, which Flink enforces in processing time;buildandapplywarn that it is approximate and wrong under replay. Flink’s ISO-8601 forTIMESTAMP(3)takes no zone designator: producers write2026-08-30T10:00:00, not…Z.
A model with neither a sink nor a reader is planned but nothing runs it; build warns.
Output goes to build/flink/application.sql under the project, or to standard output with --stdout. Exit code 1 means the backend cannot represent the application (a capability error is printed); 2 means the project itself does not pass streamform check.
streamform apply
streamform apply [--target NAME] builds the application and submits each statement, in order, to the target’s Flink SQL Gateway over its REST API: it opens a session, runs every CREATE TABLE and CREATE TEMPORARY VIEW, runs the EXECUTE STATEMENT SET, prints the job id Flink assigns, and closes the session.
applying customer-metrics to target `local` (http://localhost:8083)
ok: source orders
ok: model customer_metrics
ok: sink customer_metrics
ok: statement set → job 9d1f2c4b7a3e5f6081c2d3e4f5a6b7c8
ok: applied
If Flink rejects a statement, apply prints the statement it was on and Flink’s reason (the message lines of its exception, without the stack trace), stops, and exits 1; no job is started. A gateway that cannot be reached is exit 2. The gateway address comes from the target’s flink.gateway; it is plain HTTP, and the Flink cluster must have the Kafka SQL connector jar on its classpath for the generated tables to resolve.
Kafka topics a source reads must exist before apply: Flink’s Kafka source lists a topic’s partitions when the job starts and fails if there are none. Create them first (kafka-topics.sh --create) rather than relying on the first produced event to create them.
One Flink characteristic to know when consuming an upsert topic: a filter over an aggregate (a WHERE in a model that reads an upsert model, or HAVING) runs in Flink’s retract mode, and the upsert-kafka sink writes each retraction as a tombstone. An update to a row that stays inside the filter therefore reaches the topic as a tombstone followed by the new value for the same key, where the simulator’s changelog has one update. The keyed state a consumer holds is identical once the pair has arrived; a consumer that reacts to every record will see the key vanish and reappear.
apply submits and reports; it does not remember the job. Stopping, upgrading, and reconciling a running application are not built yet.
The integration environment
integration/docker-compose.yml runs one Kafka broker (apache/kafka:3.9.2, KRaft) and a Flink 2.0.2 cluster (JobManager, TaskManager, SQL Gateway) built from integration/flink/Dockerfile, which adds flink-sql-connector-kafka 4.0.1-2.0 to Flink’s classpath. Inside that network Kafka is kafka:9092, which is what the examples’ local target names; the host reaches the SQL Gateway at http://localhost:8083, the Flink UI at http://localhost:8081, and Kafka at localhost:9094. A release publishes both files in streamform-examples.tar.gz as flink-local/, next to the example projects, so the commands below work outside this repository with flink-local/docker-compose.yml and --project customer-metrics.
docker compose -f integration/docker-compose.yml up -d --build --wait
docker exec streamform-kafka /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --create --topic orders
streamform apply --project examples/customer-metrics
printf '%s\n' '{"customer_id": 42, "amount": 10}' '{"customer_id": 42, "amount": 18}' \
| docker exec -i streamform-kafka /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server localhost:9092 --topic orders
docker exec streamform-kafka /opt/kafka/bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic customer-metrics --from-beginning --property print.key=true
docker compose -f integration/docker-compose.yml down -v
mise run integration does that around the differential test (crates/streamform-cli/tests/integration.rs): for each example fixture whose model declares a sink, it applies the project, produces the fixture’s given events to the source topic, reads the sink topic, and compares the (key, value | tombstone) sequence with what the simulator’s changelog implies, reading Flink’s tombstone-then-value pair for one key as the single update it is. Both backends running the same IR must agree; big_customers proves the delete arrives as a tombstone. The tests are #[ignore], so mise run check never needs Docker; .github/workflows/integration.yml runs them on demand and weekly.
Streamform: Plan identity
A streaming application is persistent: it holds state, and a new version of it must either reuse that state or rebuild it. Deciding which needs two things from every version of the plan: a way to say “this is the same node” across versions, and a way to say “this node stores the same thing”. This document describes both, what moves them, and where they are written down. Comparing two versions is streamform plan; this is the material it compares.
Node identity
Every node in a plan has a stable id of the form <model>::<kind>::<ordinal>:
customer_metrics::source::0
customer_metrics::aggregate::0
customer_metrics::project::0
customer_metrics::sink::0
The ordinal counts operators of that kind within that model, in plan order. Identity is deliberately positional and independent of what the node does, so that changing an aggregate’s key or accumulators reports the same node, changed, rather than one node removed and another added. That is what lets an upgrade say “the aggregate in customer_metrics changed its key” instead of “rebuild”.
What moves an id: renaming the model, or adding a second operator of the same kind before an existing one. What does not: editing whitespace, reordering projection columns, changing a key, adding or removing a WHERE, changing an accumulator.
State fingerprint and digest
A node that keeps state (today, an aggregate) has a state fingerprint: the operator kind, the key columns in order, each accumulator by name and type, and the retention — unbounded, or the model’s declared retention rendered canonically (30d, 90m), see Event time:
aggregate key=[customer_id] accumulators=[sum(amount): decimal(38,9)] retention=unbounded
Every name in it is Streamform’s own. Accumulators are named by Streamform’s rendering of the call (sum(amount), count(*)), never by an alias or by the SQL planner’s internal name, and types use the same vocabulary as sources.yml. The accumulator type is derived by Streamform’s rule from the argument’s declared type: a SUM over decimal(10,2) accumulates decimal(38,2), a COUNT accumulates int64.
The digest is the SHA-256 of that text, 64 lowercase hex characters; text output shows the first 12. Because the text depends only on what the user wrote and on Streamform’s own rules, the digest cannot move when a dependency or the Rust toolchain is upgraded. It moves exactly when the state changes meaning: a different key, a different accumulator, a different type.
Schema fingerprint
Every node also has a schema fingerprint: its output columns by name and canonical type, in order.
[customer_id: int64, total_spend: decimal(38,9)]
Nullability is not part of it; it is inferred by the frontend and declared nowhere in a project. An aggregate’s output columns are named and typed like its accumulators (sum(amount): decimal(38,9)); the alias in the SQL is applied by the projection above it (sum(amount) AS total_spend). A model’s contract with its readers, ref() in another model or an external consumer, is its Sink’s schema fingerprint.
Changelog contract
Every node records how its output changes over time: append (every row is a new fact), upsert(<key>) (a later row for a key supersedes the earlier one), or retract. A model’s Sink records the declared materialization and key as well.
Seeing it
streamform inspect-plan [MODEL] prints the identity of one model, or of every model in dependency order followed by the application digest:
customer_metrics
materialized=upsert key=[customer_id]
output=customer_metrics::sink::0
customer_metrics::source::0 Source
definition=orders (kafka orders)
schema=[amount: decimal(38,9), customer_id: int64] digest=55c5eb27e8b4
boundedness=unbounded changelog=append
state=stateless
customer_metrics::aggregate::0 Aggregate
definition=key=[customer_id] aggregates=[sum(amount)]
schema=[customer_id: int64, sum(amount): decimal(38,9)] digest=322eb3aa5330
boundedness=unbounded changelog=upsert(customer_id)
state=keyed key=[customer_id] accumulators=[sum(amount): decimal(38,9)] retention=unbounded
state digest=4aead023aa01
customer_metrics::project::0 Project
definition=customer_id, sum(amount) AS total_spend
schema=[customer_id: int64, total_spend: decimal(38,9)] digest=7fac6038b2ce
boundedness=unbounded changelog=upsert(customer_id)
state=stateless
customer_metrics::sink::0 Sink
definition=upsert kafka customer-metrics
schema=[customer_id: int64, total_spend: decimal(38,9)] digest=7fac6038b2ce
boundedness=unbounded changelog=upsert(customer_id)
state=stateless
streamform explain shows the same plan operator by operator, with the state digest under the fingerprint of each keyed operator.
The plan manifest
streamform inspect-plan --json prints the plan manifest, a versioned JSON document holding everything above for the whole application. streamform build writes the same document to build/plan.json next to the backend output, so every build carries the identity it was built from. streamform explain --json MODEL prints one model’s slice of it.
{
"manifest_version": 1,
"application_digest": "…",
"models": [
{ "name": "customer_metrics", "materialized": "upsert", "key": ["customer_id"], "output": "customer_metrics::sink::0" }
],
"nodes": [
{
"id": "customer_metrics::aggregate::0",
"model": "customer_metrics",
"operator": "aggregate",
"inputs": ["customer_metrics::source::0"],
"definition": "key=[customer_id] aggregates=[sum(amount)]",
"schema": [
{ "name": "customer_id", "type": "int64" },
{ "name": "sum(amount)", "type": "decimal(38,9)" }
],
"schema_digest": "…",
"boundedness": "unbounded",
"changelog": { "mode": "upsert", "key": ["customer_id"] },
"state": {
"kind": "keyed",
"keys": ["customer_id"],
"accumulators": [ { "name": "sum(amount)", "type": "decimal(38,9)" } ],
"retention": "unbounded",
"digest": "…"
}
}
]
}
A node that carries a time attribute (a source with event_time, and every node downstream that keeps the column) has a time field naming the column; it is omitted when there is none, and the Source’s definition gains event_time=… watermark=… late=…. A project without event time therefore produces a manifest byte-identical to one written before event time existed.
application_digest is the SHA-256 of the manifest’s compact JSON with that field empty. A reader refuses a manifest from another manifest_version or with fields it does not know, rather than interpreting it. The manifest is derived from the plan and is not turned back into one: nothing executes from it, and streamform plan compares two of them.
Comparing two of them
streamform plan compares the manifest that is running (recorded by streamform apply, or any manifest named with --against) with the one compiled from the project, classifies every change, and says what continuing from the existing state would need. A model that was renamed, or a node whose ordinal shifted, can be matched to its running counterpart with renamed_from and nodes.<kind>.<ordinal>.was in streamform.yml. See the plan diff.
Streamform: The plan diff
A streaming application is persistent. Changing a model and deploying it does not start from nothing: there is state, accumulated under the previous version, and there are rows already written to every sink. streamform plan compares the plan that is running with the plan the project now describes and says, node by node, what the change costs before anything is deployed. This document describes the comparison, the five classifications and the rules behind them, the record of what is running, and the hints that tell the diff a node moved.
What is compared
Both sides are plan manifests (see plan identity): the desired one is compiled from the project on disk, the running one is what streamform apply recorded for the target, or any manifest or record named with --against. Nodes are matched by id. A node on one side only is added or removed; a node on both sides is compared on its definition, schema, boundedness, changelog contract, and state.
streamform plan # against the target's applied record
streamform plan --target staging
streamform plan --against build/plan.json
streamform plan --json
The five classifications
Every change is classified with one of five words. They are ordered, and the most severe one wins: a model’s verdict is the most severe of its nodes’, the application’s the most severe of its models’.
| Classification | Meaning | Action |
|---|---|---|
SAFE | Nothing that affects state or output changed | none |
COMPATIBLE | Something changed, but no existing state is affected and past output stands | redeploy; existing state continues |
STATE MIGRATION REQUIRED | Existing state has the right key but not the right shape; it can be transformed mechanically | migrate state before the new plan continues from it |
BACKFILL REQUIRED | Existing (or new) state can be loaded, but its contents would not match a from-scratch run under the new plan | replay history through the new plan |
STATE INCOMPATIBLE | Existing state cannot be reused at all | rebuild |
STATE INCOMPATIBLE is the most severe because a rebuild implies a backfill; BACKFILL REQUIRED is more severe than a migration because a migration keeps the state’s contents and a backfill recomputes them.
The rules
For a keyed node (an aggregate) present on both sides:
| What changed | Classification | Why |
|---|---|---|
| nothing | SAFE | |
| the key columns, or their order | STATE INCOMPATIBLE | state is partitioned and addressed by the key; there is no mapping from old keys to new |
| the type of an existing accumulator | STATE INCOMPATIBLE | the stored value has a different encoding |
| an accumulator added | BACKFILL REQUIRED | the new accumulator starts at its initial value for every existing key; a from-scratch run would have accumulated history into it |
| an accumulator removed | STATE MIGRATION REQUIRED | drop it; every remaining value is exactly what a from-scratch run would hold |
| the retention | STATE MIGRATION REQUIRED | timers change, values do not — reachable once a model declares retention (event time) |
| its input | BACKFILL REQUIRED | existing state was accumulated from the previous input |
the clock of a source upstream (event_time, watermark, late_events) | BACKFILL REQUIRED | the clock decides which events are late, so it decides which events reached the state; see event time |
For a stateless node (source, filter, projection, sink) present on both sides, any change is COMPATIBLE: there is no state to migrate. Two exceptions on a sink, because a sink topic is state the reader holds:
| What changed on a sink | Classification | Why |
|---|---|---|
the declared key, or materialized | BACKFILL REQUIRED | rows already in an upsert topic are keyed the old way; the compacted topic becomes a mixture unless it is rebuilt |
| the connector or target | COMPATIBLE | output moves; the old topic is left as it is, and the message says so |
| the schema | COMPATIBLE | readers see new columns; rows already written keep the old shape, and the message says so |
Added and removed nodes: a stateless node added or removed is COMPATIBLE; a keyed node added is BACKFILL REQUIRED (its state starts empty, and history is in it only if the source replays it); a keyed node removed is COMPATIBLE, and the message says its state is discarded when the old job stops. A whole model added or removed is classified as its nodes.
Propagation
A keyed node whose own state is unchanged, but which has an upstream node whose definition changed, is raised to BACKFILL REQUIRED. Its state was accumulated under the previous definition of its input: a WHERE that used to drop refunds and no longer does, a source that now reads a different topic. Loading that state under the new plan continues from totals a from-scratch run would never produce. Propagation follows inputs all the way up and across model boundaries: a changed filter in clean_orders raises the aggregate in customer_metrics, the model that reads it.
This rule is deliberately conservative. Streamform does not yet track which columns a node reads, so a projection that adds a column the aggregate never uses triggers it too. A tool whose job is to prevent silently wrong totals errs towards flagging; column-level lineage will make the rule precise later.
Nothing running
When nothing is recorded as applied to the target and no --against is given, every model is new and the verdict is SAFE: there is no existing state to contradict, so the application simply starts.
Reading the output
The example project, recorded as v0.3.0 wrote it, after changing GROUP BY customer_id to GROUP BY customer_id, amount (and the model’s key with it):
plan: customer-metrics against target `local`
running: applied 2026-08-27T10:12:03Z by streamform 0.4.0, job 9d1f2c4b7a3e, application digest 7b0d9542cf02
desired: application digest d1fbf1d3c6ab
customer_metrics changed
customer_metrics::aggregate::0 Aggregate changed
definition: key=[customer_id] aggregates=[sum(amount)] → key=[customer_id, amount] aggregates=[sum(amount)]
schema: [customer_id: int64, sum(amount): decimal(38,9)] → [customer_id: int64, amount: decimal(38,9), sum(amount): decimal(38,9)]
changelog: upsert(customer_id) → upsert(customer_id, amount)
key: [customer_id] → [customer_id, amount]
state digest: 4aead023aa01 → d982cde38013
STATE INCOMPATIBLE: the grouping key changed; existing state cannot be reused
customer_metrics::project::0 Project changed
definition: customer_id, sum(amount) AS total_spend → customer_id, amount, sum(amount) AS total_spend
schema: [customer_id: int64, total_spend: decimal(38,9)] → [customer_id: int64, amount: decimal(38,9), total_spend: decimal(38,9)]
changelog: upsert(customer_id) → upsert(customer_id, amount)
COMPATIBLE: stateless; no stored value is affected
customer_metrics::sink::0 Sink changed
schema: [customer_id: int64, total_spend: decimal(38,9)] → [customer_id: int64, amount: decimal(38,9), total_spend: decimal(38,9)]
changelog: upsert(customer_id) → upsert(customer_id, amount)
key: [customer_id] → [customer_id, amount]
BACKFILL REQUIRED: rows already written to `customer-metrics` are keyed the old way; rebuild the sink
STATE INCOMPATIBLE: rebuild required
verdict: STATE INCOMPATIBLE (1 model to rebuild)
note: the flink backend does not carry state across a changed query; on this target the action is a rebuild, and the classification tells you what a rebuild costs
The header says what is being compared: when the running plan was applied, by which Streamform, the job it started, and the application digests of both sides. Each changed node lists the fields that differ with their old and new values, then its classification and the reason. Unchanged nodes and models are not listed beyond SAFE. The verdict line counts models by what they need.
Exit codes: 0 when the verdict is SAFE or COMPATIBLE, so the application can continue from its existing state; 1 for any other verdict; 2 when the project does not compile or the running side cannot be read. CI can gate on it.
--json prints the same as a document (diff_version: 1): the target, both sides’ digests, the verdict, every model with its verdict and changed nodes, each node’s change (added, removed, changed), classification, reason, and the fields that differ, and any warnings.
What the classification is, and is not
The classification is semantic: it says what existing state would need in order to continue under the new plan. Whether a backend can do that is the backend’s business. Flink SQL restores state only for a query whose topology and serializers are unchanged, and exposes no operator ids for Streamform to pin, so today on Flink every change that is not SAFE is a rebuild in practice. plan says so in one line whenever the target’s backend is Flink and the verdict is above SAFE. Nothing performs a migration or a backfill yet; the diff tells you what is required, and the native runtime is where Streamform will own the state and execute it.
streamform apply runs the same comparison against the target’s record before applying and prints the verdict, the whole diff when it cannot continue, but it does not refuse. It cannot stop, savepoint, or restore a job yet, so on Flink every apply starts a new job with empty state whatever the verdict says; a gate that had to be bypassed for every real upgrade would only teach people to bypass it. Until job lifecycle exists, plan’s exit code is the gate.
The applied record
After the last statement is accepted, streamform apply writes what it applied to .streamform/applied/<target>.json:
{
"record_version": 1,
"target": "local",
"applied_at": "2026-08-27T10:12:03Z",
"streamform_version": "0.4.0",
"backend": "flink",
"jobs": ["9d1f2c4b7a3e5f6081c2d3e4f5a6b7c8"],
"manifest": { "manifest_version": 1, "application_digest": "7b0d…", "models": [], "nodes": [] }
}
The manifest is embedded verbatim, so a record is also a manifest and --against accepts either, told apart by the top-level version field. The record is written atomically, so a rejected apply leaves the previous one in place. .streamform/ is the project’s record directory, the same role .terraform/ plays; whether a team commits its records or keeps them in a CI cache is the team’s call, and the file contains nothing secret. A record can drift from reality (a job cancelled from the Flink UI, an apply from a machine whose record was never shared); the header prints when and by whom it was applied so that drift is visible.
A reader refuses a record from another record_version, a manifest from another manifest_version, unknown fields, and a file that is neither a record nor a manifest, rather than interpreting it.
Identity hints
Ids are positional: <model>::<kind>::<ordinal>. Two edits move them: renaming a model, and inserting a second operator of the same kind before an existing one. Without help the diff reports the renamed model as removed and a new one added, and the new aggregate as BACKFILL REQUIRED. Two hints in streamform.yml tell the diff which running node a desired node was:
models:
customer_totals:
materialized: upsert
key: [customer_id]
renamed_from: customer_metrics # every customer_totals::<kind>::<n> was customer_metrics::<kind>::<n>
nodes:
aggregate.0:
was: customer_metrics::aggregate::1 # this node in particular was that one
renamed_from matches every node of the model by swapping the model prefix; nodes.<kind>.<ordinal>.was matches one node and takes precedence. Ids in the desired plan are not changed; the hints only choose the counterpart to compare with. After apply, the record holds the new ids and the hints do nothing: plan warns about a hint that names no running node, which is the cue to delete it. streamform check rejects a hint whose node reference is not <kind>.<ordinal>, whose was is not a node id, a renamed_from naming the model itself or a model that still exists, two hints on one running node, and a hint for a node the model’s plan does not have.
A hint makes the diff say “same node, changed” instead of “removed, added”. It does not move state; whether the backend can carry state across a rename is the separate fact described above.
Streamform — Event time
Every model runs in arrival order by default: an event is processed when it shows up, and “when it happened” is a column like any other. Declaring event time on a source gives the plan a clock — a column that says when each event happened, a watermark that says how far out of order events may arrive, a rule for events that arrive later than that, and a way for keyed state to expire in event time. Fixtures can say all of it deterministically, and the same declarations run on Flink.
This page walks through the declarations, then through three fixtures that show exactly what happens — a late event, the watermark advancing, and state expiring — and ends with what Flink does with each and where it can only approximate.
Declaring it
Event time is a property of the data, so it is declared once, on the source, in sources.yml:
sources:
orders:
connector: kafka
topic: orders
format: json
schema:
customer_id: int64
amount: decimal(12,2)
ordered_at: timestamp
event_time: ordered_at # a timestamp column of the schema
watermark: 5m # bounded out-of-orderness; default 0s
late_events: drop # drop (default) or keep
| Field | Meaning |
|---|---|
event_time | The timestamp column that carries event time. Every model downstream carries it as its time attribute for as long as it keeps the column: a filter keeps it, a projection keeps it when the column is selected (under its alias, if aliased), an aggregate drops it. |
watermark | A duration: 0s, 500ms, 5m, 1h, 7d. The source’s watermark is the largest event time seen so far minus this delay, and it never moves backwards. 0s means “events arrive in order”; 5m means “an event may arrive up to five minutes after later ones”. |
late_events | What happens to an event whose event time is behind the watermark when it is delivered. drop discards it and counts it; keep processes it as if on time. drop is the default, because a declared watermark that changes nothing is a trap. |
A source without event_time runs on processing time — exactly the behaviour before this page existed — and declaring watermark or late_events on it is an error. streamform check also rejects an event_time that is not a column or not a timestamp.
A model that keeps state can declare how long to keep it, in streamform.yml:
models:
customer_metrics:
materialized: upsert
key: [customer_id]
retention: 30d
A key’s state is dropped once the watermark reaches that key’s latest event time plus the retention. Latest means the largest event time among the events that touched the key, not the time of whichever event arrived last: an event that arrives out of order, inside the watermark delay or kept by late_events: keep, can extend a key’s life and never shortens it. Dropping is silent: nothing is retracted, and the next event for the key starts the aggregate over from its initial value — so in an upsert sink the key’s row is replaced by the fresh total, not deleted, and until then the sink keeps the stale row. retention needs an event-time source somewhere upstream, because there is no clock otherwise; check says so.
streamform explain shows the clock on the Source and the time attribute on every node that carries one; inspect-plan shows the same in the Source’s definition and a time field, and the retention in the state fingerprint:
Source
name=orders
connector=kafka
time=event(ordered_at) watermark=5m late=drop
↓
Filter
predicate=(customer_id IS NOT NULL)
time attribute=ordered_at
↓
Aggregate
key=customer_id
aggregates=sum(amount)
fingerprint=aggregate key=[customer_id] accumulators=[sum(amount): decimal(38,2)] retention=30d
Timestamps and time in fixtures
A timestamp value in a fixture is an RFC 3339 instant in UTC — 2026-08-30T10:00:00Z, with an optional fraction — or an integer of milliseconds since the epoch. A bare time of day ("10:00:00") is refused: it has no date and would make the fixture depend on the day it runs.
Two things join given and the fixture file:
- watermark: <timestamp>advances the source’s watermark to that instant explicitly. It exists so a fixture can say “nothing more is coming before 10:10” without inventing an event. It never moves the watermark backwards.late:lists the events that must be dropped as late, in delivery order, exactly — the wayexpect:lists the changelog. A dropped event that is not listed fails the fixture, and a listed event that was delivered fails it too. A fixture never loses an event silently.
streamform test prints N late events dropped after the record count whenever N is not zero. Events dropped as late are not counted in events processed.
Three fixtures that show what happens
Each of these is a project under the repository’s crates/streamform-cli/tests/fixtures/time/ and runs in CI, so what this page says is what the simulator does. They share the source above and one model, SELECT customer_id, SUM(amount) AS total_spend FROM source('orders') GROUP BY customer_id.
A late event is dropped, counted, and acknowledged
model: customer_metrics
given:
- event: { customer_id: 42, amount: 10, ordered_at: "2026-08-30T10:00:00Z" }
- event: { customer_id: 42, amount: 20, ordered_at: "2026-08-30T10:10:00Z" }
- event: { customer_id: 42, amount: 5, ordered_at: "2026-08-30T10:01:00Z" }
- event: { customer_id: 42, amount: 1, ordered_at: "2026-08-30T10:06:00Z" }
expect:
- { op: insert, customer_id: 42, total_spend: 10 }
- { op: update, customer_id: 42, total_spend: 30 }
- { op: update, customer_id: 42, total_spend: 31 }
late:
- { customer_id: 42, amount: 5, ordered_at: "2026-08-30T10:01:00Z" }
Read it with the watermark in mind (watermark: 5m):
10:00arrives. Nothing has been seen, so nothing is late. The watermark becomes09:55.10:10arrives; the watermark becomes10:05.10:01arrives. It is behind10:05, so it is late: dropped, counted, not delivered. The total stays 30.10:06arrives. It is not behind10:05, so it is delivered: 31. The watermark becomes10:05still (max seen is10:10).
customer_metrics
PASS
3 events processed
3 changelog records emitted
1 late event dropped
Delete the late: section and the fixture fails, naming the event and saying how to acknowledge it — or how to keep it instead:
# late: listed dropped
1 (not listed) amount=5 customer_id=42 ordered_at="2026-08-30T10:01:00Z"
^ dropped as late but not acknowledged; list it under `late:` or set `late_events: keep` on the source
With late_events: keep on the source, the same events produce a third update to 35 and nothing to acknowledge.
The watermark advances by events and by items, and never backwards
given:
- event: { customer_id: 1, amount: 1, ordered_at: "2026-08-30T10:00:00Z" } # watermark → 09:55
- event: { customer_id: 1, amount: 1, ordered_at: "2026-08-30T09:57:00Z" } # not late: 09:57 ≥ 09:55
- watermark: "2026-08-30T10:20:00Z" # explicit → 10:20
- event: { customer_id: 1, amount: 1, ordered_at: "2026-08-30T10:15:00Z" } # late
- watermark: "2026-08-30T10:00:00Z" # behind: no effect
- event: { customer_id: 1, amount: 1, ordered_at: "2026-08-30T10:19:00Z" } # still late
- event: { customer_id: 1, amount: 1, ordered_at: "2026-08-30T10:30:00Z" } # watermark → 10:25
- event: { customer_id: 1, amount: 1, ordered_at: "2026-08-30T10:24:00Z" } # late
- event: { customer_id: 1, amount: 1, ordered_at: "2026-08-30T10:26:00Z" } # delivered
Four events are delivered and three are dropped; the fixture lists the three under late:. The rule in one line: the watermark is the later of max event time seen minus the delay and the last explicit item, and it only ever increases.
State expires after its retention, and the key starts over
With retention: 10m on the model:
given:
- event: { customer_id: 42, amount: 10, ordered_at: "2026-08-30T10:00:00Z" }
- event: { customer_id: 7, amount: 3, ordered_at: "2026-08-30T10:08:00Z" }
- watermark: "2026-08-30T10:10:00Z"
- event: { customer_id: 42, amount: 5, ordered_at: "2026-08-30T10:11:00Z" }
- event: { customer_id: 7, amount: 1, ordered_at: "2026-08-30T10:12:00Z" }
expect:
- { op: insert, customer_id: 42, total_spend: 10 }
- { op: insert, customer_id: 7, total_spend: 3 }
- { op: insert, customer_id: 42, total_spend: 5 } # not update 15: the key started over
- { op: update, customer_id: 7, total_spend: 4 }
Key 42’s last event is at 10:00; when the watermark reaches 10:10 — exactly last event plus retention, the edge is inclusive — its state is dropped. Nothing is emitted for that. The next event for 42 is therefore an insert of 5, not an update to 15. Key 7’s last event is at 10:08, so it survives the same watermark and gets an ordinary update.
What this means for a consumer of an upsert sink: between the expiry and the next event, the topic still carries 42 → 10; then it carries 42 → 5. The stale row is replaced, never deleted. If a delete on expiry matters to you, that is a native-runtime capability the roadmap lists, not something Flink SQL can express, and Streamform does not pretend otherwise.
What Flink does with each
streamform build --backend flink turns the declarations into Flink SQL, and the integration test proves Flink and the simulator agree on in-order events:
| Declaration | Flink SQL |
|---|---|
event_time + watermark | WATERMARK FOR ordered_at AS ordered_at - INTERVAL '5' MINUTE in the source’s CREATE TABLE; JSON sources read timestamps as ISO-8601 ('json.timestamp-format.standard' = 'ISO-8601') |
late_events: drop | the table is created as <source>_raw, and the name models read becomes a view over it: WHERE CURRENT_WATERMARK(ordered_at) IS NULL OR ordered_at >= CURRENT_WATERMARK(ordered_at) — Flink’s own way to drop late rows outside a window |
late_events: keep | nothing; Flink’s non-windowed aggregates process late rows anyway |
retention | a STATE_TTL hint on the aggregate (/*+ STATE_TTL('clean_orders' = '30d') */), which Flink enforces in processing time |
Four honest limits, stated here so nobody discovers them in production:
- Retention on Flink is approximate. The simulator expires state in event time; Flink’s hint expires it on the wall clock. Day to day they agree. Under replay they do not: thirty days of events replayed in an hour expire nothing on Flink, so a backfill can hold state a fresh run would have dropped.
buildandapplyprint a warning on every model that declares retention. - Lateness is deterministic in the simulator and only eventually consistent on Flink, which generates watermarks periodically and per partition. The differential test covers event time and watermarks with in-order events and does not assert which events Flink drops.
- watermark:items are simulator-only. - Flink’s ISO-8601 for
TIMESTAMP(3)takes no zone designator. A producer writing JSON to a topic an event-time source reads must write2026-08-30T10:00:00(optionally.123), not…Z. Fixtures still take theZform, because a fixture is an instant; the integration test drops theZwhen it produces to Kafka. - An event without its event time is refused by the simulator, and Flink is not asserted.
streamform testfails the fixture and names the event (event 2 has no `ordered_at`); it is not treated as late, and not delivered at the watermark. What Flink does with a row whose time column is null is not covered by the integration run, so keep such rows off an event-time topic.
What changes in identity and the diff
Declaring retention fills the retention= of a model’s state fingerprint, which has said unbounded since Phase 1, and so moves its state digest — and only that digest. streamform plan against a running plan then reports the aggregate as STATE MIGRATION REQUIRED: timers change, stored values do not, the classification that was unreachable until now. Declaring event_time moves no state or schema digest: the source’s manifest definition gains a suffix and every node that carries the attribute gains a time field, and a project without event time produces a manifest byte-identical to one written before event time existed.
That does not make an edit to a clock free. The source’s definition is part of what the state downstream was accumulated under, so streamform plan never reports one of these as SAFE:
| Edit | Verdict for keyed state downstream | Why |
|---|---|---|
event_time declared, removed, or pointed at another column | BACKFILL REQUIRED | which events count as late, and so which reached the state, changes |
watermark (the delay) | BACKFILL REQUIRED | the same: a different delay drops different events |
late_events | BACKFILL REQUIRED | events the running plan dropped would have been kept, or the reverse |
retention added, changed, or removed | STATE MIGRATION REQUIRED | timers change, stored values do not |
retention together with any of the above | BACKFILL REQUIRED | the costlier reason wins |
The Source node itself is stateless and reports COMPATIBLE; a chain with no keyed state below the source stays COMPATIBLE. The rule is conservative on purpose: a longer delay on a topic that never saw a late event changes nothing in practice, and the diff cannot know that. On Flink the STATE_TTL hint is part of the query, so a retention edit is a changed query there like any other (plan diff).
Not here yet
Windows (which consume the watermark this page produces), joins and watermarks across several sources, a processing-time column or watermark, retraction on expiry, an idle-source timeout, time zones beyond UTC instants. The roadmap says where each stands; tumbling windows are next.
The protocol: what every --json document promises
Streamform exchanges documents with everything outside it: the project files it reads, the plan documents it writes, and the reports its commands print with --json. Each is a published specification — a JSON Schema generated from the Rust type that owns it, with prose and real examples, under spec/ in the source tree and mirrored to streamform-spec on every release. These are the rules every document and every command follow; they are the contract a tool, a CI job, or a library can build on without Streamform’s source.
The documents
| Document | Version field | Where it appears | Schema |
|---|---|---|---|
project | version | streamform.yml | streamform schema project |
sources | (with project) | sources.yml | streamform schema sources |
fixture | (with project) | tests/*.yml | streamform schema fixture |
plan-manifest | manifest_version | inspect-plan --json, explain --json, build/plan.json | streamform schema plan-manifest |
applied-record | record_version | .streamform/applied/<target>.json | streamform schema applied-record |
diff | diff_version | plan --json | streamform schema diff |
check-report | check_version | check --json | streamform schema check-report |
test-report | report_version | test --json | streamform schema test-report |
graph | graph_version | graph --json | streamform schema graph |
streamform schema --list prints the same table for the build you have, with the version each document is at.
The rules
- Every document has a version field, first. It is named
<document>_version(the three project files shareversioninstreamform.yml). Every document started at1. - A version is bumped only for a change an older Streamform must refuse. A field an older build may ignore is added with a default and no bump. A change in meaning, a removed field, a renamed one — bumps. When in doubt, it bumps.
- A newer version is refused, never guessed. The error names the version found and the version this build speaks. A document with fields this build does not know is refused too (
additionalProperties: falsein every schema), so a document from a newer build that only added a field fails loudly instead of being half-read. - Exit codes are
0,1,2for every command.0: the command ran and found nothing wrong.1: the command ran and found something — problems incheck, a failed fixture intest, a verdict aboveCOMPATIBLEinplan, a cycle ingraph --json.2: the command could not do its job — the project does not load, a file cannot be read, a target is missing. Gate CI on1;2is a setup problem. - With
--json, stdout holds the document and only the document. Anything written for a person goes to stderr. One exception:check --jsonprints a document even when the project fails to load —loaded: false, the reason as its one diagnostic, then exit2— because saying what is wrong ischeck’s job. - Digests are lowercase-hex SHA-256 wherever a document carries one: the application digest, schema digests, state digests.
- Values in reports are rendered after typing. In the test report, both the expected and the emitted value are shown as the model’s column type renders them — decimals as strings (exact; trailing zeros trimmed as in a fixture), timestamps as RFC 3339 in UTC, integers and booleans as themselves — so a matching row looks matching.
problemis the authority on whether it matched.
Using the schemas
- Editors. Put
# yaml-language-server: $schema=.streamform/schema/project.jsonat the top ofstreamform.yml(and likewise forsources.ymland fixtures) afterstreamform schema project > .streamform/schema/project.json; any editor with the YAML language server then completes field names and flags unknown ones the waycheckwould. - CI. Parse
test --jsonand gate on.passed; parseplan --jsonand gate on.verdict. Assert the version you were written against withstreamform schema <document> --version N, which exits2if the build speaks another. - Programs. Validate any document against its schema with a JSON Schema 2020-12 validator; every schema carries an
$idof the formurn:streamform:spec:<document>:v<N>.
The published copy: glyf-data/streamform-spec, one directory per document, v<N>/schema.json beside v<N>/examples/ and a README.md of prose. Every file in it is generated from the source tree; requests go to the issue tracker, not as pull requests to the mirror.
Changelog
All notable changes to Streamform are recorded here. The format follows Keep a Changelog and the project uses Semantic Versioning; until 1.0, a minor version may contain breaking changes, and each is marked Breaking in its entry.
Each release section is copied verbatim into the GitHub release notes by the release workflow, so write entries for the people who will run the release, not for the people who wrote it.
Unreleased
Added
- Event time on Flink (DEC-009 decision 6): a source with
event_timegetsWATERMARK FOR <column> AS <column> - INTERVAL '<delay>' SECONDin itsCREATE TABLEand, for JSON,'json.timestamp-format.standard' = 'ISO-8601'; withlate_events: dropthe table is created as<source>_rawand the name models read becomes a view that keeps only rows at or afterCURRENT_WATERMARK; a model’sretentionbecomes aSTATE_TTLhint on its aggregate, andbuildandapplywarn that on Flink it is enforced in processing time — approximate, and wrong under replay. JSON timestamps on Kafka are read in Flink’s ISO-8601 form,yyyy-MM-ddTHH:mm:ss[.SSS]without a zone. The integration run gains a differential test over an event-time source with in-order events; lateness and expiry are not asserted there. - Event time runs in the simulator (DEC-009 decisions 3 and 5): a source with
event_timekeeps a watermark — the largest event time seen minus itswatermarkdelay, never moving backwards, or a fixture’s- watermark: <timestamp>item, whichever is later. An event behind the watermark when it is delivered is late: withlate_events: dropit is dropped and counted, withkeepit is delivered as if on time. A model’sretentionexpires a key’s state once the watermark reaches its last event time plus the retention — silently, so the next event for the key starts over as aninsertand an upsert sink keeps the stale row until then.streamform testprintsN late events dropped, and the fixture’slate:list must name every dropped event, exactly and in delivery order: a dropped event that is not listed fails the fixture with a message saying how to acknowledge it, as does a listed event that was delivered.events processedcounts delivered events.test --jsongainslate_droppedandlaterows per fixture (defaulted fields,report_versionstays 1). - Event time is declared and carried: a source in
sources.ymlmay name itsevent_timecolumn (atimestamp), itswatermark(bounded out-of-orderness, a duration such as5m, default0s), andlate_events(droporkeep, defaultdrop); a model may declareretention(a duration, enforced in event time by the simulator; see the entry above).explainprintstime=processingortime=event(<column>) watermark=… late=…on every Source andtime attribute=<column>on every node that carries one;inspect-planprints the same in the Source’s definition and atimefield, which is omitted when there is none, so a plan without event time is byte-identical to one written by v0.4.0 and every recorded digest stands. A model’sretentionappears in its state fingerprint’sretention=(which has saidunboundedsince Phase 1) and therefore moves its state digest and only that digest.checkrejects anevent_timethat is not atimestampcolumn, awatermarkorlate_eventswithoutevent_time, and aretentionon a model with no event-time source upstream. Fixtures accept RFC 3339 timestamps (2026-08-30T10:00:00Z) for timestamp columns beside the integer form,- watermark: <timestamp>items ingiven, and alate:list (DEC-009). - Every release publishes
streamform-examples.tar.gzbeside the binaries, covered bychecksums.txt: the example projects, and the local Kafka and Flink environment asflink-local/. The source repository is private, so this is how the documented first project and the Flink guide are reproduced from public artifacts; the README, the install page, and the guides download it instead of cloning. - The formats are published:
spec/in the repository holds, for every document the binary reads or writes, its JSON Schema (generated from the binary withmise run spec; CI fails when it drifts), real examples produced from the example projects, and a page of prose; the whole directory is mirrored toglyf-data/streamform-specon every release.docs/protocol.mdstates the rules every document and every--jsonfollow: a version field first, newer versions and unknown fields refused, exit codes0/1/2, stdout holds only the document, values rendered after typing. streamform check --json,streamform test --json, andstreamform graph --jsonprint one versioned document each (check_version,report_version,graph_version, all1) and nothing else on stdout; exit codes are unchanged. The test report carries every comparison row the text output shows, with values on both sides rendered after typing against the model’s schema — decimals as strings, so nothing is lost to a float, timestamps as RFC 3339 — and an update’s before-image.check --jsonprints a document even when the project fails to load (loaded: false, exit 2);graph --jsonprints the document with itscycleson a reference cycle and exits 1, where the text form refuses.streamform schema <document>prints the JSON Schema (draft 2020-12) of a document this build reads or writes —project,sources,fixture,plan-manifest,applied-record,diff,check-report,test-report,graph— generated from the same types the binary uses, so the schema cannot disagree with the binary;streamform schema --listnames every document with its version and where it appears.--version Nis refused when this build speaks another version. A project file can name its schema for editor completion with# yaml-language-server: $schema=<file>.
Fixed
- Retention counts from the latest event time a key has seen. The simulator counted from whichever event arrived last, so an out-of-order event (inside the watermark delay, or kept by
late_events: keep) could bring a key’s expiry forward and drop state that a more recent event had just touched. A write now extends a key’s life or leaves it alone, as it does under Flink’s state TTL. - A fixture event with no value in the event-time column fails with
event N has no `<column>`; source `<source>` runs on event time, so every event needs one, where it used to sayplan cannot be simulated. Messages for constructs the simulator does not run sayis not supported yet, notin v0.1. - JSON sinks on Flink write decimals as plain numbers. Flink’s JSON format strips trailing zeros and falls back to scientific notation by default, so a total of 10 reached Kafka as
1E+1, 100 as1E+2, and 0.000000027 as2.7E-8. Generated sink tables now set'json.encode.decimal-as-plain-number' = 'true'(forupsert-kafka, on both the key and the value format), and the same values arrive as10.000000000,100.000000000, and0.000000027, at the column’s scale. Anyone parsing a sink topic as text sees different bytes for the same number; a JSON parser sees the same value.buildoutput changes for every project with a JSON sink.
Changed
- The documentation says what exists: Homebrew, signed artifacts, a container image, and further targets are planned and not available, as are the testing library, the Flink runner image, and the GitHub Action; the only command that opens a network connection is
apply, to the SQL Gateway it is given. examples/order-pipelinedeclares event time:ordersgainsordered_at: timestamp,event_time: ordered_at, andwatermark: 5m; every fixture event carries a timestamp, andtests/late_order.ymlshows a late order dropped and acknowledged.docs/event-time.md(new) walks through event time, watermarks, late events, and retention with three runnable fixtures, and what Flink does with each; the site gains a Time concept page and the testing guide a late-events section.- Breaking (terms): Streamform is a closed-source binary, free to use locally and in production with no account and no limit;
LICENSEis now the terms of use rather than the MIT licence (no copy of the MIT-licensed tree was ever distributed). The file formats, the testing library, the Flink runner image, and the GitHub Action are open source under Apache-2.0 in their own repositories. Releases are published toglyf-data/streamform-releases;cargo install --gitis no longer an install path.
0.4.0 - 2026-08-27
Streamform now says what an upgrade costs before it is deployed: streamform plan compares the running plan with the desired one and classifies every change (SAFE, COMPATIBLE, STATE MIGRATION REQUIRED, BACKFILL REQUIRED, STATE INCOMPATIBLE), streamform apply records what it applied, and streamform.yml can say that a model was renamed so the diff compares the same node.
Added
streamform plan [--target NAME] [--against FILE] [--json]compares the running plan with the desired plan and classifies every change asSAFE,COMPATIBLE,STATE MIGRATION REQUIRED,BACKFILL REQUIRED, orSTATE INCOMPATIBLE, most severe first, with the fields that differ and a reason per node. A keyed node whose upstream definition changed, even in another model, is raised toBACKFILL REQUIRED, since its state was accumulated under the previous definition. Exit0when the application can continue from its existing state,1otherwise,2when the comparison could not run. Documented indocs/plan-diff.md.streamform applywrites the plan it applied, with the time, version, backend, and job ids, to.streamform/applied/<target>.json, and prints the verdict against that record before applying again. It does not refuse on any verdict yet: the Flink backend starts a new job with empty state on every apply, and refusing waits for job lifecycle.renamed_from: <model>andnodes: { <kind>.<ordinal>: { was: <id> } }on a model instreamform.ymltellplanwhich running node a desired node was, so a renamed model or a shifted ordinal compares as the same node.streamform checkrejects malformed or contradictory hints and a hint for a node the plan does not have;planwarns about a hint that names no running node.
0.3.0 - 2026-08-26
Streamform is the project’s name from this release on, and every stateful node now has an identity that survives an upgrade: streamform inspect-plan prints stable node ids, SHA-256 state and schema digests, and changelog contracts, streamform build writes them to build/plan.json, and a documentation site lives under site/. The release artifacts are the first named streamform-<target>.tar.gz.
Added
streamform inspect-plan [MODEL] [--json]prints the stable identity of the plan: every node’s id, definition, output schema with its digest, changelog contract, and, for a keyed operator, its key, accumulators, retention, and state digest. With--jsonit prints the plan manifest, a versioned document (manifest_version: 1) holding the same for the whole application plus an application digest. Documented indocs/plan-identity.md.streamform buildwrites the plan manifest tobuild/plan.jsonnext to the generated application, so every build carries the identity it was built from.- A documentation site under
site/(mise run docs,mise run docs-serve): getting started, concepts, guides, and reference, with the founding documents included fromdocs/. A landing page lives atsite/landing/index.html. TheDocsworkflow builds both on every change; the site is run locally, nothing deploys it.
Changed
- Breaking: the project is renamed to Streamform. The binary is
streamform, the crates arestreamform-*, the project manifest file isstreamform.yml, and the release artifacts arestreamform-<target>.tar.gz. Releases v0.1.0 and v0.2.0 shipped under the previous name; their entries below are written with the current names. - Breaking: an aggregate column without an alias is now named by Streamform (
sum(amount),count(*)) rather than by the SQL planner (sum(orders.amount)), and an aggregate column keeps its accumulator type through a projection (decimal(38,2)for aSUMoverdecimal(10,2), where the planner saiddecimal(20,2)). Generated Flink SQL changes accordingly for unaliased aggregates; fixtures that name such a column must use the new name. Aliased columns are unaffected. streamform explainprints adigest=line under a keyed operator’s fingerprint: the first 12 characters of the SHA-256 of the fingerprint text.- Breaking:
streamform explain --jsonprints the model’s slice of the plan manifest (manifest_version,model,nodes) instead of the previous hand-written shape; every field of the plan is now present, andfingerprint_digestisstate.digest.
Fixed
- State fingerprint digests were computed with the standard library’s hasher, whose output may change between Rust releases. They are now SHA-256 over the fingerprint’s canonical text, so a toolchain upgrade cannot mark existing state incompatible.
0.2.0 - 2026-08-25
Streamform understands an application, not a statement, and runs it on Flink. ref() chains models into a dependency graph; streamform graph draws it; streamform build --backend flink turns the plan into Flink SQL and streamform apply submits it through the SQL Gateway; mise run integration proves on real Kafka and Flink 2.0.2 that the simulator and Flink agree. Coverage fixtures now run every aggregate, operator, and column type through SQL.
Added
ref('<model>')in model SQL reads another model of the same project. Models form a dependency graph;streamform checkreports an unknownref(), a reference cycle, and a name used by both a source and a model.streamform explain <model>shows the upstream model asfrom=<model>on the first operator, andstreamform explainwith no model prints every model in dependency order.- A fixture may target a model that reads other models:
givenevents go to the source at the top of the chain andexpectis asserted on the target model’s output, after every model in between. - A model’s Sink shows its declared materialization and, when a
sink:block is configured, the connector and target. streamform build --backend flink [--target NAME] [--stdout]generates the application as Flink SQL: aCREATE TABLEper source, aCREATE TEMPORARY VIEWper model, aCREATE TABLE sink_<model>per declared sink (upsert-kafkawith a primary key forupsertmodels), and oneEXECUTE STATEMENT SET. No Java or Python is generated. Documented indocs/flink-backend.md.streamform apply [--target NAME]builds the application and submits it through the target’s Flink SQL Gateway REST API, statement by statement, printing the job id; a rejected statement stops the apply with Flink’s reason and exit code 1.integration/docker-compose.ymlruns Kafka and Flink 2.0.2 with the Kafka connector locally;mise run integrationapplies the examples to it and checks that the sink topics carry exactly what the simulator’s changelog implies, tombstones included.targets:instreamform.ymlnames where an application is built for and applied to:backend,flink.gateway,kafka.bootstrap_servers,kafka.startup.streamform checkreports a Flink target missing its sections.streamform graphprints the model dependency graph as an indented tree;streamform graph --format dotemits Graphviz.examples/order-pipeline: the three-model example,orders → clean_orders → customer_metrics → big_customers, whose last fixture asserts an insert, an update, and a delete from one filter over an upsert model.
Changed
- Breaking for callers of the Rust API:
streamform_sql::compileandcompile_allare replaced bycompile_application, which returns one plan for the whole project;streamform_sim::runtakes the model to run. The CLI is unchanged. GROUP BYover a model that emits updates is refused at planning (“aggregation over an updating stream”), since aggregating updates needs retractions that are not supported yet.- Unsupported constructs are reported as “not supported yet” rather than “not supported in v0.1”.
- An
upsertmodel’s declaredkeymust match the key its plan produces:streamform checknow reports a key that disagrees with theGROUP BY(previously accepted silently), and a global aggregate is declared with no key (previously impossible).
Fixed
COUNT(*)failed to plan (“the expression *”) in every release so far; nothing had exercised it through SQL. A coverage fixture project now runs every aggregate, operator, and column type through SQL.
0.1.0 - 2026-08-24
The first release: deterministic testing and inspection for streaming SQL semantics. No Flink, Kafka, dbt, Python, windows, watermarks, joins, ref(), or deployment yet.
Added
streamform checkloads and validates a project —streamform.yml,sources.yml,models/*.sql,tests/*.yml— compiles every model, and reports every problem in one run withdid you meansuggestions.streamform explain <model>prints the model’s streaming plan: operators, boundedness, changelog mode, state requirement, key, and a state fingerprint naming each accumulator and its type.--jsonfor machine consumption.streamform test [--model NAME]runs every fixture through a deterministic in-process simulator and asserts the exact changelog, record by record. PrintsPASS/FAILwith event and record counts, a side-by-side diff on failure, and exits1when any fixture fails.- The Streaming IR (
streamform-ir): source, filter, projection, keyed aggregation, and sink operators; append and upsert changelog modes; stable node identity<model>::<kind>::<ordinal>; state fingerprints rendered through Streamform-owned type names so a dependency upgrade cannot move them. - The SQL frontend (
streamform-sql): DataFusion plans a model’sSELECT, and lowering produces the IR. Supported: column references, literals, comparison and arithmetic operators,AND/OR/NOT,IS [NOT] NULL,CAST,WHERE,GROUP BYover columns,HAVING, and the aggregatessum,count,min,max. Anything else fails naming the construct.ORDER BYover an unbounded source is rejected as meaningless. - The simulator (
streamform-sim): single-threaded, in-memory, no wall clock; operators defined over changelogs so a filter after an aggregate emits inserts and deletes at the predicate boundary; exact fixed-point decimals; checked arithmetic that reports overflow rather than wrapping. - Project format:
source('<name>')in SQL; per-modelmaterialized: append | upsertwithkey; source column typesint32,int64,float64,decimal,decimal(precision,scale),string,boolean,timestamp; fixtures withgivenevents andexpectrecords carryingop: insert | update | delete. Documented indocs/project-format.md. - Release builds for macOS (Intel and Apple silicon) and Linux x86_64 with
checksums.txt, andcargo install --gitfrom the tag.
Streamform — Vision
What is Streamform?
Streamform is a Rust-native framework for building, testing, planning, and deploying continuously running data applications from declarative SQL models.
The user describes:
source
+
transformation
+
materialization
Streamform turns that into:
continuous dataflow
An execution backend such as Apache Flink runs that dataflow in production.
SQL models
+
source definitions
+
materialization
│
▼
Streamform
│
▼
Streaming IR
/ \
Simulator Flink
Streamform itself is not another streaming database and initially is not another distributed streaming engine.
Its value sits above execution engines:
Give data engineers and software engineers a software-engineering workflow for long-running stateful data applications.
Problem
There is a large gap between writing a transformation and operating it continuously.
A batch data engineer can comfortably write:
SELECT
customer_id,
SUM(amount) AS total_spend
FROM orders
GROUP BY customer_id
But turning that logic into:
Kafka
↓
continuous processing
↓
persistent state
↓
safe updates
↓
production deployment
requires understanding:
- Kafka
- Flink
- state management
- changelogs
- checkpoints
- watermarks
- event time
- serialization
- deployment
- savepoints
- upgrades
- recovery
The application also behaves differently from batch SQL.
A batch query:
starts
↓
processes data
↓
finishes
A streaming application:
starts
↓
processes events
↓
accumulates state
↓
continues running
↓
changes over time
↓
must survive deployments
↓
may run for years
That lifecycle is the real problem Streamform should solve.
User Experience
A project might eventually look like:
fraud-pipeline/
├── streamform.yml
├── sources.yml
├── models/
│ ├── clean_transactions.sql
│ └── account_activity.sql
└── tests/
└── account_activity.yml
A source:
sources:
commerce.transactions:
connector: kafka
topic: transactions
format: json
A model:
SELECT
account_id,
COUNT(*) AS transaction_count,
SUM(amount) AS total_amount
FROM source('commerce.transactions')
GROUP BY account_id
A materialization:
models:
account_activity:
materialized: upsert
key:
- account_id
sink:
connector: kafka
topic: account-activity
The developer workflow:
streamform check
streamform test
streamform explain
streamform plan
streamform apply
streamform inspect
Core Insight
SQL alone is not a streaming application.
A streaming application consists of:
INPUT SEMANTICS
What is the input?
Bounded file?
Kafka stream?
CDC changelog?
+
COMPUTATION
What transformation should happen?
+
MATERIALIZATION SEMANTICS
How should continuously changing results be represented?
Append?
Upsert?
Retract?
Queryable state?
Streamform owns the semantics connecting these three pieces.
First Principle: Streaming Is About Change
Consider:
SELECT
customer_id,
SUM(amount)
FROM orders
GROUP BY customer_id
Input events might be append-only:
customer=42 amount=10
customer=42 amount=18
But the output relation changes:
INSERT
customer=42 total=10
UPDATE
customer=42 total=28
Streamform must understand this distinction.
Therefore concepts such as:
Append
Upsert
Update
Retract
are first-class concepts.
They are not implementation details of Flink.
Second Principle: State Is Part of the Program
The aggregation:
GROUP BY customer_id
requires the runtime to remember previous values.
Conceptually:
state
customer 42
count = 18
total = 902
customer 91
count = 4
total = 88
Therefore Streamform must understand:
which operators require state
how that state is keyed
what schema the state has
whether state can survive a new program version
State must be represented explicitly in Streamform’s semantic model.
Third Principle: Streaming Programs Must Be Testable
A user should not need:
Kafka
+
Flink cluster
+
Docker
+
sleep(10)
+
grep logs
to test transformation logic.
Streamform should provide deterministic streaming tests.
Example:
model: customer_metrics
given:
- customer_id: 42
amount: 10
- customer_id: 42
amount: 18
expect:
- op: insert
customer_id: 42
total_spend: 10
- op: update
customer_id: 42
total_spend: 28
Then:
streamform test
executes completely in-process.
fixture events
↓
Streaming IR
↓
Rust simulator
↓
exact changelog
No external infrastructure.
Fourth Principle: Running Applications Have a Lifecycle
Streamform’s long-term differentiation should not be merely:
compile SQL to Flink.
The difficult production question is:
What happens when the SQL changes while the application already has hundreds of gigabytes of state?
Example:
Previous:
GROUP BY customer_id
New:
GROUP BY customer_id, country
Streamform should eventually tell the developer:
STATE INCOMPATIBLE
Model:
customer_metrics
Grouping key changed:
- customer_id
+ customer_id, country
Existing keyed state cannot be safely reused.
Required action:
REBUILD
Another change could result in:
SAFE
Existing state can be reused.
Or:
BACKFILL REQUIRED
This requires Streamform to understand the lifecycle of the program, not merely its syntax.
Fifth Principle: Execution Engines Are Backends
Initial architecture:
Streamform Streaming IR
/ \
/ \
Simulator Flink
Later:
Streamform Streaming IR
/ | \
/ | \
Simulator Flink Streamform Runtime
Flink provides mature production execution.
Streamform owns:
- authoring semantics
- streaming semantics
- state semantics
- changelogs
- validation
- testing
- lifecycle
- plan compatibility
This prevents Streamform from becoming coupled to Flink.
Why Rust?
The implementation should be Rust-first.
Rust is particularly appropriate because the project can eventually involve:
- SQL compilation
- Arrow-native memory
- query-plan transformations
- event processing
- state stores
- async networking
- Kafka
- storage
- concurrency
- checkpointing
- distributed execution
It also lets Streamform ship as a single native CLI without requiring a Python runtime.
Initial installation should aim toward:
brew install streamform
or:
cargo install streamform
Python bindings or:
pip install streamform
can be introduced later if there is a concrete Python integration need.
Python should not be required for the initial architecture.
What Streamform Is Not
Streamform is initially not:
- a streaming database
- a Kafka replacement
- a Flink replacement
- a warehouse
- a generic workflow orchestrator
- a dbt fork
- an agent framework
- a hosted runtime (a hosted control plane that never runs user workloads is not excluded; the founding text read “a hosted SaaS platform”)
- an API framework
- a distributed Rust streaming engine
Those boundaries are deliberate.
Initial User
The initial user is an engineer comfortable with:
SQL
data models
Kafka concepts
Git
CI
but who does not want streaming application development to require deep Flink expertise.
Later users may include:
- analytics engineers
- data platform teams
- backend engineers
- real-time product teams
Longer-Term Direction
The project can evolve naturally:
Phase 1
streaming compiler + simulator
↓
Phase 2
production Flink execution
↓
Phase 3
safe application upgrades
↓
Phase 4
runtime contracts
↓
Phase 5
native Rust runtime
↓
Phase 6
queryable materialized state
↓
Phase 7
real-time API serving
Eventually:
events
↓
Streamform model
↓
continuous state
↓
REST / gRPC / Arrow Flight
could allow a data engineer to turn continuously maintained data into production application state without separately building an API service.
That is a future extension, not the initial objective.
Product Thesis
Streamform’s thesis is:
Continuously running data applications should be developed with the same confidence, testability, versioning, and deployment discipline as normal software.
The product is therefore not primarily:
easier streaming SQL.
It is:
a software development system for stateful streaming applications.
Streamform — Architecture
This document describes the founding architecture, with some examples from the initial implementation. For the assessed current baseline, delivery sequence, and upcoming window/lifecycle decisions, see the roadmap; the delivery work behind it is kept in the source repository (docs/implementation-strategy.md). Future-looking examples here do not imply that a command or guarantee is implemented.
Architectural Overview
Streamform Project
│
┌───────────┴───────────┐
│ │
SQL models metadata
sources
sinks
tests
│ │
└───────────┬───────────┘
▼
┌────────────────┐
│ Frontend │
│ │
│ DataFusion │
│ LogicalPlan │
└───────┬────────┘
▼
┌────────────────┐
│ Semantics │
│ │
│ boundedness │
│ state │
│ changelog │
│ keys │
│ time │
└───────┬────────┘
▼
┌────────────────┐
│ Streaming IR │
└───────┬────────┘
│
┌──────────┼───────────┐
▼ ▼ ▼
Simulator Flink Native Rust
future
1. Rust Workspace
Start with a small workspace.
streamform/
├── Cargo.toml
├── README.md
├── AGENTS.md
│
├── docs/
│ ├── vision.md
│ ├── architecture.md
│ └── roadmap.md
│
├── crates/
│ ├── streamform-cli/
│ ├── streamform-project/
│ ├── streamform-sql/
│ ├── streamform-ir/
│ ├── streamform-semantics/
│ └── streamform-sim/
│
└── examples/
└── customer-metrics/
Do not create a crate for every future concept.
Add boundaries only when real code requires them.
2. Project Frontend
Streamform V0 should use its own minimal project format.
Do not make dbt a runtime dependency.
Example:
streamform.yml
sources.yml
models/*.sql
tests/*.yml
Later:
dbt manifest
↓
DbtFrontend
↓
same internal representation
becomes another frontend.
This keeps:
Streamform semantics
independent from:
dbt semantics
3. DataFusion Boundary
Use DataFusion for generic relational machinery.
SQL
↓
DataFusion parser/planner
↓
DataFusion LogicalPlan
↓
Streamform lowering
↓
Streaming IR
DataFusion provides machinery such as:
- SQL parsing
- column resolution
- expression trees
- data types
- coercion
- aggregates
- joins
- logical plans
- Arrow schemas
Streamform should not make DataFusion’s plan canonical.
Lower relatively early.
4. Streaming IR
The Streaming IR is the central architecture.
A possible starting point:
#![allow(unused)]
fn main() {
pub struct StreamingPlan {
pub nodes: Vec<StreamNode>,
}
}
#![allow(unused)]
fn main() {
pub struct StreamNode {
pub id: NodeId,
pub operator: Operator,
pub schema: Schema,
pub boundedness: Boundedness,
pub changelog: ChangelogMode,
pub state: StateRequirement,
}
}
Initial operators:
#![allow(unused)]
fn main() {
pub enum Operator {
Source(Source),
Filter(Filter),
Project(Project),
Aggregate(Aggregate),
Sink(Sink),
}
}
Keep V0 intentionally tiny.
5. Stable Node Identity
Every stateful node should have stable identity.
For example:
#![allow(unused)]
fn main() {
pub struct NodeId(String);
}
A node identity should not depend purely on:
position in DAG
because the eventual lifecycle system must compare:
Plan V1
vs
Plan V2
Stable identity enables:
state compatibility
plan diff
migration planning
deployment reconciliation
Design this early.
6. Boundedness
Streamform should explicitly model:
#![allow(unused)]
fn main() {
enum Boundedness {
Bounded,
Unbounded,
}
}
Examples:
Parquet snapshot
→ Bounded
Kafka topic
→ Unbounded
This changes what SQL means.
For instance:
ORDER BY event_time
over an infinite Kafka stream cannot produce a globally final ordering.
The semantic layer should catch such cases.
7. Changelog Model
The result of a streaming operator is not always append-only.
Start with something like:
#![allow(unused)]
fn main() {
enum Change {
Insert(Record),
Update {
before: Record,
after: Record,
},
Delete(Record),
}
}
The logical capability could separately be:
#![allow(unused)]
fn main() {
enum ChangelogMode {
Append,
Upsert(Key),
Retract,
}
}
This distinction becomes foundational for:
- testing
- sinks
- materialized state
- API serving
- subscriptions
8. State Requirements
Represent state explicitly.
#![allow(unused)]
fn main() {
enum StateRequirement {
Stateless,
Keyed {
keys: Vec<Column>,
fingerprint: StateFingerprint,
},
}
}
For:
GROUP BY customer_id
derive:
state:
keyed
key:
customer_id
A first simulator implementation can use:
#![allow(unused)]
fn main() {
HashMap<Key, AggregateState>
}
There is no need for RocksDB or distributed state initially.
9. State Fingerprint
Introduce the concept early, even if compatibility logic comes later.
Conceptually:
StateFingerprint
operator:
aggregate
key:
customer_id
accumulators:
count: int64
total_spend: decimal
retention:
unbounded
Two program versions can eventually compare these fingerprints.
This powers:
streamform plan
10. Deterministic Simulator
The simulator is the first execution backend.
Architecture:
Fixture Events
│
▼
Source
│
▼
Filter
│
▼
Aggregate
│
▼
Changelog
Properties:
- single process
- initially single-threaded
- in-memory
- deterministic
- no Kafka
- no Flink
- no Docker
- no wall-clock dependency
The simulator is simultaneously:
- a testing engine
- the second consumer of the IR
- the seed of the future Rust runtime
11. Test Model
Example:
model: customer_metrics
given:
- customer_id: 42
amount: 10
- customer_id: 42
amount: 18
expect:
- op: insert
customer_id: 42
total_spend: 10
- op: update
customer_id: 42
total_spend: 28
Then:
streamform test
produces:
customer_metrics
PASS
2 events processed
2 changelog records emitted
The fixture format — how given values are typed against the source schema, what an expect record must contain, and how a failure is rendered — is specified in project-format.md. The comparison is exact: same number of records, same op, same column values, in order.
Tests should eventually support explicitly controlled:
- event order
- event timestamps
- watermarks
- late events
12. Flink Backend
Only after the IR + simulator work should Flink be introduced.
Streaming IR
│
▼
Flink Backend
│
▼
Flink SQL / executable representation
Flink owns:
- distributed execution
- checkpointing
- network shuffle
- recovery
- state backend
- backpressure
- parallelism
Streamform owns:
- semantic interpretation
- validation
- plan structure
- changelog meaning
- state compatibility
- developer workflow
13. Multi-Model DAG
Streamform eventually supports:
source orders
↓
clean_orders
↓
customer_metrics
↓
sink
Streamform’s own authoring syntax can initially provide:
source(...)
ref(...)
These do not need to depend on dbt. As built: a project is one StreamingPlan; every node knows its model; ref('m') is an edge from a model’s first operator to m’s Sink node, so a model is planned once no matter how many read it, and changelog mode crosses the boundary (see docs/project-format.md).
Later the dbt frontend can map dbt’s:
source()
ref()
into the same model.
14. Lifecycle / Reconciler
This should become one of the project’s most important systems.
Commands:
streamform plan
streamform apply
streamform inspect
streamform drain
rather than thinking primarily in terms of:
run
deploy
because a streaming application is persistent.
Conceptually:
Desired Plan
│
▼
compare
▲
│
Running Plan
Output:
SAFE
STATE MIGRATION
STATE INCOMPATIBLE
BACKFILL REQUIRED
15. Native Rust Runtime
The simulator evolves gradually into a runtime.
Progression:
deterministic simulator
↓
Arrow batches
↓
async sources
↓
bounded channels
↓
backpressure
↓
persistent keyed state
↓
checkpointing
↓
recovery
↓
parallel operators
↓
distributed workers
Do not begin with distributed execution.
16. Queryable State
When persistent keyed state is implemented, design it so it can eventually be accessed externally.
Conceptually:
Runtime
│
├── update state
│
└── query state
│
▼
customer_id=42
This makes future serving much easier.
17. API Serving
Later:
Kafka
↓
Streamform computation
↓
queryable keyed state
↓
HTTP / gRPC / Arrow Flight
The serving layer should be an extension of maintained state, rather than a separate duplicated pipeline.
18. Python
Python should be optional and delayed.
Potential future use:
pip install streamform
could provide:
from streamform import Project
project = Project.load(".")
project.test()
Possible implementation:
Rust core
↓
PyO3
↓
Python package
But only add this when:
- notebook integration matters
- Python programmatic APIs matter
- dbt ecosystem integration requires it
The CLI and compiler should remain Rust-native.
Streamform — Product roadmap
Status assessed on 2026-09-16 against the local checkout at 6e9e818. This is a source and documentation assessment, not a fresh certification of tests, releases, or external repositories.
This roadmap replaces the original phase-by-phase build order. The vision remains the product thesis; architecture sets the compiler boundaries; the implementation strategy (docs/implementation-strategy.md in the source repository) defines delivery work and acceptance gates. Future behavior described here is planned, not an existing CLI or file-format contract.
Product direction
Streamform should make a stateful streaming application understandable, testable, and safe to change. The initial user is an engineer who knows SQL, Kafka, Git, and CI, but should not need to become a Flink specialist to answer: “What will this application emit, and what happens to its state when I deploy this change?”
The near-term product is a Rust compiler, deterministic simulator, and deployment workflow over Flink. Its differentiator is the connection between tested semantics, explainable change impact, and an observed deployment. Native execution, serving, and a hosted control plane remain options after this workflow earns adoption.
The next product proof is a customer-metrics application that can be tested with late events and windows, deployed once, inspected, and restarted from verified state without duplicate jobs or unexplained loss. A changed grouping key must produce an actionable refusal or explicit rebuild plan, not an implied safe upgrade.
Where the project actually stands
| Capability | Evidence in this checkout | Status and limit |
|---|---|---|
| Project loading, SQL lowering, generic IR | streamform-project, streamform-sql, streamform-ir | Implemented; DataFusion stays in the SQL crate |
| Deterministic tests and changelogs | streamform-sim, CLI fixtures | Implemented for single-source chains, including ref(); no joins or aggregation over updates |
| Flink SQL and Kafka execution | streamform-flink, integration/ | Implemented; integration tests exist, require Docker, and are separate from ordinary checks |
| Stable identities and semantic plan diff | IR manifests/diff, inspect-plan, plan | Implemented; semantic classification does not establish Flink savepoint compatibility |
| Apply and deployment records | CLI apply.rs, record.rs | Submits a new job and writes a local record; does not reconcile live jobs, restore state, or enforce the diff verdict |
| Event time, late events, retention | docs/event-time.md, simulator clock, Flink generator | Implemented on main, listed under Unreleased for v0.5; Flink retention uses processing time and lateness is not exact simulator parity |
| Versioned documents and schemas | docs/protocol.md, spec/, generation/mirror workflows | Implemented locally; publication and external consumers need release verification |
| Tumbling windows | Window proposal (docs/windows.md in the source repository) | Design only; no runtime support |
| Native runtime, serving, Cloud | Vision and product prose | Future direction, not demonstrated by this checkout |
The workspace version remains 0.4.0. Event time is already built; the immediate work is validating and packaging it, not implementing it again. The earlier roadmap’s Phase 0–6 work is largely present, Phase 7 is implemented but unreleased, and Phase 8 begins with tumbling windows.
Delivery order
Milestones are ordered by dependencies and evidence, not dates. v0.5 is the existing release target; later version numbers should be assigned when their gates are met.
| Milestone | User outcome | Depends on | Exit gate |
|---|---|---|---|
| M0 — Trustworthy event-time release | Install a binary and reproduce documented behavior without private source access | Existing main | Fresh verification, public example/install path, consistent documentation and versioned specs |
| M1 — Tumbling windows | Test and run a five-minute keyed metric with explicit completion semantics | M0; window feasibility decision | SQL → IR → simulator → Flink acceptance project; boundary, lateness, cleanup, and upgrade tests |
| M2 — Observable, repeatable deployment | Know what runs; repeated apply does not start duplicate jobs | M0; deployment identity design | Live status, ownership, idempotency, journal, and failure recovery tests |
| M3 — Verified restart and controlled upgrades | Preserve state for a proven case and refuse unsupported upgrades | M2; savepoint/physical-identity feasibility gate | Same-artifact restore on pinned Flink, then a documented matrix of supported changes |
| M4 — Continuous contracts | Detect or reject bad records with reproducible outcomes | M1 and M3 baseline; concrete user need | Simulator/backend agreement for a narrow contract set and failure actions |
| M5 — Broader application semantics | Express the next validated customer workload | Production feedback and semantic prerequisites | Each feature ships as its own tested vertical slice |
| M6 — Runtime and serving research | Decide whether owning execution or serving solves a demonstrated problem | Adoption evidence and operational capacity | Explicit go/no-go experiment before a new production runtime |
Execute M0 first, then M1. Start the bounded M2/M3 feasibility investigations during M1 if capacity permits; they need not delay the window contract, and they must finish before upgrade promises are made. After M1, deliver M2 then M3 before adding sliding/session windows or starting a native runtime. This is work sequencing, not a requirement for parallel agents.
M0 — Release the capabilities already built
Close the v0.5 release around event time and the published protocol. Run the repository checks and real-cluster suite, preserve the documented processing-time retention limitation, and publish reproducible examples with the binary. A public quick start must work without cloning this private source repository.
Audit the release-facing prose: the README and introduction still say event time is absent; the Unreleased changelog contains an earlier intermediate claim that watermark fixtures are refused; contribution/release instructions and ecosystem availability statements are inconsistent with newer distribution choices. Classify external components as verified available, planned, or unavailable. Do not make the release depend on implementing every ecosystem promise; implement or explicitly defer each one.
Exit: a new user on a supported platform installs the release, gets the documented check/explain/test output from downloadable examples, validates the JSON reports against shipped schemas, and can follow the Flink guide with accurate caveats. Release tags and publication remain explicit release actions.
M1 — One complete window capability
Deliver event-time tumbling aggregation with final emission, append input, and fixed positive millisecond durations. Start with a single event-time lineage and existing aggregates. Keep window boundaries, emission, cleanup, and late-data semantics in the generic IR. The window proposal (docs/windows.md in the source repository) supersedes the previous draft and identifies the decisions the feasibility work must settle.
Do not ship a simulator-only interpretation while presenting it as portable to Flink. Reject unsupported combinations before submission. Add a canonical window example, versioned schema changes where needed, stable fingerprints, meaningful plan-diff reasons, and a differential integration test.
Exit: an engineer can predict when a window becomes final, observe no premature output, test boundary and late events without Docker, and run the supported equivalent on Flink. Memory is reclaimed when windows close; fixture exhaustion is not an implicit infinite watermark.
M2 — Make deployment observable and repeatable
Separate desired intent, last submitted deployment, and observed cluster state. A local applied record is evidence of a prior submission, not proof that a job is alive or that its state is restorable.
Add application/target ownership, read-only inspection, an immutable artifact identity, deployment attempts with recoverable state, and a single-writer guard. Repeating the same apply against the same verified running deployment should be a no-op. A timeout after submission is an unknown outcome requiring reconciliation, not permission to start another job. Replacements and rebuilds need explicit modes and sink consequences.
Exit: repeated apply, process interruption, missing jobs, stale records, and ambiguous HTTP outcomes all have tested outcomes. The CLI never announces a healthy deployment solely because a statement was accepted. Define remote gateway TLS, authentication, credential redaction, and supported deployment environments before claiming remote production readiness.
M3 — Turn plan advice into a supported upgrade workflow
First prove stop/savepoint/restore of the identical deployed artifact on the pinned Flink version, including source offsets and externally visible sink behavior. Then enable only the change classes for which the backend can prove physical state compatibility.
Streamform node ids and SHA-256 semantic fingerprints are necessary, but do not identify Flink’s physical operators or serializers. Investigate compiled plans and backend operator identity before selecting the deployment representation. If the current SQL Gateway approach cannot preserve identity, record that constraint and keep unsupported upgrade execution blocked; do not silently rebuild or create a new runtime to avoid the decision.
Keep semantic verdict and executable deployment action separate. A COMPATIBLE semantic change may still have no supported restore path; a STATE MIGRATION REQUIRED result does not mean a migrator exists. Rebuilds require a replayable source range and an explicit sink replacement/cutover policy.
Exit: automated tests demonstrate one supported state-preserving operation, unsupported operations fail before mutation, and partial failures leave sufficient evidence for operator recovery. Do not promise universal rollback or exactly-once delivery from a savepoint alone.
M4 — Continuous contracts
Start with a small set such as not-null and simple predicates, and one deterministic failure policy per release. Define contract placement relative to filters, windows, aggregation, and sink writes. Failure reports should identify the model, contract, and count without unexpectedly exporting event payloads.
Quarantine/dead-letter output is a separate sink with its own schema and delivery semantics. Add it only after its failure and retry behavior is specified. A runtime fail action needs a defined failed-job and restart policy.
Exit: fixture failures and runtime failures are observable and explainable, with a backend capability check for every supported action.
M5 — Expand from observed workloads
Prioritize requests by concrete applications and support cost. The candidate order is retraction-aware aggregation and explicit upsert/CDC inputs; multiple-source fixtures and watermark coordination; bounded interval or temporal joins; then additional windows. This order is provisional and may change with user evidence.
Aggregation over updates needs correct before/after and delete handling, including non-invertible aggregates. Multiple inputs need source-qualified fixtures, watermark minimum/idle-source rules, and bounded state policies before joins. Sliding windows multiply retained state; session windows can merge keys and require new emission and compatibility rules.
Small SQL additions such as CASE, casts, and selected scalar functions may be delivered between milestones when backed by real queries and parity tests. Avoid expanding syntax without executable semantics. dbt import and Python clients belong here only when a real integration need justifies them.
M6 — Keep expensive options conditional
| Option | Evidence needed before implementation |
|---|---|
| Native Rust runtime | Flink materially blocks validated workloads or distribution, and there is capacity to own offsets, backpressure, persistence, recovery, and sink correctness |
| Persistent native state | A native-runtime use case plus measured storage/recovery requirements; select storage after the requirements |
| Queryable state / API serving | Repeated demand for serving maintained state with a clear consistency and availability contract |
| Python / dbt | Identified users need these entry points; preserve one canonical IR and protocol |
| Hosted control plane | Teams need shared history or coordination beyond local/CI workflows; verify opt-in data boundaries and keep local use independent |
| Distributed Rust execution | Single-node limitations are measured and justify sustained systems investment |
The existing free binary and open-format product direction is retained. External library, runner, Action, and Cloud plans are adoption/distribution work, not substitutes for semantic or lifecycle correctness. Their current availability was not verified in this review.
Measures of progress
Track time from installation to first passing fixture; whether users can explain their changelog and plan verdict; time to diagnose a failed deployment; and successful recoveries without duplicate jobs or lost state. Begin with three representative design-partner applications as a proposed validation cohort, not an adoption claim.
Engineering gates are concrete: unchanged manifests keep their digests where promised; supported backend cases have differential tests; repeated apply creates no additional owned job; restore tests continue accumulated totals rather than restarting them; unsupported behavior fails early and names the limitation.
Window state is bounded in event time, not absolutely bounded in memory: stalled watermarks and unbounded key cardinality still require visibility and limits. Operational resource measurements belong in acceptance evidence, not an unqualified “bounded state” claim.
Mapping from the founding roadmap
| Original phase | Current placement |
|---|---|
| 0–6: bootstrap through plan diff | Implemented baseline; verify and maintain |
| 7: event time | M0 release closure |
| 8: windows | M1 tumbling; sliding/session deferred to M5 |
| Lifecycle described in the vision/architecture but missing from the numbered roadmap | M2 and M3, promoted ahead of further expansion |
| 9: contracts | M4 |
| 10–13: native runtime, persistence, queryable state, serving | M6, conditional |
| 14–15: Python and dbt | M5/M6, demand-led |
| Distributed runtime research | M6, last |
For issue preparation, use the stable work-package ids and dependency rules in the implementation strategy (docs/implementation-strategy.md). Create detailed issues for M0–M3 first; keep later work as gated epics until the preceding evidence exists.