The demo being built

Roar Georgsen, 27 August 2026

Part 11 of 11 in Federating a systems model.

The demo is a SysML v2 model of a five-server query pipeline, published as a live GraphQL projection and joined at a federation router by a capacity service and a document service. A projection, here, is the small plainly typed view a consumer meets instead of the metamodel. Two web apps sit in front, and the whole thing ships as one container image that a single docker run starts. What follows describes the version being built from the plan in Planning the build, in the present tense, and will be revised when it ships.

Run it

The line below is what the first release publishes, and it works from the moment v0.1.0 is tagged and the package made public.

docker run --rm -p 8080:8080 ghcr.io/roarge/sysml-federation

Once the image is pulled, the ready line appears within ten seconds on Linux, macOS or Windows with nothing installed beyond Docker. Port 8080 carries four paths. /viewer is the model viewer, /document the requirements document, /graphql the router’s endpoint and /playground the router’s own query editor. A request to / is redirected to the viewer. Nothing is written to disk, so stopping the container and starting it again returns the shipped state, model version 1 and document version 1.

Behind the port one Go binary runs three subgraphs as goroutines, the vendor’s router as a child process, and a UI server that serves both apps and proxies the router. A subgraph, in federation vocabulary, is a service publishing a fragment of a schema, and the router is the process that merges the fragments and plans each query across the services that hold the fields it names. The subgraphs and the router listen on loopback ports and only 8080 is published, which is the decision on one binary, one process tree and one port, with the child-process router copied out of the vendor’s image rather than linked as a library.

Use case 1, launch

Use case 1, launch: the ready line, and the viewer the visitor opens once it appears, from the storyboard.

The model

The file is examples/pipeline/model.sysml and it opens package <'PIPE'> QueryPipeline. Its doc comment says what the file is careful about: throughput and capacity are query rates in queries per second, and the rollup is absent because capacity is declared without a value and computed elsewhere. Five servers, PIPE-S1 to PIPE-S5, hold the throughputs the visitor edits, one pipeline part owns them and the wiring, and seven requirement usages and one verification case sit beside them. Twelve use cases and one moving bottleneck lists every element with its short name, its value and the part that satisfies it. What matters here is how the file is put together.

An abstract part definition, Component, declares capacity : Real and nothing else, and both Server and Pipeline specialise it. A server adds throughput : Real and an input and an output port carrying a directed item, the pipeline adds latency : DurationValue, and neither capacity nor latency is given a value anywhere in the file.

The two requirement definitions share one shape, a subject, an attribute for the limit and a constraint comparing a feature of the subject with it:

requirement def ThroughputRequirement {
    doc /* The subject shall sustain at least the required query rate. */
    subject target : Component;
    attribute requiredRate : Real;
    require constraint { target.capacity >= requiredRate }
}

PIPE-R1 binds requiredRate to 1500 and takes the pipeline as its subject. PIPE-R1.1 to PIPE-R1.5 are joined to it by a #derivation connection with one #original end and five #derive ends, and their limits are expressions rather than numbers, globalThroughput.requiredRate for ingest, parse and serve and half of that for each index server. The adapter evaluates those expressions, which is why a derived limit follows an edit to PIPE-R1 and cannot be edited itself. PIPE-R2 is a LatencyRequirement with maxLatency = 200[ms] and PIPE-VC1 is a verification whose objective is verify latencyLimit. Inside the pipeline part, five connect statements give the wiring and seven satisfy statements bind each requirement to its part.

How target.capacity >= requiredRate becomes a quantity, a comparison and a limit is the decision on reading the constraint, and reading a connection’s direction from the order of its ends is another. The file is checked against the OMG pilot implementation and OpenSysML under the decision on SysML 2.0 formal as the target, and the versions that accepted it are recorded in the example’s README. From use cases to requirements treats the model and the arithmetic in full.

The viewer

The viewer at /viewer shows the model as its own text, with the language visible. A small tokeniser marks package, part, attribute, requirement, connect and satisfy in bold, numbers and quoted names such as <'PIPE-R1'> in blue, and doc bodies and comments in grey. The text is the served text, so an edit made anywhere shows up in it as a changed literal.

Beside the text is a sketch drawn from the wiring, not from anything hand placed. The five servers sit left to right, ingest, parse, indexA over indexB, serve, joined by arrows that follow the connect statements. Each box reads throughput N. The caption reads capacity 1200, bottleneck parse in the shipped state and parse is outlined red. Columns come from depth in the wiring.

Under the sketch, one block per requirement carries its verdict. A verdict is the capacity service’s answer for a requirement, one of PASS, FAIL, INCONCLUSIVE and ERROR with a reason string beside it. PIPE-R1’s block is red and reads FAIL capacity 1200 against 1500, limited by parse. PIPE-R2 reads INCONCLUSIVE PIPE-VC1 is declared and no service runs it, because the capacity service computes capacity and nothing else. The five derived blocks read PASS or FAIL with throughput N against L.

