Skip to content

Ports and wire contracts ​

One process, two ports.

PortWhat it carries
5526Coordination — the SPA, the plan/agent/memory API, the SignalR hub, the MCP endpoint
5525The run console — ingestion, the run list and detail, exports, the Event Model wire

Both are server-side defaults applied in Program.cs, not launchSettings.json entries. That distinction is load-bearing: launchSettings.json is a dotnet run file no packaged tool ever reads, so a port stated only there is a port the shipped host does not bind. The console learned it the hard way — 5525 lived only in launch settings, the packaged tool fell to Kestrel's bare :5000, and it was simultaneously invisible to every publisher (they all probe 5525) and squatting on the port every other ASP.NET default host on the box wants. Stoat's 5526 had exactly the same defect until the fold.

They are defaults, so ASPNETCORE_URLS still wins — and it has to, because the command line cannot reach this: RunJasperFxCommands wraps an already-built WebApplication in a PreBuiltHostBuilder, and NetCoreInput.ApplyHostBuilderInput returns early for one, which makes --config:urls=… silently inert.

Why 5525 was not collapsed into 5526 ​

Because it is the address every publisher already probes:

  • MonitorPublisher.DefaultUrl in Bobcat
  • the BOBCAT_MONITOR_URL default
  • EventModelWatchPlan.DefaultConsoleUrl, and therefore stoat watch-event-model
  • the Vite dev proxy
  • CritterWatch's spec-evidence reader

Moving it would break all of them to save a line of configuration.

The console's HTTP surface ​

These are public wire contracts, not internal list models. An external consumer has no assembly reference to warn it when a shape changes.

Route
GET /api/pingThe probe. A publisher calls it once at startup with a tight timeout and goes no-op for the whole run if nothing answers.
POST /api/ingestBatches of monitor events. The only ingestion path.
GET /api/runsThe run list. Carries the tag, outcome counts and scenario progress; takes ?tag=.
GET /api/runs/{runId}Per-scenario detail, including lanes, recycles, worker faults, stalls, progress and touched-type evidence.
GET /api/runs/{runId}/export?format=ctrf (default), junit, or ndjson — the raw archive.
DELETE /api/runs/{runId}Eject one run.
DELETE /api/runsEject a set, narrowed by ?olderThan= / ?exceptRunId=, returning the ids it actually took.
GET /api/event-modelThe merged Event Model descriptor.
PUT /api/event-model/{source}A push. Names its source and replaces only that source — see below.

Plus the SignalR hub at /api/messages on the coordination side, and MCP at /api/mcp.

Ejection is not deletion ​

A run evicted from the board still has its NDJSON archive on disk; it moves to ejected/. Only the age policy ever deletes a file. That is what makes automatic eviction reasonable to ship at all, and it is the same promise the manual Eject button has always made.

Two invariants go with it: a live run is never taken — not caution, it does not work, because the publisher's next event recreates the entry — and an orphan is fair game, because its publisher is gone by definition.

One model, several producers ​

A model has producers compiled into different assemblies: the running application knows its Wolverine chains and its store's projections, and a Bobcat spec assembly knows the slices its scenarios declare. Neither can see the other.

So a push names its source and replaces only that source, and GET serves the merge. Latest wins erased one of them every time. The merge is keyed on the model name, which is why [assembly: EventModelName("…")] is load-bearing rather than cosmetic — the descriptor sources must agree on the name or they float apart instead of folding.

A push is refused when the console is serving a differently-named model: GET merges only the sources naming the current one, so publishing anyway would hide the other half rather than join it.

The publisher's side ​

Everything a Bobcat run emits — the events, the transport, the env vars, the seams — is documented in Bobcat's own repository, because that is where it ships:

Bobcat references nothing here, and Stoat references nothing in Bobcat. Not one-way — zero. An MIT repository cannot depend on a BSL one, and keeping the dependency at zero rather than one-way makes it impossible to get wrong by accident. The wire shape, not an assembly, is the contract; the two copies of MonitorEvents.cs are kept honest by ContractRoundTripTests on this side.

That is precisely what let a BSL console absorb an MIT viewer without either side acquiring a reference to the other.

Remote consoles ​

Stoat.Console still stands alone as stoat-console for a remote deployment. A Stoat pointed at one sets Stoat:BobcatUrl and goes over the wire; otherwise run observation reads the registry in memory. Note that the standalone console serves no MCP — the run tools live in Stoat because LicenseGuard does, and the reference runs Stoat → Stoat.Console and never back, so an endpoint served from there could only ever be an ungated copy of a licensed surface.

Source-available under the Business Source License 1.1.