Modeller
ArchitectureDecisions

Hosted workspace API for the playground

Expose ModellerWorkspace behind a stateless, bounded HTTP API rather than a CLI subprocess or a per-user language-server process.

Status: Accepted

Canonical terms

Workspace · Identity registry · Diagram projection

Context

The anonymous modeller.website playground (#68) needs to parse, validate, and project a visitor's RML with no host filesystem, no installed .NET toolchain, and no CLI process. Issue #70 built the transport-neutral seam (Modeller.Workspace/ModellerWorkspace) that makes this possible in-process; issue #71 hosts that seam behind an HTTP API. Before this decision, there was no ASP.NET Core project, Dockerfile, or deploy pipeline anywhere in the repository, and the two existing "run Modeller functionality as a long-lived process" precedents — Modeller.LanguageServer (stdio, stateful, spawned per browser tab by apps/studio) and apps/studio's per-request CLI subprocess shell-out — are both explicitly not the model to follow here (see workspace-application-service.mdx's Non-goals).

Decision

Modeller.Api is a new, ordinary stateless ASP.NET Core minimal API calling ModellerWorkspace directly — no subprocess, no per-user process, no session state. It depends only on Modeller.Workspace.

One endpoint, one analyzed workspace per round trip. POST /v1/workspace/analyze accepts a full workspace (documents, identity, configuration) plus zero or more projection requests, and returns diagnostics, a semantic outline, semantic-kind counts, discoverable projection roots, the effective identity registry, and a projection result per requested view. A response to an ephemeral request returns the identities minted for that analysis. A stateless client must send that registry as a durable identity strategy on a follow-up request if it uses a discovered root ID. This keeps root IDs stable without server-side session state. A projection requested in the original call still runs against the same in-memory AnalyzedWorkspace. GET /v1/workspace/supported-views exposes ModellerWorkspace.SupportedViewKinds so a client doesn't have to hardcode the issue #64 allowlist.

POST /v1/workspace/complete accepts the same bounded workspace plus a document and cursor line. It returns statements from the parser's shared RmlGrammar catalog for the current block and semantic names from that analyzed workspace. Thus, completion and parsing use one grammar authority. The supported projections are Lifecycle, RuleDecision, and Structural. A Structural projection uses the context as its root and includes entities, fields, relationships, enumerations, and members.

Response shape distinguishes three cases, matching the acceptance criteria's "stable, bounded responses" requirement:

  • Malformed/over-limit request shape (bad JSON, too many documents, an escaping path, too many projection requests) → 400, structured api.* diagnostics, never an unhandled exception.
  • Structurally valid request whose workspace fails to parse/validate → 200 with diagnostics populated and no projections — this is expected user-input feedback, not a protocol error.
  • Cancellation (client disconnect, or the server-side deadline below) → 503.

Request-shape limits (RequestLimits.cs) sit in front of ModellerWorkspace.Analyze, because nothing below the API bounds document count, per-document size, or path depth today — ParseOptions/ValidationProfile only cap aggregate characters/tokens/statements/diagnostics, identically for the CLI's local-trust callers and anonymous public traffic. This is the layer where public-traffic ceilings belong: ≤50 documents, ≤200 KB/document, ≤8 path segments, ≤10 projection requests, ≤50 roots per projection request, ≤5,000 definitions in an analyzed workspace, ≤2,000 elements (nodes+edges) in any one projected graph, and ≤5,000 elements combined across every projection in one response — tunable, not load-bearing on the design.

Two of these bounds work together rather than independently, and are easy to get subtly wrong: the aggregate response bound must compare the running total plus each candidate projection's own count, not the running total alone — checking only the total accumulated so far still lets one large graph push the combined response past the ceiling (e.g. 4,500 already accumulated plus a 1,000-element graph would otherwise return 5,500 uncaught); and the definition-count bound is checked once, before any projection runs at all, because DiagramProjector.Project (Modeller.Projections) still has no size budget of its own — a per-graph or aggregate cap alone only rejects a projection after it has already been computed, which does nothing to stop an adversarially large revision from making that computation itself slow or memory-heavy. DiagramProjector.Project does now accept a CancellationToken and observes it between definitions as it walks a revision (threaded from the pipeline's per-request deadline through ModellerWorkspace.Project), so an in-progress walk against a large revision can be aborted, not just refused before starting — but bounding the input a projection is ever asked to run against remains the primary control, since cancellation alone does not cap peak memory or per-step cost. ExceedsAggregateGraphElementLimit, ExceedsDefinitionLimit, and their paired response-builder helpers are small pure functions in WorkspaceContractMappings specifically so each bound's logic — not just its wiring — has a direct unit test.