Above the text sits the edit panel with exactly six inputs: the five server throughputs and the limit of PIPE-R1. There is no control for PIPE-R2’s 200 ms, for a derived limit, or for capacity or latency. The panel is where the decision on the viewer showing text beside wiring departs from its own text, which placed the inputs inline. The projection carries no source spans, so inline inputs had nothing to anchor to, and the record is due for amendment when the apps ship. A rejected value, abc, -5 or an empty field, is named on the status line and the field returns to the served value. A Reset button restores the shipped model.

Live updates arrive over server-sent events on /graphql, as version numbers only, and the app refetches on each one, the decision on subscriptions as version events. If the stack goes away the status line reads live updates: ... and the app retries about 1, 2, 4, 8 and then 8 seconds apart until a connection succeeds. A refetch that lands while an input is focused keeps the focus. No request leaves localhost:8080, fonts included, and the page renders with the network disconnected.

The document

The document at /document shows the same requirements as a numbered document whose numbering is its own. The shipped tree is an unnumbered prose paragraph first, explaining that the rollup is idealised and that an allocated limit on a server can fail while the pipeline as a whole passes, then PIPE-R1 as 1 with PIPE-R1.1 to PIPE-R1.5 nested as 1.1 to 1.5 in server order, then PIPE-R2 as 2. The header reads document version 1, model version 1.

Each row shows what comes from the model, what comes from the analysis, and what the document itself holds. PIPE-R1’s row shows its short name and text, its limit as an input holding 1500, derives PIPE-R1.1 to PIPE-R1.5, satisfied by pipeline, current value capacity 1200, and the verdict FAIL capacity 1200 against 1500, limited by parse, outlined red. PIPE-R1.2’s row shows derived from PIPE-R1, parse throughput as an input holding 1200, and FAIL throughput 1200 against 1500. PIPE-R2’s row carries limit latency <= 200 ms as plain text and no input at all. Count the inputs on the page and there are six, the same six as the viewer.

Every row has a grip. Drag PIPE-R1.5 above PIPE-R1.1 and within two seconds it reads 1.1 and the others 1.2 to 1.5 in their former order. Drag PIPE-R2 into PIPE-R1’s list and it reads 1.6 while still showing verified by PIPE-VC1, because the relationship comes from the model and the number from the document. Heading above on PIPE-R1, with the text Performance, takes number 1 and pushes PIPE-R1 to 1.1 and its children to 1.1.1 to 1.1.5, and Add prose on the heading appends a dashed, unnumbered paragraph. Exclude on PIPE-R1.4 removes it from the tree, PIPE-R1.5 moves up to 1.1.4, a tray lists it with Restore, and the viewer still lists PIPE-R1.4 because nothing happened to the model. Excluding a node with children promotes them.

The document service holds the tree and nothing else, the decision on document-owned structure, and its shipped tree is the one place the example’s identifiers enter a service. Every edit above leaves the header reading model version 1, because a reorder, a heading, a paragraph or an exclusion is a document mutation and touches only the document’s version. The value inputs go the other way: PIPE-R1.2’s throughput set to 1700 goes through the adapter, the model version rises, and the document’s own version has not moved. Drag and drop is SortableJS, vendored as the one third-party file the decision on vanilla web apps allows.

What a visitor sees in fifteen minutes

The visitor has Docker, a browser and no manual. The walk below is the worked example of From use cases to requirements observed through the router and both apps, and the plan names it as the end-to-end proof.

The shipped state fails. The viewer’s caption says capacity 1200, bottleneck parse, PIPE-R1 is red, and the reason names parse.

Set ingest to 3000 in the edit panel and press Tab. Within two seconds the text shows 3000 where 2000 was, and nothing else moves at all. PIPE-R1.1 reads PASS with throughput 3000 against 1500, but it passed at 2000 as well. A serial chain is governed by its worst link and ingest was never it.

Ingest raised to 3000

Ingest raised to 3000 leaves the capacity and the verdict where they were, from the architecture views.

Set parse to 1700, the edit from use case 4, raise the bottleneck. Capacity rises to 1400 and the sketch outlines indexA and indexB red. The bottleneck has moved to the next weakest place in the wiring, the two index servers whose 700 and 700 sum to 1400. Set indexA to 900 and capacity reaches 1600, PIPE-R1 reads PASS and the block loses its red. PIPE-R1.4 on indexB still fails, throughput 700 against 750, because its allocated share is half of 1500 and indexA delivering more than its share covers indexB delivering less. The pipeline passes and one of its servers does not, and both verdicts are right.

