Skip to content

Latest commit

 

History

14 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Interlock

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.

The ops board


The one idea

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.


What you are looking at

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.

Map mode

The line actually runs

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.

Locking a station stops the line

A locked station

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.

The timeline is the time machine

The timeline

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.

Overrides are the point of the audit log

The audit log

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.)

Side effects, or the absence of them

The outbox

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.

It boots with real numbers

The power-on self test

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.


Running it

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 dev

Open 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 corpus

The seed corpus is read from the build output, so regenerating it needs a rebuild before the seeder picks it up.


How it is put together

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.

Repository layout

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 folder web/. It is Interlock.UI/ here, by request. Everything else follows the plan's layout.

The bits worth reading first

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

Testing

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 requests

The 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.


Things that look wrong and are not

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.


How the demo stays fresh

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.


Deploying

az deployment group create \
  --resource-group <rg> \
  --template-file infra/main.bicep \
  --parameters infra/main.bicepparam

SQL_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.


Documentation

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

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages