A workflow engine that tracks packages through a fulfillment pipeline — event-sourced, with replayable history and at-least-once side-effect delivery.
The engine is the product. The warehouse skin exists because it is instantly legible to a non-technical reader.
Nothing here stores where a package is. It stores what happened to it, as an append-only list of events, and works out the position by replaying that list.
That one decision buys the rest of the product:
| Falls out of it | Not built separately |
|---|---|
| A complete audit trail | It is the state |
| Point-in-time replay | Past state is a query, not a snapshot |
| A no-sign-in sandbox | Your events are concatenated before the replay |
| A projection you can verify | current_step_id is a cache the fold must reproduce |
server log ─┐
├─▶ replay (one function, one language) ─▶ what you see
your browser ──┘
The bottom-left branch is why there is no sign-in. Your actions append to a log in your own browser and are posted with every read; the server folds its log together with yours and returns the combined answer. Your log never lands anywhere else — which is why the audit screen will not show it.
New to it? Press m in the app. Every part of the screen gets a number and an explanation.
docs/WALKTHROUGH.md is the same map in prose, keyed to the same numbers — both read from
Interlock.UI/src/map/regions.ts, so they cannot drift.
Packages advance on a five-second beat. Nothing is animated: a background service appends real transitions through the same write path a click would use, the gate is evaluated for each one, and the outbox fires. New packages arrive at goods-in; finished ones are retired to keep the corpus bounded.
A gated station is a crew under a named supervisor, not one person signing every box — and a station with work stacking up pulls more of the crew onto it. The headcount you see is a function of the queue, and it is the same rule that decides how much the station actually clears each beat.
Apply a lockout and everything standing on that station stops dead, the belt shows hazard stripes, and the queue behind it grows and starts to age.
Nothing scripts that. The line runner asked the engine whether each package could move and the engine said no. Lockout is not a special case in the code — it is one more gate condition that is not satisfied, evaluated by the same evaluator that handles sign-offs and open problems.
Removing another worker's lock requires naming who it was for and writing a reason. In industry cutting someone's lock is a safety incident; here it shares a glyph and a filter with an approval override, because they are the same act.
One lane per package, time left to right, one coloured block per stage — the same colours the stations wear on the board. Width is time, so a queue shows up as a vertical band of wide blocks.
Drag anywhere across it and the panel on the right recounts the whole board for that instant. Nothing was snapshotted to make that work; the answer is replayed on demand, and rendered against the workflow version each package actually ran rather than the current one.
An override is someone acting on another person's behalf — the record an investigation opens the log for. The system refuses to record one without a written reason.
They are also exempt from retirement. Everything else finished gets cleaned up eventually, but a retention rule that aged out the overrides first would quietly empty the most useful screen in the product. (It did, once. That is why the rule exists.)
When a package moves, something outside usually needs telling. A database write and a network call cannot be one atomic unit — so the intent to notify is written into a table in the same transaction as the move, and a background service delivers it afterwards.
Rows are claimed before sending, so running two copies of the app cannot double-deliver. Delivery is at-least-once, so the built-in receiver deduplicates on a delivery key: a repeat increments a counter instead of being processed twice.
Every line of the start-up readout is read from the first API response — workflows loaded, events replayed, queue depth. It auto-plays, is skippable with any key, and there is no "turn on" button.
Needs .NET 10.0.300 (pinned by global.json), Node 22, and SQL Server LocalDB.
# API — migrates and seeds on first run, then serves on http://localhost:5080
cd api && dotnet run --project Interlock.Api
# SPA — proxies /api to the above
cd Interlock.UI && npm install && npm run devOpen http://localhost:5173. Swagger UI is at http://localhost:5080/swagger.
cd api && dotnet run --project Interlock.Api -- --seed # re-seed, re-anchoring the epoch
cd api && dotnet run --project Interlock.Api -- --migrate # migrate only
cd api && dotnet run --project Interlock.Api -- --generate-seed # regenerate the checked-in corpusThe seed corpus is read from the build output, so regenerating it needs a rebuild before the seeder picks it up.
Browser ──▶ Static Web Apps (SPA)
│ fetch
▼
App Service (ASP.NET Core)
Controllers → Services → Repositories
Interlock.Engine (pure fold + gates, no I/O)
LineRunner (hosted, 5s) ─┐
OutboxDispatcher (hosted, 8s) ─┤ POST
SlaEvaluator (hosted, 60s) ─┤
│ │
Azure SQL — Basic tier └─▶ /api/_echo
One deployable plus a database. No Functions app, no queue.
| Layer | Responsibility |
|---|---|
| Controller | HTTP concerns, validation, application/problem+json |
| Service | Orchestration, transaction boundaries, calls the engine |
| Repository | EF Core for CRUD; raw SQL where a locking hint or index matters |
| Engine | Pure fold + gate evaluator. A library the services call, not a layer in the chain |
Interlock.Engine has no DbContext, no HttpClient, and no clock — time is an argument. A test
asserts the assembly references none of those, so replay cannot dispatch by construction rather
than by discipline.
api/
Interlock.Engine/ pure domain — the fold and the gate evaluator
Interlock.Engine.Tests/ fixtures/folds/*.json — the property-test corpus
Interlock.Api/ controllers, services, repositories, hosted services, seeder
Interlock.Api.Tests/ database-backed service and integration tests
Interlock.UI/ Vite + React + TypeScript
bruno/ .bru contract collection + environments
infra/ Bicep
docs/ walkthrough, architecture diagrams, decision records
PLAN.md§4 calls the web folderweb/. It isInterlock.UI/here, by request. Everything else follows the plan's layout.
| File | Why |
|---|---|
Interlock.Engine/WorkflowEngine.cs |
The fold and the gate evaluator, and nothing else |
Interlock.Engine.Tests/fixtures/folds/ |
Twelve scenarios asserting state after every prefix |
Interlock.Api/Services/WorkflowWriter.cs |
Append, evaluate, transition, queue the side effect — one transaction |
Interlock.Api/Repositories/IDispatchRepository.cs |
The outbox claim. Raw SQL, because it needs ROWLOCK/READPAST/OUTPUT |
Interlock.Api/Hosted/LineRunner.cs |
The line that keeps running, and why every movement is a real event |
Interlock.UI/src/helpers/SandboxHelper.ts |
Your browser's event log |
Four layers, all runnable by hand and in CI.
cd api && dotnet test # xunit — 127 tests
cd Interlock.UI && npm run test:run # vitest — 83 tests
cd Interlock.UI && npm run e2e # playwright — 12 tests
cd bruno && bru run --env local # bruno — 29 requestsThe headline test is the fold fixture corpus: twelve recorded scenarios that assert state after every prefix of their event sequence, not just the end. Correct after one event, after two, after three, all the way down. A separate test replays all one hundred seeded packages and asserts the stored position matches the replayed one — if they ever disagree, the stored one is wrong.
Interlock.Api.Tests runs against a real database, not the in-memory provider: the outbox claim
depends on ROWLOCK/READPAST/OUTPUT, the projection query on JSON_VALUE, and concurrency on
rowversion. None of those exist in a fake. It uses LocalDB by default and honours
ConnectionStrings__Interlock so CI can point it at a container.
The Playwright suite doubles as the walkthrough-video recording script. Both it and the Bruno collection run against a live, moving line — nothing addresses a package by a hardcoded id, and nothing assumes a package is still where it was a moment ago. A test that only passes on a frozen board would not be testing this product.
Bruno runs post-deploy against the deployed environment in CI, not against an ephemeral stack in the PR job. It does not gate the PR; it tests what actually shipped.
Event payloads are JSON while definitions are fully relational. Event bodies are written once, read in sequence, and never filtered by an inner field — which is what a document column is for. Definition structure is joined and queried, which is what it is not for. The asymmetry is the point.
State reads are POSTs. They carry the visitor's local event log in the body. That is a query with a payload, not a mutation.
Optimistic concurrency is implemented and barely exercised. Visitor actions stay in the browser, so the only writers are background services. The path is covered by tests rather than by traffic.
Two columns are denormalised onto the instance. completed_at and has_override could both be
derived from the event log — and doing so meant a table scan on every housekeeping pass, which
eventually held locks long enough to stall the whole API. The comments say so.
There is no queue. A broker does not replace the outbox — the outbox exists precisely because a database write and a queue publish cannot be atomic, so the standard topology is outbox, then relay, then queue. At this volume the broker adds a resource and removes no code.
There is no auth. Deliberate, and a demo affordance rather than a production pattern. A sign-in wall on a portfolio reads as data collection and stops the visit. In production this is per-tenant auth with server-side writes.
Seed events are stored as offsets from a seed epoch, and every read resolves them against the
visitor's own epoch — generated on first load and kept in localStorage. The corpus is therefore
always current relative to whoever is looking at it, and stable within a session because the epoch
does not move. See api/Interlock.Api/Demo/DemoTime.cs.
az deployment group create \
--resource-group <rg> \
--template-file infra/main.bicep \
--parameters infra/main.bicepparamSQL_ADMIN_LOGIN and SQL_ADMIN_PASSWORD come from the environment; APP_SERVICE_PLAN_ID reuses
an existing plan if you have one. Confirm the plan is Basic tier or above — the hosted services
require Always On, which Free and Shared do not offer.
The database is Azure SQL Basic, not serverless. Auto-pause is right for a scheduled pipeline where nothing is waiting; it is wrong for an interactive demo whose job is to look alive on load.
PLAN.md |
The full specification. Authoritative — if anything disagrees with it, the plan wins |
docs/WALKTHROUGH.md |
How to drive it, keyed to the numbers map mode shows |
docs/ARCHITECTURE.md |
Mermaid diagrams: deployment, write path, read path, gate evaluation, time machine |
docs/decisions.ts |
Architectural decision records, each with the condition that would reverse it |






