# ASysT — API reference (native API + OMG SysML v2 interop)

The ASysT engine (`asyst-engine`, Go) exposes two HTTP surfaces:

1. **ASysT's own REST + WebSocket API** (most of this doc) — what the React frontend and automation use
   (diagram export, CI, scripting).
2. A **thin, read-only [OMG SysML v2 API](#omg-sysml-v2-api-interop)-shaped interop layer**
   (`/api/sysmlv2/…`) so *other* SysML v2 tools can read an ASysT model through the standard surface.

> Note: this is the API reference (it documents both surfaces); the SysML v2 interop is the last
> section, not a separate document.

**Guiding principle:** the LSP owns semantics, the engine owns files, and ASysT owns modeling interaction
plus presentation. Explicit `.sysml` edits use the validated Source Editor; rendering sidecars remain a
separate persistence path.

- **Base URL (dev):** `http://localhost:8080`
- **Health/probe:** `GET /health` and `GET /api/health`
- **`file` parameter:** a path or `file://` URI to a `.sysml` model. Windows drive paths (`E:\…`) are
  accepted. URL-encode it in query strings.
- **Errors:** non-2xx responses are JSON `{ "status": "<code>", "message": "<detail>" }`. **`status` is the
  stable vocabulary — branch on it, never on `message`.** `message` is prose for a human and is *redacted*
  before it leaves the engine: filesystem paths, `file://` URIs, query strings and internal Go type names
  are replaced with `<path>` / `?<query>` / `<internal>`, and a long detail is clipped with `…`. The
  unredacted text is written to the engine log, which never leaves the machine.
- **Request size:** every `POST`/`PUT`/`PATCH` body is capped — **8 MiB by default**, 32 MiB for
  `/api/file`, 1 MiB for `/api/client-log`, 64 KiB for `/api/license`. An over-budget body is refused with
  `413` and `{ "status": "request_too_large", "limitBytes": <n> }` **before the handler runs**, so a
  refused request never writes a file, a sidecar or a journal entry.

---

## Table of contents

- [Endpoint summary](#endpoint-summary)
- [Rendering automation](#rendering-automation)
- [Diagram export](#diagram-export)
- [Project interchange export](#project-interchange-export)
- [Model report & baseline](#model-report--baseline)
- [Validation & diagnostics](#validation--diagnostics)
- [Variant management (PLE)](#variant-management-ple)
- [Core read endpoints](#core-read-endpoints)
- [Mutating endpoints](#mutating-endpoints)
- [WebSocket — `GET /ws`](#websocket--get-ws)
- [OMG SysML v2 API (interop)](#omg-sysml-v2-api-interop)
- [Proposed additions (not yet implemented)](#proposed-additions-not-yet-implemented)

---

## Endpoint summary

This table is exhaustive and machine-checked against `asyst-engine/server/routes.go`. Use one exact
method/path pair per row. `internal` means the route is intentionally reserved for ASysT's own surfaces;
it does not mean the route may go undocumented.

| Method | Path | Audience | Purpose |
|---|---|---|---|
| GET | `/api/bundled` | public | List every bundled model offered by the launcher |
| GET | `/api/demo` | public | Read the stable launch-default manifest |
| GET | `/api/diagnostics` | public | Read whole-model problems without a WebSocket |
| GET | `/api/export` | public | Render a model to SVG |
| GET | `/api/features` | public | Read entitlements and installed-license status |
| GET | `/api/files` | public | List `.sysml` files under the project root |
| GET | `/api/fs/list` | public | List folders for the in-app project picker |
| GET | `/api/graph` | public | Read one file as graph JSON |
| GET | `/api/health` | public | Read liveness, build identity and LSP status |
| GET | `/api/image` | public | Retrieve one model-scoped drawing image |
| GET | `/api/interchange` | public | Export a manifest-defined model as a `.kpar` archive |
| GET | `/api/layout` | public | Read a saved layout sidecar |
| GET | `/api/manifest` | public | Read the manifest-defined model and package index |
| GET | `/api/model-graph` | public | Read one unified, cross-file model graph |
| GET | `/api/model/changes` | public | Compare two model revisions through the derived change ledger |
| GET | `/api/model/revision` | public | Read the whole-model revision |
| GET | `/api/model/source-revision` | public | Read the optimistic source-editing digest |
| GET | `/api/model/watch` | public | Long-poll the whole-model revision |
| GET | `/api/project` | public | Read the current project root |
| GET | `/api/projection` | public | Read an FQN-anchored projected subgraph |
| GET | `/api/rendering/capabilities` | public | Discover rendering ownership, operations and QA semantics |
| GET | `/api/rendering/schema` | public | Read the exact embedded `.asystrendering` schema |
| GET | `/api/renderings` | public | List named renderings saved for a model |
| GET | `/api/report` | public | Publish an HTML report or baseline bundle |
| GET | `/api/search` | public | Find elements model-wide by name, kind, FQN or documentation |
| GET | `/api/session` | public | Read browser navigation and camera state |
| GET | `/api/source-file` | public | Read one complete source file |
| GET | `/api/source-location` | public | Resolve an element to its exact source selection |
| GET | `/api/sysmlv2/projects` | public | OMG-shaped interop: list models as Projects |
| GET | `/api/sysmlv2/projects/{projectId}/elements` | public | OMG-shaped interop: list a Project's elements |
| GET | `/api/sysmlv2/projects/{projectId}/elements/{elementId}` | public | OMG-shaped interop: read one element |
| GET | `/api/variants` | public | Read the feature model and saved configurations |
| POST | `/api/client-log` | internal | Persist the browser's bounded action/trace batch |
| POST | `/api/file` | public | Upload a model into the project |
| POST | `/api/file/open` | public | Open an existing model into a working copy |
| POST | `/api/image` | public | Copy a dropped image into the model's `Images/` folder |
| POST | `/api/layout` | public | Write a presentation-only layout sidecar |
| POST | `/api/license` | public | Verify, install and hot-reload a license keyfile |
| POST | `/api/manifest` | public | Scaffold a manifest for a plain folder |
| POST | `/api/model/commands/apply` | public | Apply a planned semantic command at its exact revision |
| POST | `/api/model/commands/plan` | public | Plan semantic intent without writing |
| POST | `/api/model/create` | public | Create a validated manifest-defined model |
| POST | `/api/model/edit` | public | Validate and atomically apply source edits |
| POST | `/api/model/facts/apply` | public | Apply a bounded direct model-fact command |
| POST | `/api/model/annotation-target` | public | Set what a note is about, or detach it |
| POST | `/api/model/file` | public | Add one new `.sysml` file to the open model |
| POST | `/api/model/file/delete` | public | Report what removing one `.sysml` would cost, or move it to the model's trash |
| POST | `/api/model/file/rename` | public | Rename one `.sysml` of the open model |
| POST | `/api/model/history/redo` | public | Redo a source-specific history snapshot |
| POST | `/api/model/history/undo` | public | Undo a source-specific history snapshot |
| POST | `/api/model/workspace/apply` | public | Publish one atomic transaction across affected files |
| POST | `/api/model/workspace/history/redo` | public | Redo one atomic workspace transaction |
| POST | `/api/model/workspace/history/undo` | public | Undo one atomic workspace transaction |
| POST | `/api/project` | public | Switch the current project root |
| POST | `/api/rendering/restamp` | public | Refresh which model an arrangement belongs to, changing no other byte |
| POST | `/api/session` | public | Persist browser navigation and camera state |
| POST | `/api/validate` | public | Run the whole-model zero-reported-errors parser gate |
| POST | `/api/variants/config` | public | Save a named variant configuration |
| POST | `/api/variants/config/delete` | public | Delete a named variant configuration |
| POST | `/api/variants/resolve` | public | Resolve a 150% model to a selected 100% graph |
| POST | `/api/variants/validate` | public | Check feature selection cardinalities and constraints |
| POST | `/api/views/delete` | public | Delete a named rendering |
| POST | `/api/views/duplicate` | public | Duplicate a named rendering |
| POST | `/api/views/rename` | public | Rename a named rendering |
| GET | `/ws` | public | WebSocket diagnostics, open-file and view broadcasts |

---

## Rendering automation

ASysT exposes the same presentation document the canvas uses. The ownership boundary is deliberate:

- **SysML owns meaning:** elements, relations, attributes, documentation, declared views and `expose`s.
- **A named rendering owns appearance:** coordinates, sizes, routes, colours, disclosure, notes, frame and
  component-specific table/sequence state.

Start every automation against the running engine's self-description:

```text
GET /api/rendering/capabilities
GET /api/rendering/schema
```

`capabilities` names the current endpoints, parameter meanings and capture/export distinction.
`schema` returns the canonical closed JSON Schema embedded in the binary. It is the maintained parity
contract with the canvas, including per-diagram positions/packages/edges, element styles, notes, extraction
levels, port presentation and table/sequence state. A client should validate its document against this
endpoint rather than carry a copied field list.

The read/edit cycle is:

```text
GET  /api/renderings?file=<model>
GET  /api/layout?file=<model>&view=<rendering-path>
POST /api/layout
Content-Type: application/json

{
  "uri": "E:/models/TestArchitecture/architecture.sysml",
  "view": "TestArchitecture/TeachingViews/testLandscape/view/docs-test-landscape",
  "layout": {
    "version": 2,
    "nodes": {},
    "viewNodes": {
      "view:TestArchitecture::TeachingViews::testLandscape": {
        "TestArchitecture::TestLandscape::1 - Static analysis and contracts": { "x": 80, "y": 220 }
      }
    },
    "viewColors": {
      "view:TestArchitecture::TeachingViews::testLandscape": {
        "TestArchitecture::TestLandscape::1 - Static analysis and contracts": "#2563eb"
      }
    }
  }
}
```

`view` in the layout API is the slash-separated **rendering path** below `Rendering/`, without the
`.asystrendering` suffix. The POST writes YAML atomically and records layout-only history; it never edits
`.sysml`. For manifest models, renderings live at model root and are shared by every member file.

Per-diagram buckets are keyed by the canvas diagram id, for example
`view:TestArchitecture::TeachingViews::testLandscape`. Prefer `viewNodes`, `viewEdges`, `viewPackages`,
`viewColors`, `viewElementStyles` and `viewNotes`; their unprefixed counterparts are shared fallbacks.
Package member coordinates are relative to the owning package, unlike flat diagram coordinates.

### Capture is not export

A **capture** records the interactive browser surface a user sees; it is QA evidence and may include
viewport limits. An **export** creates the deliverable and is not limited by the visible viewport. For a
source checkout, `node scripts/capture-canvas.mjs --help` exposes deterministic target, viewport, package
overview, product PNG export and quality-audit controls. The audit can fail on overlap, cramped gaps,
edge-label collision, clipping and broken package containment. Documentation should normally embed the
vector SVG from `/api/export`; use capture to inspect that the live canvas agrees.

---

## Diagram export

Render a model to a standalone **SVG** — the headless counterpart of the in-app *Export PNG* button
(no browser, no manual click). It runs the same model→graph pipeline as `/api/graph`, then positions
nodes from the saved layout so the export reflects the view you arranged.

```
GET /api/export?file=<uri>[&format=svg][&view=<declared-view>][&rendering=<saved-path>]
    [&viewKey=<diagram-id>][&packageOverview=true][&context=<part>][&sequence=<name>][&download=<filename>]
```

| Param | Default | Meaning |
| ----- | ------- | ------- |
| `file` | — (required) | Path / `file://` URI of the `.sysml` model |
| `format` | `svg` | Output format. Only `svg` today (see *Proposed additions*) |
| `view` | — | A declared SysML **`view` name or FQN**; selects semantic content |
| `rendering` | `view` (compatibility) | Named rendering path; selects presentation independently from semantic content |
| `viewKey` | derived from declared view | Exact per-diagram bucket inside the rendering, for advanced/non-standard surfaces |
| `packageOverview` | `false` for a declared view | `true` retains owning package containers and uses relative `viewPackages` geometry |
| `context` | — | Render the **IBD** (internal structure) of this part instead of the BDD |
| `sequence` | — | Render the named **sequence diagram** (lifelines + messages) instead of the graph |
| `download` | — | If set, adds `Content-Disposition: attachment; filename=…` |

**Frame & vocabulary:** every export—including element tables and sequences—is wrapped in the same SysML
view frame used on screen: `«view» Name`. A model-declared view prints its resolved `: ViewDef`; an
ad-hoc package, IBD, table or sequence export uses the right-hand kind badge instead. Boxes use the
canvas's SysML v2 stereotypes («part def», «part», «state def», …). Initial/final pseudo-states draw as
**dot figures**. The IBD's context is the frame itself (no context box), exactly like the canvas boundary.

**Views project model-wide:** when `view` names a SysML `view` element and the model has an
`asyst.manifest.json`, its `expose`s resolve against the **whole-model graph** (cross-file elements
included; `X::**` exposes the FQN subtree). A view that resolves to nothing is a loud `422 view_empty`,
never an empty picture — and a view whose exposes are **only packages** is refused with a sentence that says
so, rather than one about a missing manifest: a diagram draws elements, so expose what is inside a package
(`expose Pkg::**`) rather than the package itself.

**Layout fidelity:** saved layouts are read from the **model-root** rendering sidecars: the IBD takes
its own per-diagram bucket (the arrangement you saved in the app), and `rendering=X` resolves through the same
four-step read the app uses — the current `Rendering/<FQN-as-folders>/<lens>/<name>.asystrendering` first,
then the two earlier forms, then `*.layout.yaml`. A saved layout covering most of the
graph wins outright and only the positioned nodes draw (mirroring exactly what you arranged —
deliberately unplaced nodes stay hidden); a sparse one seeds its anchors and auto-packs the rest below;
with none, every node is auto-placed (grid, or flow for state machines / activities). With
`packageOverview=true`, only package/boundary ancestors of the projected content are retained and the
export reads relative positions and sizes from that diagram's `viewPackages` bucket.

**Response:** `200 OK`, `Content-Type: image/svg+xml`. On failure, a JSON error (e.g. `analysis_failed`,
`graph_failed`, `ibd_unavailable`, `view_empty`, `unsupported_format`).

**`X-ASysT-Export-Warnings` — what this export left out** (since 2026-08-08). A successful export can still
be missing something, and until now it said nothing about it: an arrangement file that cannot be parsed makes
the whole diagram fall back to automatic placement, and a saved arrangement that covers most of the board
**culls** everything it does not position, so one key gone stale after a rename deletes a box from a picture
that still looks perfectly normal. Both cases now put a plain sentence — naming the arrangement, or naming
each element that was left out — into this response header, as a JSON array of strings, and into the engine
log. The header is **absent** when nothing was dropped, so its mere presence is the signal. It never changes
the status code and never withholds the image: the picture stays usable, it just stops pretending to be
complete.

### Examples

```bash
# Raw HTTP (engine already running):
curl "http://localhost:8080/api/export?file=E:/_Work/_WorkASysT/Demo/animal-relationships-bdd/animal-relationships-bdd.sysml" -o animal.svg

# Internal block diagram of one part:
curl "http://localhost:8080/api/export?file=E:/_Work/_WorkASysT/Demo/CrisisResponse_V2/Model/CrisisResponse.sysml&context=CrisisSystem" -o crisis-ibd.svg

# Declared content + independently named rendering + package containment:
curl "http://localhost:8080/api/export?file=E:/models/TestArchitecture/architecture.sysml&view=testLandscape&rendering=TestArchitecture/TeachingViews/testLandscape/view/docs-test-landscape&packageOverview=true" -o test-landscape.svg
```

### CLI

`./asyst.cmd export` wraps the endpoint and starts the backend if it isn't already running (stopping it
again afterwards if it had to):

```bash
./asyst.cmd export <file.sysml> [-o out.svg] [--view <name>] [--context <part>] [--sequence <name>]

# e.g. regenerate the architecture figure in the docs:
./asyst.cmd export _Docs/_Master_Doc/.sysml/asyst-architecture/asyst-architecture.sysml -o _Docs/_Master_Doc/images/asyst-architecture.svg

# a sequence diagram (timeline), from a file that declares one:
./asyst.cmd export <file.sysml> --sequence <SequenceName> -o timeline.svg

# batch: render every .sysml under a folder (default Demo) into an output dir:
./asyst.cmd export --all Demo -o build/diagrams
```

---

## Project interchange export

Export the current manifest-defined model, or the manifest owning a selected model file, as a deterministic
ZIP-compatible `.kpar`:

```
GET /api/interchange[?file=<uri>]
```

The response is `200 OK`, `Content-Type: application/zip`, with an attachment filename derived from the
manifest's model name and version. The archive contains `.project.json`, `.meta.json`, and the manifest's
textual `.sysml` files. `.meta.json` records the normative metamodel URI
`https://www.omg.org/spec/SysML/20250201`, the package-to-file index, and a SHA-256 checksum for every model
file. Equal model snapshots produce byte-identical archives.

The endpoint requires a valid `asyst.manifest.json`. It refuses an unsafe member path or any textual member
whose extension is not `.sysml`; errors use the normal JSON error envelope. This is an export surface only:
ASysT does not currently import `.kpar` or ingest models represented directly as abstract-syntax instances.

```bash
curl "http://localhost:8080/api/interchange" -o model.kpar
curl "http://localhost:8080/api/interchange?file=E:/models/System.sysml" -o system.kpar
```

---

## Model report & baseline

Publish a whole model as a **shareable document** / **versioned snapshot** — the deliverable side of
ASysT. The in-app **Export ▾** menu surfaces both; the endpoint is:

```
GET /api/report?file=<uri>[&format=html|zip][&print=1]
```

- **`format=html`** *(default)* — a **self-contained HTML report**: model identity + version,
  element/relation counts, the diagram (inline SVG, using your saved layout), a **requirements table**
  (each requirement with its statement + what satisfies / verifies it), and an **element index**. The
  app opens it in a **new window**. The report carries a **"Save as PDF / Print"** toolbar button.
- **`print=1`** — the report opens the browser's **print dialog automatically** → choose *Save as PDF*.
  This is ASysT's **PDF path**: it uses the browser's own print engine, so no Chromium / wkhtmltopdf is
  bundled into the binary (keeping it small and the toolchain free). The **Export ▾ → Save as PDF** menu
  item opens `…&print=1` in a new window.
- **`format=zip`** — a **baseline bundle**: `report.html` + **every source `.sysml` file** + the
  `asyst.manifest.json`, named `<modelName>-baseline-<timestamp>.zip`. A complete, archivable model
  version (the "dump of the project").

For a manifest model the report spans the whole cross-file model; for a standalone file it covers that
file. Read-only — it never edits `.sysml`. Code: `asyst-engine/server/handlers/report.go`.

---

## Validation & diagnostics

Diagnostics already stream over the [WebSocket](#websocket--get-ws) for the open file; these two endpoints
make the same data **available without a socket** — one-shot, model-wide, and CI-friendly. The LSP produces
the problems; ASysT only surfaces them. A zero-error response means the pinned parser reported no errors;
it does not prove reference integrity, semantic correctness or standards conformance. Both locate "the model" exactly like
[`/api/manifest`](#get-apimanifest): a file inside a manifest model is checked **whole** (every file
opened together, so cross-file references resolve and don't read as false "unresolved" errors); a
standalone file is checked on its own.

### `GET /api/diagnostics`

```
GET /api/diagnostics?file=<uri>
```

Returns every file's problems, grouped — the data behind the in-app **model-wide problems panel**.

```json
{
  "status": "ok",
  "scope": "model",
  "root": "E:/…/CrisisResponse",
  "summary": { "errors": 29, "warnings": 2, "information": 0, "hint": 1, "files": 118, "filesWithProblems": 18 },
  "files": [
    { "path": "E:/…/Model/10_core/AerialResponseUnits.sysml", "relativePath": "Model/10_core/AerialResponseUnits.sysml",
      "uri": "file:///…", "diagnostics": [ { "range": { "start": { "line": 0, "character": 22 } }, "severity": 1, "message": "mismatched input '::'…" } ] } ]
}
```

`scope` is `model` (a manifest model — `files` lists **every** model file, clean ones included, each
with its `relativePath` from `root`) or `file` (a standalone file — one entry). `summary` is the
severity roll-up plus file counts. Each diagnostic carries its LSP `severity` (1 error · 2 warning ·
3 information · 4 hint; absent ⇒ error) and 0-based `range` for click-to-reveal in source. Errors:
`missing_file` (400), `invalid_file` (400), `analysis_failed` (502).

**Tier file cap.** Diagnostics obey the same model-scale entitlement as the graph: a capped tier checks
the first `maxModelFiles` files and says so, in the same vocabulary the graph uses. Both
`/api/diagnostics` and `/api/validate` then carry `"totalFiles"`, `"truncated": true` and a
`warnings` entry with code `license.model_files_truncated`. On an unlimited tier all three are absent.
A shorter problem list with no explanation would read as a cleaner model, which is why the cap is
reported rather than merely applied.

### `POST /api/validate`

```
POST /api/validate            body: { "file": "<path or file:// uri>" }   ( "path" and ?file= also accepted )
```

The same model-wide check projected down to a parser **verdict** — useful in CI or a pre-commit hook.
`valid` is `true` when this diagnostic run contains **zero errors**; warnings do not change the field.
Present it as a parser-gate result, never as a semantic certificate.

```json
{ "status": "ok", "valid": false, "scope": "model", "root": "E:/…/CrisisResponse",
  "summary": { "errors": 29, "warnings": 2, "information": 0, "hint": 1, "files": 118, "filesWithProblems": 18 },
  "problemFiles": ["Model/10_core/AerialResponseUnits.sysml", "Model/40_behavior/BroadcastAlert.sysml"] }
```

Code: `asyst-engine/server/handlers/diagnostics.go`.

---

## Variant management (PLE)

ASysT's product-line-engineering surface: read a model's **variability** (variation points / variants /
selection constraints), configure it, and **resolve** the 150 % superset to a 100 % product. Read-only,
except the configuration **sidecar** writes (`*.variants.yaml` — ASysT config, never `.sysml`). Models are
located like [`/api/manifest`](#get-apimanifest); the deep dive is
[Variant_management](../feature_component/ASysT_Variant_management.md).

### `GET /api/variants?file=`

The variability (feature) model + saved configurations.

```json
{
  "status": "ok",
  "root": "E:/…",
  "model": {
    "variationPoints": [
      { "id": "…::hvac", "name": "hvac", "kind": "part", "type": "HVACSystem", "group": "alternative",
        "variants": [
          { "id": "…::hvac::heatPump", "name": "heatPump", "type": "HeatPumpSystem" },
          { "id": "…::hvac::centralAir", "name": "centralAir", "type": "CentralAirSystem",
            "children": [ { "id": "…::coolingCapacity", "name": "coolingCapacity", "group": "alternative", "variants": [ … ] } ] } ] } ],
    "constraints": [ { "name": "selectionConstraint", "expression": "(hvac == hvac::heatPump and solarPanels != null) xor (hvac == hvac::centralAir)" } ]
  },
  "configurations": [ { "name": "HeatPumpHome", "selections": { "…::hvac": ["…::hvac::heatPump"] } } ]
}
```

`group` is `alternative` (choose one) · `optional` (at most one) · `or` (any). A variant's `type` is the
element it binds; `children` are variation points nested under it.

### `POST /api/variants/validate` · `POST /api/variants/resolve`

Body: `{ "file": "<uri>", "configuration": { "name"?: "…", "selections": { "<pointId>": ["<variantId>"] } } }`.

`validate` returns `{ status, validation }`; `resolve` adds the pruned graph:

```json
{ "status": "ok",
  "validation": {
    "valid": true, "complete": false,
    "issues": [ { "pointId": "…::HVACChoices", "pointName": "HVACChoices", "group": "alternative",
                  "selected": 0, "severity": "info", "message": "a variant must be selected" } ],
    "constraints": [ { "name": "selectionConstraint", "expression": "…", "status": "indeterminate" } ] },
  "dropped": ["…::CentralAirSystem"],
  "graph": { "nodes": [ … ], "edges": [ … ] }
}
```

`valid` is false only on a **hard** problem (a group over-filled, or a constraint **violated** — never
*indeterminate*); `complete` is false while any active choice is pending. `graph` (resolve only) is the
100 % product: the unified graph minus `dropped` (the unselected variants' bound types and anything left
exclusively owned by them).

### `POST /api/variants/config` · `POST /api/variants/config/delete`

Save (upsert) / delete a named configuration in the model's `*.variants.yaml` sidecar. Body:
`{ "file", "configuration" }` / `{ "file", "name" }`. Both return `{ status, configurations: [...] }`.

Code: `asyst-engine/server/handlers/variants.go`, `asyst-engine/variants/`.

> **Entitlements are live.** The report, baseline, 4× PNG export, SVG/headless export, variants and the
> named-rendering cap are all gated. The UI reads one entitlement seam
> (`asyst-modeler/src/lib/features.ts`) and the engine enforces the same grants server-side, so a client
> that ignores the seam gains nothing. `GET /api/features` reports the active grants and `POST /api/license`
> installs a signed keyfile (verified before it is persisted, then hot-reloaded).

---

## Core read endpoints

### `GET /api/health`

```json
{ "status": "ok", "lsp": "running", "mode": "dev", "version": "<the running build>" }
```

`mode` is `dev` (run from source) or `release` (packaged build with the frontend embedded).

<!-- The version is deliberately NOT a literal here. This sample carried "0.1.1" for thirty-six releases
     and read as a current fact to anyone consulting the API reference; a version retyped into a document
     is a copy nothing regenerates and nothing checks (asyst-DOC1). The engine reports whatever it was
     built with — `dev` when run from source — and `asyst-vscode/package.json` is the only declaration. -->
The value is whatever the running engine was built with (`dev` from source), never a number this
document maintains.

### `GET /api/graph`

```
GET /api/graph?file=<uri>[&context=<part>]
```

Returns the graph as JSON: `{ "nodes": [...], "edges": [...], "views"?: [...], "sequences"?: [...] }`.
Node fields include `id`, `name`, `kind` (`package` | `block` | `usage` | `interface` | `requirement` |
`enumeration` | `state` | `pseudo_state` | `action` | `decision` | `fork` | `constraint` |
`analysis_case` | `verification_case` | `use_case` | `actor` | `connection`), `attributes`, `ports`,
optional `doc`, `parentId`, `hasInternalParts`, and `state` details. Edge `kind`s include the SysML
relations plus `allocation` (`allocate a to b`), `include` (`include use case x`) and `binding`
(`bind a.x = b.y`). `views` lists the model's
own SysML `view`s (`{ name, exposes: [...], rendering?, definition? }`); `definition` is the resolved
declared type used by the frame's `: ViewDef` suffix. `sequences` lists interactions as
`{ name, participants: [{name, type?}], messages: [{from, to, payload?, name?, occurrence}] }` (the
frontend's linear timeline renderer). With `context=<part>` it returns that part's **IBD** graph instead.

### `GET /api/model-graph`

```
GET /api/model-graph?file=<uri>[&depth=<n>]
```

The **unified, package-rooted graph** for the manifest-defined model the file belongs to: the engine
opens **every model file at once in the LSP** (workspace resolution, so references resolve *across*
files), then assembles them into **one graph over a shared node space** — so a part typed by a `part
def` in another file, or a `:>` across packages, draws as a real edge. Nodes are deduped by FQN `id`,
edges by `id`, `views`/`sequences` by name. Same JSON shape as `/api/graph` plus `maxDepth`. The model
is located by walking up from `?file=` to the nearest `asyst.manifest.json` (or the project root),
exactly like [`/api/manifest`](#get-apimanifest).

- **`depth`** *(progressive expand)* — keep only the entry package outward `n` rings (a node's distance
  is its package's load order). Omitted ⇒ whole model. `maxDepth` in the response is the model's range.
- **Caching** — the assembled graph is memoised per model root and validated by a fingerprint of the
  files (path + size + mtime) and the manifest identity, so an unchanged model is served without
  re-opening every file (a ~250× speed-up on the CrisisResponse demo: ~1.8 s → ~7 ms).
- **Layout** is **model-level** for manifest models: the sidecars live under `<modelRoot>/Rendering/`, one
  named `.asystrendering` file per rendered surface, so opening any file of the model finds the same saved
  arrangements (see [Model_manifest §6](../feature_component/ASysT_Model_manifest.md#6-implemented-now-vs-next)).
  The old `<modelName>.layout[.<view>].yaml` form is read once for back-compat and removed on the next save.

Read-only — analysis and discovery never edit `.sysml`. When the file is **not** part of a manifest
model the response is `{ "status": "no_manifest" }` (the caller falls back to `/api/graph`); a
present-but-invalid manifest yields `{ "status": "error", "message": … }`. Files that fail to analyse
are skipped so one bad file doesn't sink the whole model.

> Known limit: cross-file resolution is as good as what the LSP returns plus name-based reconciliation
> in the unified mapper — two elements sharing a simple name across packages can mis-resolve, and
> depth scoping is by package membership (behaviours / un-packaged elements stay at depth 0). FQN-keyed
> resolution is the next refinement.

### `GET /api/search`

```
GET /api/search?file=<uri>&q=<query>[&limit=<n>]
```

Finds elements anywhere in the model by **name, kind, qualified name (FQN) or documentation** — the data
source for the in-app search box (the companion to the package explorer). It searches the **whole** model
(the cross-file unified graph for a manifest model, else the single file), independent of the current
canvas scope, and caches the assembled model graph so repeated searches don't re-open every file in the
LSP. Matching is case-insensitive; results are ranked best-match first (exact name › name prefix › name
substring › FQN › kind › doc), capped at `limit` (default 50). Read-only.

```json
{
  "query": "wheel",
  "results": [
    { "id": "Vehicle::BaseWheel", "name": "BaseWheel", "kind": "block", "matchedOn": "name" },
    { "id": "Parts::Wheel",       "name": "Wheel",      "kind": "block", "matchedOn": "name" }
  ]
}
```

`matchedOn` is which field matched (`name` | `fqn` | `kind` | `doc`). A blank `q` returns
`{ "results": [] }` without touching the LSP. The UI reveals the chosen element in its `.sysml` source on
click (`revealInVSCode`).

### `GET /api/layout`

```
GET /api/layout?file=<uri>[&view=<name>]
```

Returns the saved layout document `{ version, viewpoint?, nodes: {id:{x,y}}, edges? }` plus `sha256` —
the exact revision of the `Rendering/**.asystrendering` file it read, `""` when
there is no such file. Omitting `view`, or asking for `default`, reads the board's own **unnamed working
arrangement** (`Rendering/default.asystrendering`), which is a real file like any other: it is simply never
listed by `/api/renderings` and never counted against the named-rendering entitlement. Only the *older*
unnamed forms (a flat `Rendering/<stem>.asystrendering`, a legacy `<base>.layout.yaml`) stay ignored.

### `POST /api/layout`

```
POST /api/layout   { uri, view, layout, expectedSha256? }
```

Writes one arrangement file. **`expectedSha256` is the revision the write was composed against**, and its
three states are different claims: a digest means *"I loaded exactly this"*, `""` means *"the engine told
me there is no such file"*, and **omitting the field** means *"I never looked"* — accepted only when the
file does not exist yet. An overwrite whose digest no longer matches is refused `409 presentation_conflict`;
an overwrite that sends nothing is refused `400 missing_presentation_revision`. Creating a *named*
rendering beyond the tier's limit is refused `403 license_limit`; the unnamed working arrangement is never
charged. The response carries the revision just written, for the next write to send.

### `GET /api/files`, `GET/POST /api/project`, `GET /api/fs/list`

List `.sysml` models under the project root, and get/set that root. `POST /api/project` body:
`{ "path": "<absolute path>" }`.

`GET /api/fs/list?path=<absolute dir>` is the **read-only folder listing behind the in-app
"Open project folder" dialog** (ASysT is a local app — the backend browses the user's own machine).
An empty `path` returns the drive list on Windows (`"path": ""`), `/` elsewhere. Response:
`{ "status": "ok", "path", "parent", "dirs": [{ "name", "path", "hasManifest" }], "hasManifest" }` —
`hasManifest` flags ASysT model folders (they contain `asyst.manifest.json`); `parent` is `""` at a
filesystem root. Browsing changes nothing; the dialog `POST`s the chosen path to `/api/project`.

### `GET /api/bundled`

```
GET /api/bundled
```

Lists the manifest-defined models shipped under the engine's **launch `MODEL_DIR`** — today the
`CrisisResponse` pilot and the `SysML v2 in ASysT — lessons` course. Response:
`{ "status": "ok" | "no_manifest", "root", "models": [{ "root", "modelName", "rootFilePath" }] }`,
sorted by directory name so the launcher's rows are stable across platforms.

Like `/api/demo` it is anchored to the launch project, so `POST /api/project` and VS Code file
adoption cannot repoint or empty it. It is read-only discovery: a model is never opened, edited or
executed, and one unreadable manifest is skipped rather than failing the whole listing.

### `GET /api/manifest`

```
GET /api/manifest[?file=<path>]
```

Resolves the **manifest-defined model** for the current project (or, with `?file=`, walks up from
that file to the model it belongs to) and returns the **package-first (FQN) index** discovered from
`asyst.manifest.json`. Read-only — discovery never edits or executes the model. See
[Model_manifest](../feature_component/ASysT_Model_manifest.md).

```json
{
  "status": "ok",
  "root": "E:/…/Demo/CrisisResponse",
  "manifest": {
    "modelName": "…", "version": "0.1", "entryPackage": "CrisisResponse::Core",
    "rootFile": "Model/CrisisResponse.sysml",
    "initialRendering": "Rendering/CrisisResponse/CrisisSystem/definition/parts-exposed.asystrendering"
  },
  "index": {
    "modelName": "…", "version": "0.1", "entryPackage": "CrisisResponse::Core",
    "rootFilePath": "E:/…", "initialRenderingView": "CrisisResponse/CrisisSystem/definition/parts-exposed",
    "root": "E:/…", "files": ["Model/CrisisResponse.sysml", …],
    "packages": [
      { "fqn": "CrisisResponse::Core", "file": "Model/core/10_CoordinationCenter.sysml",
        "filePath": "E:/…", "files": ["Model/core/10_CoordinationCenter.sysml", …], "filePaths": ["E:/…", …],
        "imports": [], "isEntry": true, "reachable": true, "order": 0,
        "members": [ { "name": "CoordinationCenter", "kind": "part def", "qualifiedName": "CrisisResponse::Core::CoordinationCenter" } ] }
    ],
    "warnings": []
  }
}
```

`status` is `ok` (a manifest was found and loaded), `no_manifest` (the project root is a plain folder
— the caller falls back to `/api/files` and the file tree), or `error` (a manifest was found but is
invalid — `message` explains). A package's **`files`/`filePaths` name every file that opens it** — the pinned
language server and this index accept a package being reopened in any number of files, and on the pilot
`CrisisResponse` is declared in all ten files of the model. (⚠ **That is measured behaviour, not a rule of the
SysML v2 specification**, which never mentions reopening at all — see
[the model manifest §2](../feature_component/ASysT_Model_manifest.md#where-the-authority-for-that-merge-actually-comes-from).)
`file`
and `filePath` name only the FIRST one the directory walk reached, which is alphabetical accident: never
present them as "the file of the package" (added 2026-08-08;
`asyst-A-PACKAGE-S-REOPENING-FILES-ARE-MERGED-AWAY`). `reachable` marks packages that belong to the model (shared root
namespace or import-connected); `order` is the load distance from the entry package. `warnings`
surfaces soft drift (a missing `entryPackage`, a stale `rootFile`/`initialRendering`, unreachable
packages). `initialRenderingView` is returned only after the optional manifest path has been validated;
the frontend uses that logical path to restore the named presentation on a project-first open.

### `POST /api/manifest`

Scaffold an `asyst.manifest.json` for a plain folder — the "turn standalone files into a model"
action behind the explorer's **Create manifest** button. Body: `{ "dir"?: "<folder>", "modelName"?:
"<name>" }` (`dir` defaults to the project root). It scans the folder's `.sysml` files to pick a
sensible `rootFile` and `entryPackage` (a package named `Core` is preferred, else the most root-like
one), writes the manifest, and returns the same shape as `GET` with the freshly discovered `index`.
Refuses to overwrite an existing manifest (`status: "error"`). The manifest is ASysT's own project
config, not `.sysml` content — writing it respects the never-edit-the-model rule.

---

## Mutating endpoints

### Validated SysML source transactions

- `POST /api/model/create` — body `{ "directory"?, "modelName", "packageName"?, "fileName"? }`;
  stages a new manifest/root `.sysml`, validates it through the LSP, and publishes only into a new folder.
- `POST /api/model/annotation-target` — body `{ "file", "expectedSha256", "line", "expectedBodyText",
  "targets" }`; sets what a note is **about**. `targets` is the whole list, so an empty one detaches the
  note and hands it back to its owning element. **It is a HEAD edit, not a new statement**: `about` is a
  clause on the comment's own head in the pinned grammar, and an anonymous comment has no symbol for the
  language server to look up — so the note is located from `line` plus `expectedBodyText`, both of which
  must still match, rather than by name. The browser sends coordinates and element ids ONLY; the SysML is
  written here, because the frontend never emits SysML and never locates declarations. Publication goes
  through the same `SourceEditor` transaction as every other model write, with the usual whole-model
  staging and revision precondition. ⚠ **Only a `comment` may be pointed at anything** — `doc` and `rep`
  have no `about` alternative in the grammar and are refused here as well as greyed in the interface.
- `POST /api/model/file` — body `{ "name", "content"? }`; adds ONE new `.sysml` to the model that is
  open. `name` is a plain file name — a name carrying a folder, a `..`, or an extension that is not
  `.sysml` is refused, and so is one that is already taken. `content` defaults to **empty**, which is
  what a new file holds; the first gesture is the user's. The whole model is staged **with the new file
  in it** and validated through the LSP before anything is written, and publication takes the model lock
  and is conditional on the file still being absent and the model still being the one that was validated.
  Nothing registers a file — the model is every `.sysml` under its root — so the file is part of the
  model on the next read, with no import and no manifest change.
- `POST /api/model/file/rename` — body `{ "path", "name" }`; `path` is absolute or relative to the model
  root, `name` is a plain file name under the same rules as creation. The file MOVES and nothing inside it
  changes, so **no whole-model validation is run**: SysML v2 has no file-name semantics — nothing imports a
  file and the manifest carries no file list — so the declarations before and after are identical, and a
  green verdict here would only license the belief that the rename had been checked for something. The one
  fact a file name does carry is the manifest's `rootFile`, and it is repaired in the same act
  (`manifestRepaired`, `repairedRootFile` in the reply). A CASE-ONLY rename (`Model.sysml` → `model.sysml`)
  is performed rather than refused as "already taken", which on a case-insensitive volume it would
  otherwise be. Publication takes the model lock and is conditional on the file's own bytes AND the model
  still being the one that was read.
- `POST /api/model/file/delete` — body `{ "path", "confirm"? }`. **Without `confirm` it removes nothing**
  and answers `{"status":"plan","impact":{…}}`: `declares` (everything the file writes), `removes` (what
  would CEASE TO EXIST — `declares` minus everything a sibling also declares, so a package reopened next
  door is not counted as a loss), `references` (places outside the file naming one of those), `drawings`
  (saved boards that draw one) and `empty`. With `confirm` the file is moved to `<modelRoot>/.asyst/trash/`
  beside a record of where it came from — never destroyed — and `rootFile` is repaired if it named that
  file.
  **It REFUSES (`409 file_referenced`) while anything outside the file still names what it declares**, and
  the refusal names the element, the file and the line. That gate is a reference scan the engine performs
  itself and NOT a validation: on the pinned server a model whose declarations have been deleted out from
  under their references reports zero errors (`ASysT_LSP_Boundary.md`), so staged validation cannot see
  this at all. Every other tool warns and offers "delete anyway"; ASysT cannot, because it cannot show the
  damage afterwards. The scan is deliberately over-cautious — it matches complete tokens outside comments,
  so a coincidence of spelling refuses a deletion that would in fact have been safe. Removing a model's
  LAST file is refused (`409 last_model_file`).
- `GET /api/model/source-revision?file=…` — returns the current SHA-256 used for optimistic edits.
- `GET /api/model/revision?file=…` — returns the fingerprint of the complete manifest model.
- `GET /api/model/watch?file=…` — long-polls that fingerprint; `GET /api/model/changes` derives the
  observable census difference since a caller's earlier revision.
- `POST /api/model/edit` — body `{ "file", "expectedSha256", "label", "edits":[{"range":
  {"start":{"line","character"},"end":{"line","character"}},"newText","expectedText"}] }`. Ranges use LSP
  zero-based UTF-16 positions. The engine rejects stale revisions and overlapping edits, validates a
  staged copy of the complete manifest model, then atomically replaces the source only at zero errors.
  **`expectedText` is REQUIRED on every edit**: it is the text the caller believes that range currently
  holds, and an edit that omits it is refused with `invalid_request`. It exists because `expectedSha256`
  alone could not refuse anything — every caller fetched the digest one statement before writing and the
  engine compared it against the same disk it had just been read from, so the precondition was satisfied
  by the act of reading it. A digest is re-derivable at will; text is not. An insertion replaces nothing
  and states `""`. A mismatch is `409 source_conflict`, the same status a moved file already produces,
  and line-ending differences alone never cause one.
- `POST /api/model/commands/plan` followed by `/api/model/commands/apply` is the semantic-intent path used
  by Modeler gestures. Apply must carry the exact whole-model revision returned by Plan.
- `POST /api/model/workspace/apply` is the composite write path: one expected model revision, one command,
  N affected `.sysml` files, one staged whole-model check and one all-or-nothing publication receipt.
- `POST /api/model/facts/apply` is the bounded direct model-fact command surface and publishes through the
  same Source Editor authority.
- `POST /api/model/history/undo` and `/api/model/history/redo` — body `{ "file",
  "expectedSha256" }`; revalidate and restore the process-local source snapshot history.
- `POST /api/model/workspace/history/undo` and `/redo` revalidate and replay a whole workspace receipt.

Only `.sysml` under the active project is writable. `.kerml`, paths outside the project, invalid
candidates, and stale revisions are rejected. Git remains the durable collaboration/recovery layer.

### Rendering and project mutations

These touch arrangement files or project structure. **The engine keeps no arrangement history**: undoing
an arrangement is the browser's own, and the engine-side copy — with its `POST /api/history/{undo,redo}`
endpoints — was removed on 2026-08-02 because nothing ever called it. Undoing a *model* change is
`/api/model/history/*` and `/api/model/workspace/history/*`, which are real and used.

- `POST /api/layout` — body `{ "uri", "layout", "view" }`; persists the arrangement file. Nothing is
  retained in the engine afterwards.
- `GET/POST /api/image` — serves or copies a model-scoped drawing image under the model's `Images/`
  directory. Image geometry and size remain rendering state; neither route touches `.sysml`.
- `GET/POST /api/session` — reads or writes git-ignored navigation, camera and selection runtime state.
- `POST /api/file`, `POST /api/file/open` — bring a model into the project (a working copy + folder
  management; not content editing).
- `POST /api/views/duplicate` — copy a named rendering without changing its source rendering or `.sysml`;
  body `{ "uri", "view", "newView" }`. The destination must not already exist.
- `POST /api/views/rename`, `POST /api/views/delete` — rename or delete named renderings.
> Two server-side history features have now been **removed** for the same reason — no client: the old
> `GET /api/history/log` action-log ring (the Logs panel is a live client-side store, see
> [Action_log](../feature_component/ASysT_Action_log.md)), and `POST /api/history/{undo,redo}` with the
> arrangement snapshots behind it. Source history is unaffected and remains under `/api/model/history/*`.

---

## WebSocket — `GET /ws`

Push channel for live updates. Messages are JSON tagged by `type`: `diagnostics` (LSP problems for the
open model), `openFile` (host asked to open a model), and view-sync notifications. Clients render or
ignore as they choose.

---

## OMG SysML v2 API (interop)

A **thin, read-only adapter** that exposes ASysT's models on the shape of the OMG *SysML v2 API and
Services* (Project → Commit → Element), so other v2 tools can read an ASysT model through the standard
surface. It is **not** a full implementation of that spec — it's an interop convenience, read-only, and
never writes `.sysml`.

**The mapping** (deliberately approximate):

- A **manifest-defined model** → a **Project** (`@id` derived from `modelName`; one synthetic "current"
  commit is implied).
- The **unified cross-file graph**'s nodes → **Elements** (`@id`, `@type`, `name`, `qualifiedName`,
  `owner`, `ownedRelationship`); its edges → **relationship Elements** (`@type`, `source[]`, `target[]`).
- `@type` is a best-effort KerML/SysML metaclass name (e.g. `PartDefinition`, `RequirementDefinition`,
  `ActionUsage`, `FeatureMembership`, `TransitionUsage`). ASysT's coarse IR can't reproduce the full
  metamodel — treat `@type` as **indicative**, not spec-certified.

| Method | Path | Returns |
| ------ | ---- | ------- |
| GET | `/api/sysmlv2/projects` | `[{ "@id", "@type":"Project", "name", "version", "asyst:root" }]` for each manifest model under the project root (+ one level of subdirs). |
| GET | `/api/sysmlv2/projects/{projectId}/elements` | The model's elements + relationship elements as one OMG element array. |
| GET | `/api/sysmlv2/projects/{projectId}/elements/{elementId}` | One element by `@id`, or `404`. |

```bash
# list projects, then read the first project's elements:
curl http://localhost:8080/api/sysmlv2/projects
curl "http://localhost:8080/api/sysmlv2/projects/CrisisResponse/elements"
```

**`@id` is an element's permanent identity, and `qualifiedName` is its name.** They used to be the same
string, so an integration that stored an `@id` and came back after a rename held a dangling reference.
Since 2026-08-12 an element's `@id` is the id ASysT mints once and keeps in the model's identity ledger
(`asyst.identity.json`), and it does **not** change when the element is renamed — which is what SysML v2
requires of an element ID. Read `qualifiedName` when you want the name; it still moves with a rename.

⚠ **Relationships are not covered yet.** A relationship element's `@id` is still derived from the two ends
it connects, so it still changes when either end is renamed. Treat relationship ids as unstable.

Code: `asyst-engine/server/handlers/sysml_api.go`.

---

## Proposed additions (not yet implemented)

The export endpoint is the first headless/automation API; these are the natural next functions:

| Proposed | Why |
| -------- | --- |
| `GET /api/export?format=png\|pdf` | Raster/print output (needs an SVG rasterizer; SVG is the vector foundation today). |
| Batch export **over HTTP** | `POST /api/export/batch`, to render every model/view in a project from one request. The CLI half already exists — `./asyst.cmd export --all` — so only the endpoint is proposed. |
| `GET /api/model` | Pass-through of the LSP's resolved semantic-model DTO (`sysml/model`) for tools that want the language-server response, not ASysT's graph IR. |
| OMG SysML v2 API — **write side + commits** | The read surface now exists ([above](#omg-sysml-v2-api-interop)); commits/branches and write operations are the natural next step. |

---

> See also: [01 — Vision](../_Master_Doc/01_ASysT_Vision.md) ·
> [02 — Architecture](../_Master_Doc/02_ASysT_Archi.md) ·
> [SysML v2 primer](<../SysML_spec/guides/SysML_v2_Primer.md>) ·
> [00 — Setup & operations](../feature_component/00_Setup_manifest.md) ·
> [Model_manifest](../feature_component/ASysT_Model_manifest.md)