The bottleneck the viewer outlines is the minimum cut, the smallest total throughput of any set of servers whose removal would sever every path from an entry server to an exit server. Capacity is the maximum flow through the wiring with each server’s throughput as its limit, and the two numbers are equal by the max-flow min-cut theorem, which is the decision on rollup as maximum flow. The values are chosen so that no tie occurs on this path. Every state of the walk, with its capacity, its cut and all six verdicts, is tabulated in From use cases to requirements.

Reset, then set the limit of PIPE-R1 to 1000, and the shipped capacity of 1200 now passes. The derived limits follow without being touched, because they are expressions over PIPE-R1’s limit.

Reset again, because the same edits work from the document. Set PIPE-R1.2’s throughput to 1700 in its row and within two seconds the row reads PASS, PIPE-R1 still fails at 1400, and the viewer tab shows 1700 in the text and capacity 1400, bottleneck indexA, indexB under the sketch, with no reload. Going the other way, edits made in the viewer land in the document’s verdict column, which is use case 10, change from the viewer and watch the document.

Use case 10, change from the viewer and watch the document

Use case 10, change from the viewer and watch the document: an edit in one app, the verdict changing in the other.

Then a reorder. Drag PIPE-R1.5 to the top of PIPE-R1’s list. The numbering changes and the header still says model version 1. The visitor has now seen the two kinds of edit go to two different services through one endpoint.

Use case 11, query the graph, is the one to run in the playground at /playground:

{ requirement(id: "PIPE-R1") { text verdict verdictReason documentNumber } }

In the shipped state the answer is {"data":{"requirement":{"text":"The pipeline shall sustain the required query rate","verdict":"FAIL","verdictReason":"capacity 1200 against 1500, limited by parse","documentNumber":"1"}}}. The text is from the adapter, the verdict and its reason from the capacity service, the number from the document service, in one object. Each service declared Requirement with the same entity key and the router joined them. An entity key is the field by which independently owned services agree that they are describing the same object, and the decision to use short names as keys is why PIPE-R1 is the string in the query. The playground’s schema explorer lists documentNumber beside verdict on Requirement as though one service had written both.

One requirement answered by three services

One requirement answered by three services in a single object, use case 11, query the graph.

The refusals are worth two minutes. setLimit(requirementId: "PIPE-R1.3", value: 900) answers an errors array whose message contains the value is not a literal in the source, because PIPE-R1.3’s limit is the expression globalThroughput.requiredRate / 2 and there is no literal to patch. setAttribute(partId: "PIPE-S1", name: "throughput", value: -5) answers the value must be a finite, non-negative number. Neither emits a version event, which a curl -N subscription to modelChanged in a second terminal confirms: it shows :heartbeat lines while idle, a data: frame carrying the new model version within a second of each successful mutation, and nothing at all after a refusal. The document’s refusals are the same shape.

Reset from either app, use case 12, restores the shipped model and the shipped tree in both tabs within two seconds, whatever was edited and wherever. Through the router the same thing is mutation { resetModel { version } resetDocument { version } }.

Reset from either app

Reset from either app returns both to the shipped state, use case 12.

Package by package

adapter/syntax is the hand-written parser of the decision on a strict subset parser: tokens and a lexer with byte spans, a typed abstract syntax tree, expressions with precedence, and a structural parser covering packages, parts, attributes, ports, connect, satisfy, requirements with subjects and constraints, and the #derivation connection. A refusal is a syntax.Error with a line and a column counted from the source bytes.

adapter/model builds the projected model from that tree, evaluates expressions with units and a cycle guard, reads the quantity, comparison and limit from each constraint, and patches a literal in the source text for setAttribute and setLimit, producing a new immutable model with a new version each time. A second model, a warehouse, is the fixture that proves the adapter carries none of the example’s words: neither adapter/ nor the capacity service may contain PIPE-, pipeline, throughput or any other word of the example, and a test walks the sources for them.

adapter/projection resolves the schema’s fields over the model and holds nothing the schema does not show. adapter/serve puts the subgraph on a port and holds the store, which hands each operation one snapshot of the current model and owns the version counter. What they publish is the curated projection of the decision on a generic projection: parts, attributes, ports, connections, requirements with their relationships and verification cases, the model’s text and its version, and nothing named after any example.

examples/pipeline/capacity/flow is pure code, the maximum flow with node capacities by Dinic’s algorithm, the source-side minimum cut, and a differential test against the recursive minimum-and-sum on series-parallel wirings. Beside it flow.Verdict and flow.Analyse carry the verdict precedence and every reason template from the decision on reason templates. The capacity subgraph itself is thin, a schema, entity resolvers and two populate functions that receive the fields the router carries through @requires, computed afresh on every read so that the service holds no copy of the model. examples/pipeline/document/tree is the editorial tree with dotted-decimal numbering, also pure, and its subgraph loads shipped.json, serves the seven document mutations and documentChanged, and owns its version counter.