Every request-shape check is also a null-safety guard: Nullable annotations on the request DTOs are not runtime-enforced for a JSON payload that nulls out or omits a "required" field, so RequestLimits.Validate catches that as a diagnostic rather than letting it reach a null-dereference deeper in the pipeline; RespectNullableAnnotations on the JSON options closes the same gap for explicit nulls one layer earlier, at deserialization. A request whose JSON body cannot be parsed at all (or fails deserialization for a reason RequestLimits never sees, such as an explicit null for a top-level required property) still returns the same structured WorkspaceAnalyzeResponse envelope — with a generic api.request.malformed diagnostic — rather than the framework's default empty-bodied 400, so a client can parse every 400 response the same way regardless of which layer rejected it.

Timeout, concurrency, CORS: each request is bounded by a 5-second server-side deadline (a linked CancellationTokenSource combining the client's own cancellation with a fixed timer), shorter than any platform-level function duration limit; WorkspaceOutcome<T>.Cancelled (already a first-class case from #70) maps directly to 503. The identity-application transforms (RmlCompiler.EnsureIdentities/ApplyIdentities) check that deadline per source line, not only between documents, so a large single document can't hold a deadline open past its own budget. A global concurrency-limiter (AddRateLimiter, in-box since .NET 7) caps simultaneous in-flight analyses, returning 429 past the limit. CORS is a named, configurable-origin policy (Cors:AllowedOrigins) — empty by default so nothing is cross-origin-accessible until a specific origin is configured, with appsettings.Production.json allowing https://modeller-next.vercel.app, https://modeller.website, and https://www.modeller.website for the deployed environment — and explicitly allows the Content-Type header (WithHeaders("Content-Type")), without which a browser's preflight for a JSON POST would reject the follow-up request even with the origin itself permitted. www is listed because it is the canonical host: the apex 308s to it, so every real browser request carries Origin: https://www.modeller.website — with only the apex allowed, the entire deployed playground and Initiative experience failed with an opaque "Failed to fetch". An allowlist keyed on a hostname has to include every host the site actually serves from, not just its canonical spelling in prose.

The policy also carries what the SignalR JavaScript client needs for /hubs/initiative (x-requested-with/x-signalr-user-agent in the allowed headers, plus AllowCredentials() for the client's default withCredentials); without those the realtime channel silently fails its negotiate, leaving the Initiative pages working but no longer live. Credentials are only safe to allow because the origin list is explicit and never a wildcard.

No-source-logging: structured logs carry only request metadata (document/projection counts, outcome, elapsed time, diagnostic codes) — never document content or identity registries. This is enforced as a code-level rule (verified by NoSourceLoggingTests, which submits a unique marker string and asserts it never appears in captured log output, including on a failed analysis).

Observability

Per docs/coding-standards/web-apis-and-services/seq-tracing.md, the service unconditionally registers Serilog (configured from IConfiguration, UseSerilogRequestLogging() for per-request HTTP logs) and OpenTelemetry tracing/metrics instrumenting both ASP.NET Core and outbound HttpClient calls (AddAspNetCoreInstrumentation(), AddHttpClientInstrumentation()), exported via AddOtlpExporter(). The exporter is registered unconditionally rather than gated on a configured endpoint — AddOtlpExporter() already honors the standard OTEL_EXPORTER_OTLP_ENDPOINT/OTEL_EXPORTER_OTLP_* environment variables itself (defaulting to http://localhost:4317) and fails soft (retries in the background) when no collector is reachable, so there is nothing this service needs to gate. No document content or identity registry ever appears in a log, span, or metric — logged fields are limited to document/ projection counts, byte sizes, outcome, elapsed time, and diagnostic codes; see NoSourceLoggingTests.

Retention: this service holds no state between requests and writes nothing to durable storage of its own — retention is entirely a property of wherever logs/traces/metrics are shipped (the OTLP collector and its backend), not of Modeller.Api itself. Since no collector is provisioned in this pass, there is no retention window to declare yet; whichever collector is chosen when a platform is selected (see Hosting, below) should apply its operator's standard retention policy for HTTP access logs and traces, with no exception carved out for this service (there is nothing sensitive to retain longer or purge sooner than usual, precisely because source text is never logged in the first place).

Version compatibility

The /v1/ prefix is the compatibility contract: a breaking change to the request or response shape ships as /v2/, with /v1/ continuing to serve unchanged for existing callers rather than being mutated in place. Additive, backward-compatible changes (a new optional request field, a new response field, a new diagnostic code) do not require a version bump — clients must already tolerate unknown response fields and unrecognized diagnostic codes. WorkspaceAnalyzeResponse and SupportedViewsResponse both carry their own ApiVersion field ("1.0" today) as a finer-grained payload-shape marker independent of the URL version segment, for a client that wants to detect a payload-shape change without relying on the route. There is no deployed consumer of this API yet (the website frontend integration is #68/#72/#73's work), so no compatibility window has been exercised in practice — this section states the policy the first breaking change must follow, not a retrofit of an existing one.

Hosting: container-first, platform-undetermined

Per docs/research/issue-71-hosting-options.md: build a plain, portable OCI container (ordinary Kestrel, binds $PORT) so it runs unmodified across candidate hosts — "independently of the Vercel frontend" means an independently deployable container, not necessarily a different provider. Vercel added container-Function support on 2026-06-30, making it the lowest-friction option since the frontend already lives there, but it is weeks old at the time of this decision and must be validated with a deployment spike before being relied on:

  1. Does the .NET 10 image boot and stay within the selected plan's memory/CPU limits?
  2. Can the API's application-level timeout (above) be enforced well inside Vercel's platform duration limit, with cancellation reliably observed?
  3. Do health checks, metadata-only structured logs, OTLP export, preview deploys, and rollback behave as this document requires?
  4. Is cold-start latency (scale-to-zero) acceptable for the playground?

If the spike fails any of these, or if avoiding a brand-new hosting feature outweighs one-platform convenience, fall back to Azure Container Apps (mature, revision-based rollback, scale-to-zero) or Google Cloud Run (equally mature, cloud-neutral). Do not reshape the service into Azure Functions or AWS Lambda to fit a host — #71 already asks for a portable container, and ordinary serverless container platforms preserve that design; Lambda/Functions would not.

Spike result: Vercel container Functions selected. The image boots and runs successfully as the modeller-api Vercel project, deployment status Ready, at https://modeller-next.vercel.app. Verified against the live deployment: /healthz/live200 Healthy; /v1/workspace/supported-views200; a real /v1/workspace/analyze request → 200 with no diagnostics. No fallback to Azure Container Apps or Cloud Run was needed.

src/Modeller.Api/Dockerfile is the canonical image definition, built with the repo root as context (docker build -f src/Modeller.Api/Dockerfile .). Dockerfile.vercel at the repo root — not next to it under src/Modeller.Api/ — is an intentional duplicate kept in sync by hand: Vercel's single-service container support requires the file to be named exactly Dockerfile.vercel and to sit at the project's configured Root Directory, and that Root Directory must be the repo root for the build to reach the sibling projects and root-level files the Dockerfile copies. Configure the Vercel project's Root Directory as the repo root (no vercel.json "services" block is needed for this single container); collapse the duplication with a small build script if the two Dockerfiles start to diverge.

Deployment and rollback

Build the image → tag it with the commit SHA → push to the chosen registry → deploy as a new revision → rollback by redeploying the previous tag (Vercel, Azure Container Apps, and Cloud Run all support revision-based rollback natively). For the live modeller-api Vercel project: roll back via the Vercel dashboard's Deployments list for the project (or vercel rollback from the CLI) — select the last known-good deployment and promote it back to production; this reuses the previously built image rather than rebuilding, so it is fast and does not depend on the failing commit's build succeeding. .github/workflows/api-container.yml builds and smoke-tests the image on every change to Modeller.Api and its dependencies; it does not push or deploy anywhere, since no registry credentials are configured — that wiring is explicit future work once a platform is chosen.

Consequences

  • WorkspaceAnalyzeResponse.Roots (root id/kind/name/slug for every entity-with-lifecycle and rule) is a small, deliberate addition beyond the issue's literal wording, mirroring the CLI's project --view <kind> (no root) root-listing behavior — without it, a client has no way to discover a valid projection root without already knowing the semantic id in advance.
  • WorkspaceAnalyzeResponse.Identity carries the effective registry for the analyzed documents. This is browser session state, not server persistence or an implicit workspace export. It lets a stateless client reuse discovered semantic IDs in later analysis requests.
  • Actually provisioning a cloud account, wiring the website frontend to call this API, and exposing Export/generation over HTTP are explicit non-goals of this decision; see workspace-application-service.mdx and the open items above.
  • RmlCompiler.EnsureIdentities/ApplyIdentities gained an optional CancellationToken parameter (defaulting to CancellationToken.None, so every existing caller is source- and binary-compatible) so the hosted API's per-request deadline is observed during identity application, not only between documents in a batch.

On this page