Both apps and the shared client sit under examples/pipeline/ui. shared/graphql.js does one query function and one subscribe function with the backoff above. The document app runs over SortableJS 1.15.7, vendored beside its MIT licence and its checksum, and NOTICE names the copyright and the two third-party licences. cmd/sysml-federation is the supervisor: main.go with the subcommands and exit codes, serve.go starting the subgraphs and stopping them in reverse order, router.go running the vendor’s binary as a child, ui.go serving the embedded apps and proxying the router, and healthcheck.go, which the image runs in place of anything that would need a shell.

The composition is examples/pipeline/graph.yaml and the committed config.json, produced by wgc router compose outside the build and guarded by a drift test that fails when a subgraph schema and the composed configuration disagree, the decision on committed composition. That test is the README’s third condition. The line figures are the ones Planning the build set, hand-written code only: about 2750 lines of Go under adapter/, 600 for each of the two services, 700 for cmd/sysml-federation, and 900 lines of JavaScript and 300 of CSS for each web app. They are the expected scale rather than a limit, and the counts are measured and reported. The stack is Go 1.27, gqlgen v0.17.94 with Federation v2 and WebSocket subscriptions over coder/websocket, Cosmo router 0.343.1 and wgc 0.130.1. Generated files are exempt from the empty-interface rule by their own record.

The image

examples/pipeline/Dockerfile has three stages. The first is golang:1.27 on the build platform, cross-compiling cmd/sysml-federation for the target with CGO_ENABLED=0, -trimpath and stripped symbols. The second is ghcr.io/wundergraph/cosmo/router:0.343.1, used only as a source to copy from. The third is gcr.io/distroless/static-debian13:nonroot, which receives the router binary by COPY --from, the router’s Apache licence by ADD --checksum pinned to the sha256 of the file at the router’s release tag, the Go binary, and config.json and model.sysml under /app. No stage after the first runs a command, so there is no shell in the image and no QEMU in the build. That COPY --from a multi-platform image resolves the target platform rather than the build platform is the spike Five spikes before the first line describes, with --platform=$TARGETPLATFORM on the router stage as the fallback.

Four environment variables are baked in, DO_NOT_TRACK=1, COSMO_TELEMETRY_DISABLED=true, TRACING_ENABLED=false and METRICS_OTLP_ENABLED=false, the decision on telemetry off by environment. EXPOSE 8080, a HEALTHCHECK every 10 seconds after a 15 second start period that runs /sysml-federation healthcheck, an entrypoint of the binary and a default command of serve. The router logs the vendor’s own “Not recommended for Production” when it starts from a file, which the demo takes at face value, since there is no control plane and the configuration is committed.

Publishing is a GitHub Actions workflow on v* tags, the decision on publishing on tags. It builds for linux/amd64 and linux/arm64, tags the version and latest, and then reads the manifest back. For each platform it sums the compressed layer sizes and the config size and fails the run if the sum exceeds 80,000,000 bytes, the 80 MB budget in the decimal unit the registry uses. Nothing else runs in CI beyond the unit tests. The first tag is v0.1.0, after which the package is made public once, by hand, and the launch line is tried from a host logged out of GHCR.

The air-gap check is the last demonstration. The image runs with --network none and LOG_LEVEL=debug, and after 20 seconds the container reports healthy. A search of its log for posthog, wundergraph.com, otel, otlp and their kin prints nothing, and the only addresses logged are 127.0.0.1 and 0.0.0.0. Any attempt to reach an outside address would fail immediately and loudly at debug level, so an empty search is evidence of no attempt, which readiness on its own is not.

What it is not

Not a product, not a SysML v2 API implementation. The adapter reads files rather than fronting a repository, the decision on reading files, and its coverage of the language is a fraction of what the goal requires. There is no persistence across restarts, no authentication, no multi-user editing, no queueing model.

Editing through the projection is scaffolding, a decision recorded as editing as scaffolding: edits land in the served text and its version counter, never on disk, and a real deployment would write through the SysML v2 API instead. The version counter stands in for the commits of a conforming repository. Expression-bound values are read-only in both apps, and a refetch that lands while a field is focused loses an unsent keystroke. Both apps trust the router’s answer and compute nothing.

The capacity arithmetic is exact for the idealised pipeline it describes, one with perfect balancing, evenly partitionable work and no queueing, and it says so in the document’s first paragraph. For a real pipeline the number reads as an upper bound at best, and the results must not be used for capacity planning. The arithmetic was chosen to make a point about federation, and the point is that the verdict beside each requirement comes from a service that has never parsed a model file, sitting beside text from a service that has never computed anything.


Previous: Five spikes before the first line · Index: Federating a systems model

Decision records: docs/decisions · Repository: https://github.com/Roarge/sysml-federation