diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml
new file mode 100644
index 0000000..b6575d2
--- /dev/null
+++ b/.github/workflows/backend-ci.yml
@@ -0,0 +1,56 @@
+name: backend-ci
+
+# cue/** and examples/** are included because backend tests pin the embedded
+# schema and demo assets byte-for-byte to those directories.
+on:
+ push:
+ branches: [main]
+ paths:
+ - backend/**
+ - cue/**
+ - examples/**
+ - .github/workflows/backend-ci.yml
+ pull_request:
+ paths:
+ - backend/**
+ - cue/**
+ - examples/**
+ - .github/workflows/backend-ci.yml
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: backend
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+
+ - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
+ with:
+ go-version-file: backend/go.mod
+ cache-dependency-path: backend/go.sum
+
+ - name: gofmt check
+ run: |
+ unformatted="$(gofmt -l .)"
+ if [ -n "$unformatted" ]; then
+ echo "gofmt needed on:"
+ echo "$unformatted"
+ exit 1
+ fi
+
+ - name: build
+ run: go build ./...
+
+ - name: install gotestsum
+ run: go install gotest.tools/gotestsum@v1.13.0
+
+ - name: test
+ run: gotestsum --junitfile junit.xml --format testname -- ./...
+
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ if: always()
+ with:
+ name: junit
+ path: backend/junit.xml
diff --git a/.github/workflows/binaries.yml b/.github/workflows/binaries.yml
new file mode 100644
index 0000000..8ab9b57
--- /dev/null
+++ b/.github/workflows/binaries.yml
@@ -0,0 +1,58 @@
+name: binaries
+
+# Self-contained cueto binaries: the web UI is built once with an empty
+# VITE_API_URL (same-origin), copied over the placeholder embed dir, and the CLI
+# is cross-compiled for each release target. Pure-Go dependencies make
+# CGO_ENABLED=0 cross-compilation from one runner safe; backend tests verified
+# separately in backend-ci.
+
+on:
+ push:
+ branches: [main]
+ tags: ["v*"]
+ pull_request:
+ paths:
+ - backend/**
+ - frontend/**
+ - .github/workflows/binaries.yml
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+
+ - name: install pnpm
+ run: npm install -g pnpm@11
+
+ - name: build web UI (same-origin API)
+ working-directory: frontend
+ run: |
+ pnpm install --frozen-lockfile
+ pnpm run build
+
+ - name: embed web UI
+ run: |
+ rm -rf backend/internal/assets/webui
+ cp -R frontend/dist backend/internal/assets/webui
+
+ - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
+ with:
+ go-version-file: backend/go.mod
+ cache-dependency-path: backend/go.sum
+
+ - name: build binaries
+ working-directory: backend
+ env:
+ CGO_ENABLED: "0"
+ run: |
+ mkdir -p dist
+ GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o dist/cueto-linux-amd64 ./cmd/cueto
+ GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w" -o dist/cueto-linux-arm64 ./cmd/cueto
+ GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags="-s -w" -o dist/cueto-darwin-arm64 ./cmd/cueto
+
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: cueto-binaries
+ path: backend/dist/cueto-*
diff --git a/.gitignore b/.gitignore
index 909b7b1..0f06449 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,12 @@
# Project store: registry and per-project versions (runtime data, default DATA_DIR).
/data/
+
+# go build output for the CLI binary (e.g. `go build ./cmd/cueto`).
+/cueto
+
+# Local reproduction of the binaries workflow (backend/dist/cueto-*).
+/backend/dist/
+
+# gotestsum JUnit report (backend-ci.yml).
+/backend/junit.xml
diff --git a/Makefile b/Makefile
deleted file mode 100644
index 0e3af28..0000000
--- a/Makefile
+++ /dev/null
@@ -1,9 +0,0 @@
-# Architecture CI checks. `make check` validates the committed module: Layer 1 is
-# pure-CUE validity (`cue vet` and `cueto vet`), Layer 2 is the world-facing graph
-# checks the compiler cannot do (`cueto check` - @file/@uri references resolve). It
-# exits nonzero on any violation, so it drops straight into a CI step.
-.PHONY: check
-check:
- cd cue && cue vet ./...
- cd backend && go run ./cmd/cueto vet -C ../cue
- cd backend && go run ./cmd/cueto check -C ../cue
diff --git a/README.md b/README.md
index 3bfefdf..d6c1cb9 100644
--- a/README.md
+++ b/README.md
@@ -5,131 +5,186 @@
An evaluation server for diagrams whose single source of truth is CUE. The same value is inferred as a diagram from plain schema and data, edited as code, and queried in a REPL.
-## The idea
+Every organization knows things about itself: who is on which team, which services exist, who owns them, what depends on what. Today that knowledge is scattered across wikis and spreadsheets, and nothing checks it, so a page can claim a service is owned by Alice long after Alice has left. cueto converges those facts into one value under a CUE schema and keeps it honest: remove a person and every fact that names them breaks the build instead of going stale. The same compiled value answers questions by evaluation, so an agent wired to it reads exact, grounded facts instead of retrieved text.
-Every organization knows things about itself. Who is on which team, which services exist, who owns them, what depends on what. Today that knowledge is scattered across wikis, spreadsheets, catalogs, and readme files, and nothing checks it, so a page can claim a service is owned by Alice long after Alice has left. The facts just went stale, because no mechanism forces them to stay true.
+## Demo in five minutes
-Knowledge as code converges those sources into one value under a CUE schema, and cueto is a way to see that value and keep it honest. Because the diagram is the data, references are typed against what the module actually declares, so neither the data nor the schema can drift from the picture. Remove a person and every fact that names them breaks the build rather than lingering as a stale claim.
+Prerequisites are Go 1.26+, Node with pnpm, and git. The repo ships a demo project, `examples/service-catalog`: a small engineering organization (teams, people, services, ownership, dependencies, roles, deploy environments) as plain CUE, with no diagram authored and nothing imported from cueto.
-The same property makes cueto a deterministic retrieval surface for agents. A question is answered by evaluating a CUE expression against the compiled value, so the answer is exact and grounded in what is really declared, unlike RAG, which returns probabilistic passages with no guarantee they are true or current. Because the agent reads one evaluated fact instead of a pile of retrieved text, the context carries fewer tokens, the model's attention stays on what matters, and there is far less room to hallucinate.
+Start the backend.
-## What it demonstrates
+```
+cp backend/.env.example backend/.env
+cd backend
+go run ./cmd/server
+```
-- **Inference**. A module of plain schema and data, with no diagram authored, still renders, because cueto derives an entity-relation graph from the integrity idioms you already wrote. See [Inference](#inference).
-- **Architecture pattern**. A hand-owned schema package (`cue/diagram/`) that is never machine-written, with a concrete instance (`data.cue`) overlaid per request, so the canvas only ever round-trips the data and the schema stays authoritative.
-- **Workflow design**. The same model is edited two ways, a visual canvas and CUE code, kept in sync through a source map, then evaluated, validated, formatted, and saved to real files on disk in the user's own project, with git as the only history.
-- **Knowledge model**. The schema separates rendering fields (`type`, `shape`, colors) from a free-form `data` payload, so the nodes you draw carry domain facts you can query.
-- **Queryability**. A REPL pane with CUE stdlib introspection and autocompletion evaluates any expression against the live model in the editor.
-- **Observability**. Evaluation returns structured diagnostics with source positions and host paths scrubbed, plus provenance and hints, rather than opaque errors.
-- **Production trade-offs**. Untrusted CUE is evaluated in-process under body-size, output-size, per-request deadline, and concurrency bounds, behind explicit server timeouts and graceful shutdown.
+Start the frontend in a second shell.
-## What it is not
+```
+cp frontend/.env.example frontend/.env
+cd frontend
+pnpm install
+pnpm run dev
+```
-This is not a production framework.
-This is not a complete product.
-This is a reference implementation and design study.
+Open http://localhost:5173. The `service-catalog` demo opens by itself - a single project is always the default. You get a rendered graph of the whole organization, 14 nodes and 16 edges, even though the module authors no diagram: cueto infers it from the shapes already in the data. Two views are derived, `model` (one ER-style table per registry) and `instances` (one node per member), each with a legend of the discovered registries and a per-element trace of which detection rule produced it.
-## Inference
+### Ask it questions
-Give cueto a module of plain schema and data, with no diagram authored and nothing imported from cueto, and it derives the graph from the integrity idioms you already wrote.
+The REPL panel evaluates any CUE expression against the live model in the editor. Try these.
-```cue
-package main
+```
+> teams[services.billing.owner].channel
+"#team-payments"
-#Person: {
- name: string
- mother: string | *""
- father: string | *""
- role: string | *""
- year: int
-}
+> services.storefront.dependsOn
+["gateway", "billing"]
-people: [ID=string]: #Person
-people: {
- george: {name: "George McFly", role: "parent", year: 1938}
- lorraine: {name: "Lorraine Baines", role: "parent", year: 1938}
- marty: {name: "Marty McFly", role: "traveler", mother: "lorraine", father: "george", year: 1968}
- dave: {name: "Dave McFly", role: "sibling", mother: "lorraine", father: "george", year: 1961}
- linda: {name: "Linda McFly", role: "sibling", mother: "lorraine", father: "george", year: 1965}
- doc: {name: "Dr. Emmett Brown", role: "inventor", year: 1920}
-}
+> [for id, s in services if s.tier == "critical" {s.name}]
+["API Gateway", "Billing", "Ledger"]
+```
+
+Every answer comes from the compiled value, checked against the same schema that renders the graph, so a dangling name is a build error rather than a hallucination.
+
+### Roles and deploy configs
+
+The demo also declares who may do what (`access.cue`) and how services run per environment (`deploy.cue`), and both exploit the value lattice CUE is built on. A role is a disjunction, a set of permissions, and unification `&` is the lattice meet: unifying two roles intersects what they allow.
+
+```
+> (roles.developer & roles.operator) & "write"
+"write"
+
+> _ & "deploy"
+"deploy"
+
+> "read" & "admin"
+conflicting values "admin" and "read"
```
-From that alone cueto derives the graph.
+Top (`_`) permits anything, so it is the identity of the meet, and two disjoint permissions meet at bottom, an error. Configs work the same way as a meet-semilattice: every environment is `configBase & overlay`, the greatest lower bound of both, with defaults filling whatever the overlay leaves open.
-- A registry, a struct with open string labels like `people`, becomes a set of nodes.
-- A field constrained to a registry's key set, such as `mother` or `father`, becomes a relation.
-- Two views render the result, `model` drawing each registry as one ER-style type table and `instances` drawing one node per member.
-- Every derived view carries a legend of the discovered registries and a per-element trace that records which detection rule produced each node and edge.
+```
+> configBase & {replicas: 3}
+{"replicas": 3, "logLevel": "info", "memoryMb": 256}
-Detection is by shape only, so cueto learns no domain vocabulary and the module stays plain CUE that any tool can read.
+> environments.prod
+{"replicas": 3, "logLevel": "error", "memoryMb": 1024}
+```
-## Querying
+There is no template engine and no override precedence table: an environment that contradicts its base is not "last writer wins", it is bottom, a build error.
-The same value is queryable. The REPL pane evaluates any CUE expression against the live model in the editor, nothing is saved and the schema and files are untouched, so a structured question gets a deterministic answer by evaluation rather than retrieval.
+### Break it
-"who is Marty's mother?" is a path lookup rather than a guess.
+Delete the line declaring `alice` in the editor (or in `examples/service-catalog/catalog.cue`). The build fails at the exact fact that named her:
```
-> people[people.marty.mother].name
-"Lorraine Baines"
+cueto vet: module is not valid:
+ services.gateway.techLead: 3 errors in empty disjunction:
+ 6:17: services.gateway.techLead: conflicting values "bruno" and "alice"
```
-The answer comes from the compiled value. `marty.mother` is checked against the same schema that renders the graph, so a dangling name is a build error rather than a hallucination. An agent wired to this endpoint answers from evaluated fact instead of retrieved text, because the graph you draw and the knowledge you query are one CUE value.
+That is the whole point: knowledge that cannot silently go stale. The error is the same lattice at work, a fact that names a missing person unifies to bottom, so staleness cannot survive a build. Put the line back and the graph returns.
+
+Canvas and editor stay in sync through a source map, and a save writes the real file on disk. Your edits show up in `git diff`; git is the only history.
+
+## One binary
+
+`cueto serve` runs the whole app - API, embedded web UI, diagram schema, and demo - as one static binary with no checkout, no env file, and no Node. Everything lives under one standard root, `$XDG_DATA_HOME/cueto` when set, else `~/.cueto`:
-
+```
+~/.cueto/
+ config.cue optional, hand-edited: port and hardening bounds, schema-validated
+ state.json machine-written: the current project per projects root
+ projects/ each child is a git repo plus a CUE module
+ schema/ the embedded diagram schema, materialized at startup
+```
+
+On first run the projects root is empty, so serve seeds the `service-catalog` demo and the app opens straight into it. Selection follows three rules, and no environment variable ever names a project: a single project is the default; with several, the last selected wins (the web app and `cueto use` write the same state); with several and none selected, you land on the project picker.
+
+The binaries workflow cross-compiles releases for linux amd64/arm64 and macOS arm64 with the UI embedded. To reproduce one locally:
+
+```
+cd frontend
+pnpm install
+pnpm run build
+rm -rf ../backend/internal/assets/webui
+cp -R dist ../backend/internal/assets/webui
+cd ../backend
+go build -o ../cueto ./cmd/cueto
+cd ..
+./cueto serve
+```
-
-Authoring a view by hand
+The `webui` copy is build output; do not commit it (the committed placeholder page is what CI overwrites). Flags: `-port` (default 8091, or `port:` in `config.cue`), `-home`, `-projects`, `-cue`.
-Inference is not required. You can author the `diagram` field explicitly and map the same `people` data into nodes and edges.
+## Ask it from the CLI
-```cue
-package main
+The `cueto` CLI runs the same engine without the server, for CI and agents. Run it from `backend/`.
-import d "github.com/stratorys/cueto/diagram"
+`catalog` discovers the domains and named evaluations, with fields, types, and relations, so an agent can plan a call without reading any CUE:
-diagram: d.#Diagram & {
- nodes: {
- for pid, p in people {
- (pid): {
- type: "entity"
- label: p.name
- data: {
- role: p.role
- year: p.year
- }
- }
- }
- }
- edges: [
- for pid, p in people if p.mother != "" {
- {
- id: "m_\(pid)"
- source: p.mother
- target: pid
- kind: "arrow"
- label: "mother"
- }
- },
- for pid, p in people if p.father != "" {
- {
- id: "f_\(pid)"
- source: p.father
- target: pid
- kind: "arrow"
- label: "father"
- }
- },
- ]
+```
+$ go run ./cmd/cueto catalog -C ../examples/service-catalog
+{
+ "domains": [
+ {"name": "people", "kind": "registry", "fields": {...}},
+ {"name": "services", ...},
+ {"name": "teams", ...}
+ ],
+ "evaluations": [
+ {"name": "ownerOf", "description": "Which team owns a service, and how to reach them", ...},
+ {"name": "blastRadius", "description": "Which services break if this service goes down", ...}
+ ]
+}
+```
+
+`query` runs a bounded, schema-checked filter, never a CUE expression:
+
+```
+$ echo '{"domain":"services","select":["name","tier"],"where":[{"field":"owner","operator":"eq","value":"payments"}]}' | go run ./cmd/cueto query - -C ../examples/service-catalog
+{
+ "result": [
+ {"id": "billing", "name": "Billing", "tier": "critical"},
+ {"id": "ledger", "name": "Ledger", "tier": "critical"}
+ ],
+ "count": 2
}
```
-
+`eval` runs one named, schema-validated evaluation against a JSON input:
-## Authoring
+```
+$ echo '{"serviceId":"gateway"}' | go run ./cmd/cueto eval blastRadius --input - -C ../examples/service-catalog
+{
+ "status": "success",
+ "result": {"dependents": ["billing", "storefront"]},
+ "evaluation": "blastRadius",
+ "revision": "..."
+}
+```
-The canvas and the CUE editor stay in sync through a source map, so a change in one appears in the other. Canvas edits are spliced back into CUE text through `/rewrite`, and `/format` normalizes the result with `cue fmt`, so the code and the picture never disagree.
+The `-C` flag is optional everywhere: without it the CLI resolves the same current project the app uses - the working directory when it is itself a CUE module, else `-p `, else the selected (or only) project under the cueto home. `cueto projects` lists what `-p` accepts and stars the current one; `cueto use ` switches it.
+
+```
+$ go run ./cmd/cueto projects
+* service-catalog
+
+$ go run ./cmd/cueto catalog
+{ ... the current project's catalog ... }
+```
+
+`vet`, `check`, `graph`, `describe`, `get` round out the set (`go run ./cmd/cueto help`). The same operations are served per project over HTTP at `/projects/:id/knowledge/{catalog,domains,query,eval,provenance,health}`. An agent never gets to send arbitrary CUE: only named, bounded operations against the compiled value. Today the app itself wires in the catalog (the Knowledge panel); the rest are CLI and HTTP, for CI and agents.
+
+## How inference works
+
+Detection is by shape only, so the module stays plain CUE that any tool can read.
+
+- A registry, a top-level struct with open string labels like `teams: [ID=string]: #Team`, becomes a set of nodes.
+- A field constrained to a registry's key set becomes a relation. The idiom is a disjunction of the keys, `#TeamID: or([for id, _ in teams {id}])`, then `team: #TeamID`. A list field of key-set elements yields one edge per element, like `dependsOn: [...#ServiceID]`. A plain `string` field is not a reference; an explicit `@ref(teams)` attribute is the escape hatch.
+- Named entries under a plain `evaluations` field, each with `description`, `input`, and `result`, become the callable evaluations above. No import is required.
+
+See [examples/service-catalog](examples/service-catalog) for the complete module: three files (`catalog.cue`, `access.cue`, `deploy.cue`), around 150 lines of plain CUE that drive everything shown here.
## Architecture
@@ -142,9 +197,9 @@ flowchart LR
end
subgraph be["backend/ (Go + gin)"]
- api["/config /cue/meta /format /rewrite /projects (list, create)\nper project: /projects/:id/{eval,repl,vet,tree,save,file,history}"]
+ api["/config /cue/meta /format /rewrite /projects (list, create)\nper project: /projects/:id/{eval,repl,vet,tree,save,file,history}\nknowledge: /projects/:id/knowledge/{catalog,domains,query,eval,provenance,health}"]
eval["CUE evaluator (bounded, in-process)"]
- projectsdir[("projects root (each child: git repo + CUE module, git = history)")]
+ projectsdir[("projects root (each child: CUE module, git = history)")]
end
subgraph cue["cue/ (source of truth)"]
@@ -161,49 +216,30 @@ flowchart LR
api --> projectsdir
```
-The CUE evaluator is a pure, adapter-independent core. It takes a prepared file set and returns JSON, views, inference trace and legend, hints, and diagnostics, under fixed size, output, deadline, and concurrency bounds. It knows nothing about HTTP, disks, or projects. The same engine backs two adapters today, the gin HTTP server and the `cueto` CLI, so a diagram vets and evaluates identically in the editor and in CI. Persistence and transport are thin shells around that one core.
-
-## How it works
+The evaluator is a pure, adapter-independent core: it takes a prepared file set and returns JSON, views, inference trace, and structured diagnostics, under body-size, output-size, per-request deadline, and concurrency bounds. The gin HTTP server and the `cueto` CLI are thin shells around that one core, so a module vets and evaluates identically in the editor and in CI.
-1. `cue/diagram/` is the hand-owned schema package (`#Diagram`, `#Node`, `#Column`, `#Edge`). It is never rewritten by the app.
-2. `cue/data.cue` is the concrete instance that imports the schema and declares one or more diagram views. The canvas round-trips only this file, and the schema stays fixed.
-3. On `/eval`, the backend loads the module fresh from disk, overlays the request's editable files, and unifies them against the schema. It discovers every top-level field that is diagram-shaped, meaning it unifies with `#Diagram` and carries `nodes`, so a module may expose zero, one, or many such **views**, and it returns the selected view's concrete diagram as JSON plus the list of discovered view names, or structured diagnostics on failure. A view must be concrete to render, so `/eval` gates it, while non-view knowledge fields need only be valid. A module that authors no view is not an error, because cueto infers the `model` and `instances` views from the module's registries and key-set references and returns those instead, each with a legend and per-element trace (bounded at `inferNodeMax` nodes and `inferEdgeMax` edges). All under size, output, deadline, and concurrency bounds.
-4. Canvas edits are spliced back into CUE text through `/rewrite`, and `/format` normalizes it with `cue fmt`, so the code and the picture never disagree.
-5. `/repl` evaluates any CUE expression against the live model in the editor. `/cue/meta` exposes stdlib introspection that powers autocompletion and auto-import.
-6. `/vet` validates every package in the module for validity, catching dangling references and schema and closedness violations, and returns structured diagnostics. It never requires concreteness, so an incomplete-but-valid module vets clean while `/eval` gates the rendered view. `make check` runs `cue vet ./...` plus `cueto vet` and `cueto check`, so an invalid committed diagram, or a broken file or URI reference, fails CI.
-7. Persistence is git. The server is pointed at a **projects root**, and each child directory is a git repository with its own CUE module. `GET /projects` lists them and `POST /projects` creates one by git-initializing a new directory, scaffolding a minimal vocabulary-free module, and making one initial commit, the only time cueto ever writes git state. Every module-touching operation is scoped to a project, namely `/projects/:id/eval`, `/vet`, `/repl`, `/tree`, `/save`, `/file`, `/history`, and `DELETE /projects/:id/file`.
-8. `/projects/:id/save` validates the buffer against the whole module and writes the real file on disk under a path guard, refusing a save when the file changed on disk since it was loaded and never staging, committing, or otherwise mutating git state. `/projects/:id/history` and `/projects/:id/file` read the git log and file blobs read-only to feed the history panel. cueto is not a version store, and git is the only history.
+- `cue/diagram/` is the hand-owned schema package. It is never rewritten by the app. A project may also author `diagram` views explicitly against it; the canvas round-trips only the data, never the schema.
+- Each child directory of the projects root holding a CUE module is a project. `POST /projects` creates one by git-initializing a directory and scaffolding a minimal module, the only time cueto writes git state. Saves write real files under a path guard, never touching git.
+- `cueto serve` uses `/projects` as the root; the dev server takes `PROJECTS_DIR` from `.env` (default `../examples`, so the demo is there with zero setup). `GET /session` resolves the current project server-side, and the embedded schema and demo are drift-tested byte-for-byte against `cue/` and `examples/`.
-## Run locally
+## Validation and tests
-Prerequisites are Go 1.26+, the [`cue`](https://cuelang.org) CLI for `make check`, and Node with pnpm.
-
-Start the backend.
+Validate the committed modules (this is what CI should run; the `cue` CLI is needed for the first command).
```
-cp backend/.env.example backend/.env
-cd backend
-go run ./cmd/server
+cd cue
+cue vet ./...
```
-Set `PROJECTS_DIR` to a directory that holds your projects, where each child is a git
-repository with its own `cue.mod`. The web app lists them and creates new ones, and the
-first is made for you through `git init`. The diagram schema comes from `CUE_DIR`.
-
-Start the frontend in a second shell.
-
```
-cp frontend/.env.example frontend/.env
-cd frontend
-pnpm install
-pnpm run dev
+cd backend
+go run ./cmd/cueto vet -C ../cue
+go run ./cmd/cueto check -C ../cue
+go run ./cmd/cueto vet -C ../examples/service-catalog
+go run ./cmd/cueto check -C ../examples/service-catalog
```
-Run the architecture CI check.
-
-```
-make check
-```
+`vet` validates the whole module (dangling references, schema and closedness violations) without requiring concreteness. `check` resolves `@file`/`@uri` graph references against the world.
Run the tests.
@@ -217,10 +253,6 @@ cd frontend
pnpm run test
```
-## Related writing
-
-- [Coming soon](https://stratorys.com)
-
## License
Mozilla Public License v2.0 (MPL v2.0). See [LICENSE](LICENSE). Copyright 2026, Lucas Jahier, Stratorys.
diff --git a/backend/.env.example b/backend/.env.example
index 88cd71c..74ff173 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -2,11 +2,12 @@
# Directory holding the hand-owned diagram/ schema package (read-only).
CUE_DIR=../cue
-# Required. Projects root: each child directory is a git repo plus a CUE module.
-# The web app lists these as projects and "New project" git-inits a new child here.
-# A save writes the real file on disk (no git mutation) and the history panel reads
-# git commits, read-only; git is the only version store. Must be an existing dir.
-PROJECTS_DIR=../projects
+# Required. Projects root: each child directory is a CUE module (git repo for
+# history). The web app lists these as projects and "New project" git-inits a new
+# child here. A save writes the real file on disk (no git mutation) and the
+# history panel reads git commits, read-only; git is the only version store.
+# Must be an existing dir. ../examples ships the service-catalog demo project.
+PROJECTS_DIR=../examples
PORT=8091
# Untrusted-input hardening bounds.
diff --git a/backend/cmd/cueto/main.go b/backend/cmd/cueto/main.go
index b45626f..761fc33 100644
--- a/backend/cmd/cueto/main.go
+++ b/backend/cmd/cueto/main.go
@@ -32,6 +32,7 @@ import (
"github.com/stratorys/cueto/backend/internal/diag"
"github.com/stratorys/cueto/backend/internal/evaluation"
+ "github.com/stratorys/cueto/backend/internal/knowledge"
)
// CLI evaluation bounds. Generous next to the server's per-request caps: a CI run is
@@ -58,6 +59,22 @@ func main() {
err = runCheck(args)
case "graph":
err = runGraph(args)
+ case "catalog":
+ err = runCatalog(args)
+ case "describe":
+ err = runDescribe(args)
+ case "get":
+ err = runGet(args)
+ case "query":
+ err = runQuery(args)
+ case "eval":
+ err = runEval(args)
+ case "serve":
+ err = runServe(args)
+ case "projects":
+ err = runProjects(args)
+ case "use":
+ err = runUse(args)
case "-h", "--help", "help":
usage()
return
@@ -73,39 +90,232 @@ func main() {
}
func usage() {
- fmt.Fprint(os.Stderr, `cueto - evaluate and validate a CUE module
+ fmt.Fprint(os.Stderr, `cueto - evaluate, validate, query, and serve CUE knowledge modules
usage:
- cueto vet -C validate the whole module (Layer 1, pure CUE)
- cueto check -C run @file/@uri graph checks (Layer 2)
- cueto graph -C [-view v] print the discovered/inferred diagram as JSON
+ cueto serve [-port n] [-home d] [-projects d] run the app: API plus embedded web UI
+ cueto vet validate the whole module (Layer 1, pure CUE)
+ cueto check run @file/@uri graph checks (Layer 2)
+ cueto graph [-view v] print the discovered/inferred diagram as JSON
+ cueto catalog print the knowledge catalog as JSON
+ cueto describe describe one catalog domain
+ cueto get print one domain record
+ cueto query run a safe knowledge query
+ cueto eval --input run a named evaluation
+ cueto projects list projects under the cueto home (* = current)
+ cueto use set the current project
+
+module resolution for vet/check/graph/catalog/describe/get/query/eval:
+an explicit -C wins; else the working directory when it is a CUE module;
+else -p ; else the selected project, or the only one, under the cueto home
+($XDG_DATA_HOME/cueto or ~/.cueto).
flags:
- -C module root directory (contains cue.mod); default "."
- -cue cueto diagram schema directory; default "../cue" (graph only)
+ -C module root directory (contains cue.mod)
+ -p project id under the cueto home projects root
+ -cue cueto diagram schema directory (default: embedded schema)
-view discovered view to render (graph only)
`)
}
+func runtimeFor(moduleDir, cueDir string) (*knowledge.CueRuntime, knowledge.ProjectRef, error) {
+ engine, src, err := setup(moduleDir, cueDir, "")
+ if err != nil {
+ return nil, knowledge.ProjectRef{}, err
+ }
+ return knowledge.NewRuntime(knowledge.New(engine)), knowledge.ProjectRef{ModuleDir: src.Dir}, nil
+}
+
+func commandFlags(name string, args []string) (*flag.FlagSet, *string, *string, error) {
+ fs := flag.NewFlagSet(name, flag.ContinueOnError)
+ dir := fs.String("C", "", "module root directory (default: resolved project)")
+ project := fs.String("p", "", "project id under the cueto home")
+ cueDir := fs.String("cue", "", "cueto schema directory (default: embedded schema)")
+ if err := fs.Parse(normalizeFlags(args)); err != nil {
+ return nil, nil, nil, err
+ }
+ moduleDir, err := resolveModuleDir(*dir, *project)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ schemaDir, err := resolveSchemaDir(*cueDir)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ return fs, &moduleDir, &schemaDir, nil
+}
+
+// normalizeFlags permits the documented `command positional -C dir` form even
+// though Go's flag package otherwise stops parsing at the first positional arg.
+func normalizeFlags(args []string) []string {
+ flags, positional := []string{}, []string{}
+ for i := 0; i < len(args); i++ {
+ if args[i] == "-C" || args[i] == "-cue" || args[i] == "-p" || args[i] == "--input" {
+ flags = append(flags, args[i])
+ if i+1 < len(args) {
+ i++
+ flags = append(flags, args[i])
+ }
+ continue
+ }
+ positional = append(positional, args[i])
+ }
+ return append(flags, positional...)
+}
+
+func printJSON(value any) error {
+ out, err := json.MarshalIndent(value, "", " ")
+ if err == nil {
+ fmt.Println(string(out))
+ }
+ return err
+}
+
+func runCatalog(args []string) error {
+ _, dir, cueDir, err := commandFlags("catalog", args)
+ if err != nil {
+ return err
+ }
+ runtime, project, err := runtimeFor(*dir, *cueDir)
+ if err != nil {
+ return err
+ }
+ result, err := runtime.Catalog(context.Background(), project)
+ if err != nil {
+ return err
+ }
+ return printJSON(result)
+}
+
+func runDescribe(args []string) error {
+ fs, dir, cueDir, err := commandFlags("describe", args)
+ if err != nil {
+ return err
+ }
+ if fs.NArg() != 1 {
+ return errors.New("usage: cueto describe -C ")
+ }
+ runtime, project, err := runtimeFor(*dir, *cueDir)
+ if err != nil {
+ return err
+ }
+ result, err := runtime.Describe(context.Background(), project, fs.Arg(0))
+ if err != nil {
+ return err
+ }
+ return printJSON(result)
+}
+
+func runGet(args []string) error {
+ fs, dir, cueDir, err := commandFlags("get", args)
+ if err != nil {
+ return err
+ }
+ if fs.NArg() != 2 {
+ return errors.New("usage: cueto get -C ")
+ }
+ runtime, project, err := runtimeFor(*dir, *cueDir)
+ if err != nil {
+ return err
+ }
+ result, err := runtime.Get(context.Background(), project, fs.Arg(0), fs.Arg(1))
+ if err != nil {
+ return err
+ }
+ fmt.Println(string(result))
+ return nil
+}
+
+func readJSONArg(name string) ([]byte, error) {
+ if name == "-" {
+ return os.ReadFile("/dev/stdin")
+ }
+ return os.ReadFile(name)
+}
+
+func runQuery(args []string) error {
+ fs, dir, cueDir, err := commandFlags("query", args)
+ if err != nil {
+ return err
+ }
+ if fs.NArg() != 1 {
+ return errors.New("usage: cueto query -C ")
+ }
+ raw, err := readJSONArg(fs.Arg(0))
+ if err != nil {
+ return err
+ }
+ var query knowledge.Query
+ if err := json.Unmarshal(raw, &query); err != nil {
+ return err
+ }
+ runtime, project, err := runtimeFor(*dir, *cueDir)
+ if err != nil {
+ return err
+ }
+ result, err := runtime.Query(context.Background(), project, query)
+ if err != nil {
+ return err
+ }
+ return printJSON(result)
+}
+
+func runEval(args []string) error {
+ fs := flag.NewFlagSet("eval", flag.ContinueOnError)
+ dir := fs.String("C", "", "module root directory (default: resolved project)")
+ projectID := fs.String("p", "", "project id under the cueto home")
+ cueDir := fs.String("cue", "", "cueto schema directory (default: embedded schema)")
+ input := fs.String("input", "", "input JSON file or - for stdin")
+ if err := fs.Parse(normalizeFlags(args)); err != nil {
+ return err
+ }
+ if fs.NArg() != 1 || *input == "" {
+ return errors.New("usage: cueto eval --input [-C | -p ]")
+ }
+ raw, err := readJSONArg(*input)
+ if err != nil {
+ return err
+ }
+ moduleDir, schemaDir, err := resolveDirs(*dir, *projectID, *cueDir)
+ if err != nil {
+ return err
+ }
+ runtime, project, err := runtimeFor(moduleDir, schemaDir)
+ if err != nil {
+ return err
+ }
+ result, err := runtime.Eval(context.Background(), project, knowledge.EvalRequest{Evaluation: fs.Arg(0), Input: raw})
+ if err != nil {
+ return err
+ }
+ return printJSON(result)
+}
+
// runVet validates the whole module and exits nonzero on any diagnostic. It never
// gates concreteness: an incomplete-but-valid module vets clean.
func runVet(args []string) error {
fs := flag.NewFlagSet("vet", flag.ExitOnError)
- dir := fs.String("C", ".", "module root directory")
- cueDir := fs.String("cue", "../cue", "cueto diagram schema directory (unused by vet)")
+ dir := fs.String("C", "", "module root directory (default: resolved project)")
+ project := fs.String("p", "", "project id under the cueto home")
+ cueDir := fs.String("cue", "", "cueto diagram schema directory (unused by vet)")
if err := fs.Parse(args); err != nil {
return err
}
- engine, src, err := setup(*dir, *cueDir, "")
+ moduleDir, schemaDir, err := resolveDirs(*dir, *project, *cueDir)
if err != nil {
return err
}
- diags, err := engine.Vet(context.Background(), src)
+ engine, src, err := setup(moduleDir, schemaDir, "")
if err != nil {
return err
}
- if len(diags) > 0 {
- return errors.New(formatDiags("module is not valid:", diags))
+ runtime := knowledge.NewRuntime(knowledge.New(engine))
+ health, err := runtime.Health(context.Background(), knowledge.ProjectRef{ModuleDir: src.Dir, Package: src.Package})
+ if err != nil {
+ return err
+ }
+ if !health.Valid {
+ return errors.New(formatDiags("module is not valid:", health.Diagnostics))
}
fmt.Println("OK: module is valid.")
return nil
@@ -115,12 +325,17 @@ func runVet(args []string) error {
// exits nonzero on any failure.
func runCheck(args []string) error {
fs := flag.NewFlagSet("check", flag.ExitOnError)
- dir := fs.String("C", ".", "module root directory")
- cueDir := fs.String("cue", "../cue", "cueto diagram schema directory (unused by check)")
+ dir := fs.String("C", "", "module root directory (default: resolved project)")
+ project := fs.String("p", "", "project id under the cueto home")
+ cueDir := fs.String("cue", "", "cueto diagram schema directory (unused by check)")
if err := fs.Parse(args); err != nil {
return err
}
- engine, src, err := setup(*dir, *cueDir, "")
+ moduleDir, schemaDir, err := resolveDirs(*dir, *project, *cueDir)
+ if err != nil {
+ return err
+ }
+ engine, src, err := setup(moduleDir, schemaDir, "")
if err != nil {
return err
}
@@ -140,13 +355,18 @@ func runCheck(args []string) error {
// nonzero when the rendered view is invalid or incomplete.
func runGraph(args []string) error {
fs := flag.NewFlagSet("graph", flag.ExitOnError)
- dir := fs.String("C", ".", "module root directory")
- cueDir := fs.String("cue", "../cue", "cueto diagram schema directory")
+ dir := fs.String("C", "", "module root directory (default: resolved project)")
+ project := fs.String("p", "", "project id under the cueto home")
+ cueDir := fs.String("cue", "", "cueto diagram schema directory (default: embedded schema)")
view := fs.String("view", "", "discovered view to render")
if err := fs.Parse(args); err != nil {
return err
}
- engine, src, err := setup(*dir, *cueDir, *view)
+ moduleDir, schemaDir, err := resolveDirs(*dir, *project, *cueDir)
+ if err != nil {
+ return err
+ }
+ engine, src, err := setup(moduleDir, schemaDir, *view)
if err != nil {
return err
}
diff --git a/backend/cmd/cueto/main_test.go b/backend/cmd/cueto/main_test.go
new file mode 100644
index 0000000..12fe0bf
--- /dev/null
+++ b/backend/cmd/cueto/main_test.go
@@ -0,0 +1,51 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+package main
+
+import (
+ "reflect"
+ "testing"
+)
+
+// normalizeFlags permits the documented `command positional -C dir` form even
+// though Go's flag package otherwise stops parsing at the first positional arg.
+func TestNormalizeFlags(t *testing.T) {
+ cases := []struct {
+ name string
+ args []string
+ want []string
+ }{
+ {
+ name: "no flags",
+ args: []string{"catalog"},
+ want: []string{"catalog"},
+ },
+ {
+ name: "flag before positional",
+ args: []string{"-C", "../cue", "vet"},
+ want: []string{"-C", "../cue", "vet"},
+ },
+ {
+ name: "flag after positional",
+ args: []string{"describe", "-C", "../cue"},
+ want: []string{"-C", "../cue", "describe"},
+ },
+ {
+ name: "--input with stdin marker",
+ args: []string{"eval", "myeval", "--input", "-"},
+ want: []string{"--input", "-", "eval", "myeval"},
+ },
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ got := normalizeFlags(c.args)
+ if !reflect.DeepEqual(got, c.want) {
+ t.Fatalf("normalizeFlags(%v) = %v, want %v", c.args, got, c.want)
+ }
+ })
+ }
+}
diff --git a/backend/cmd/cueto/resolve.go b/backend/cmd/cueto/resolve.go
new file mode 100644
index 0000000..2c3ae67
--- /dev/null
+++ b/backend/cmd/cueto/resolve.go
@@ -0,0 +1,170 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+package main
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/stratorys/cueto/backend/internal/assets"
+ "github.com/stratorys/cueto/backend/internal/home"
+ "github.com/stratorys/cueto/backend/internal/projects"
+)
+
+// resolveModuleDir picks the module root for a subcommand, mirroring the web
+// app's session rules so the CLI and the app always agree on "current":
+//
+// 1. an explicit -C dir wins (CI, arbitrary modules)
+// 2. the working directory when it is itself a CUE module
+// 3. -p id under the cueto home's projects root
+// 4. the persisted selection (cueto use / the web app) when it still resolves
+// 5. the only project under the projects root
+//
+// Anything else is an error that says exactly how to disambiguate.
+func resolveModuleDir(cFlag, pFlag string) (string, error) {
+ if cFlag != "" {
+ return cFlag, nil
+ }
+ if pFlag == "" {
+ if cwd, err := os.Getwd(); err == nil && isModuleDir(cwd) {
+ return cwd, nil
+ }
+ }
+ h, manager, err := homeProjects()
+ if err != nil {
+ return "", err
+ }
+ if pFlag != "" {
+ dir, ok := manager.Resolve(pFlag)
+ if !ok {
+ return "", fmt.Errorf("unknown project %q under %s", pFlag, h.ProjectsDir())
+ }
+ return dir, nil
+ }
+ if id := h.Selection(h.ProjectsDir()); id != "" {
+ if dir, ok := manager.Resolve(id); ok {
+ return dir, nil
+ }
+ }
+ ps, err := manager.List()
+ if err != nil {
+ return "", err
+ }
+ switch len(ps) {
+ case 0:
+ return "", fmt.Errorf("not inside a CUE module and no projects under %s: pass -C , or run `cueto serve` once to seed the demo project", h.ProjectsDir())
+ case 1:
+ dir, _ := manager.Resolve(ps[0].ID)
+ return dir, nil
+ }
+ ids := make([]string, 0, len(ps))
+ for _, p := range ps {
+ ids = append(ids, p.ID)
+ }
+ return "", fmt.Errorf("multiple projects under %s and none selected: pass -p or run `cueto use ` (projects: %s)", h.ProjectsDir(), strings.Join(ids, ", "))
+}
+
+// resolveSchemaDir picks the cueto diagram schema dir: an explicit -cue wins,
+// else the embedded schema is materialized under /schema, so an installed
+// binary works from any directory without a repo checkout.
+func resolveSchemaDir(cueFlag string) (string, error) {
+ if cueFlag != "" {
+ return cueFlag, nil
+ }
+ root, err := home.DefaultRoot()
+ if err != nil {
+ return "", err
+ }
+ dir := filepath.Join(root, serveSchemaDirName)
+ if err := assets.MaterializeSchema(dir); err != nil {
+ return "", fmt.Errorf("materialize schema: %w", err)
+ }
+ return dir, nil
+}
+
+// resolveDirs is the two-step resolution every subcommand runs: the module root
+// (resolveModuleDir) and the schema dir (resolveSchemaDir).
+func resolveDirs(cFlag, pFlag, cueFlag string) (string, string, error) {
+ moduleDir, err := resolveModuleDir(cFlag, pFlag)
+ if err != nil {
+ return "", "", err
+ }
+ schemaDir, err := resolveSchemaDir(cueFlag)
+ if err != nil {
+ return "", "", err
+ }
+ return moduleDir, schemaDir, nil
+}
+
+// runProjects lists the projects under the cueto home, marking the current one
+// with a star, so a CLI-only user can see what -p accepts and what is selected.
+func runProjects(args []string) error {
+ if len(args) > 0 {
+ return errors.New("usage: cueto projects")
+ }
+ h, manager, err := homeProjects()
+ if err != nil {
+ return err
+ }
+ ps, err := manager.List()
+ if err != nil {
+ return err
+ }
+ if len(ps) == 0 {
+ fmt.Printf("no projects under %s (run `cueto serve` once to seed the demo)\n", h.ProjectsDir())
+ return nil
+ }
+ current := h.Selection(h.ProjectsDir())
+ for _, p := range ps {
+ marker := " "
+ if p.ID == current {
+ marker = "*"
+ }
+ fmt.Printf("%s %s\n", marker, p.ID)
+ }
+ return nil
+}
+
+// runUse persists the current project for the cueto home, the same state the web
+// app writes, so the app and every later CLI call agree on the default.
+func runUse(args []string) error {
+ if len(args) != 1 {
+ return errors.New("usage: cueto use ")
+ }
+ h, manager, err := homeProjects()
+ if err != nil {
+ return err
+ }
+ id := args[0]
+ if _, ok := manager.Resolve(id); !ok {
+ return fmt.Errorf("unknown project %q under %s (see `cueto projects`)", id, h.ProjectsDir())
+ }
+ if err := h.SetSelection(h.ProjectsDir(), id); err != nil {
+ return err
+ }
+ fmt.Printf("current project is now %s\n", id)
+ return nil
+}
+
+// homeProjects resolves the standard home and its projects manager.
+func homeProjects() (*home.Home, *projects.Manager, error) {
+ root, err := home.DefaultRoot()
+ if err != nil {
+ return nil, nil, err
+ }
+ h := home.New(root)
+ return h, projects.New(h.ProjectsDir()), nil
+}
+
+// isModuleDir reports whether dir is a CUE module root (has a cue.mod dir).
+func isModuleDir(dir string) bool {
+ info, err := os.Stat(filepath.Join(dir, "cue.mod"))
+ return err == nil && info.IsDir()
+}
diff --git a/backend/cmd/cueto/resolve_test.go b/backend/cmd/cueto/resolve_test.go
new file mode 100644
index 0000000..e5f19a1
--- /dev/null
+++ b/backend/cmd/cueto/resolve_test.go
@@ -0,0 +1,161 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stratorys/cueto/backend/internal/home"
+)
+
+// testHome routes home.DefaultRoot to a temp dir via XDG_DATA_HOME so resolution
+// tests never touch the real ~/.cueto. Returns the home and its projects root.
+func testHome(t *testing.T) *home.Home {
+ t.Helper()
+ data := t.TempDir()
+ t.Setenv("XDG_DATA_HOME", data)
+ h := home.New(filepath.Join(data, "cueto"))
+ if err := h.Ensure(); err != nil {
+ t.Fatal(err)
+ }
+ return h
+}
+
+func addProject(t *testing.T, h *home.Home, id string) string {
+ t.Helper()
+ dir := filepath.Join(h.ProjectsDir(), id)
+ if err := os.MkdirAll(filepath.Join(dir, "cue.mod"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ module := "module: \"example.com/" + id + "\"\nlanguage: version: \"v0.17.0\"\n"
+ if err := os.WriteFile(filepath.Join(dir, "cue.mod", "module.cue"), []byte(module), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return dir
+}
+
+func TestResolveModuleDirExplicitCWins(t *testing.T) {
+ testHome(t)
+ dir, err := resolveModuleDir("/some/dir", "ignored")
+ if err != nil || dir != "/some/dir" {
+ t.Fatalf("resolve = %q, %v; want /some/dir", dir, err)
+ }
+}
+
+func TestResolveModuleDirCwdModule(t *testing.T) {
+ testHome(t)
+ module := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(module, "cue.mod"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Chdir(module)
+ dir, err := resolveModuleDir("", "")
+ if err != nil {
+ t.Fatalf("resolve: %v", err)
+ }
+ if resolved, _ := filepath.EvalSymlinks(dir); resolved != mustEval(t, module) {
+ t.Fatalf("resolve = %q, want cwd module %q", dir, module)
+ }
+}
+
+func TestResolveModuleDirProjectFlag(t *testing.T) {
+ h := testHome(t)
+ t.Chdir(t.TempDir())
+ want := addProject(t, h, "acme")
+ addProject(t, h, "beta")
+ dir, err := resolveModuleDir("", "acme")
+ if err != nil || dir != want {
+ t.Fatalf("resolve = %q, %v; want %q", dir, err, want)
+ }
+ if _, err := resolveModuleDir("", "ghost"); err == nil || !strings.Contains(err.Error(), "unknown project") {
+ t.Fatalf("unknown -p err = %v", err)
+ }
+}
+
+func TestResolveModuleDirSelection(t *testing.T) {
+ h := testHome(t)
+ t.Chdir(t.TempDir())
+ addProject(t, h, "acme")
+ want := addProject(t, h, "beta")
+ if err := h.SetSelection(h.ProjectsDir(), "beta"); err != nil {
+ t.Fatal(err)
+ }
+ dir, err := resolveModuleDir("", "")
+ if err != nil || dir != want {
+ t.Fatalf("resolve = %q, %v; want selected %q", dir, err, want)
+ }
+}
+
+func TestResolveModuleDirOnlyProject(t *testing.T) {
+ h := testHome(t)
+ t.Chdir(t.TempDir())
+ want := addProject(t, h, "acme")
+ dir, err := resolveModuleDir("", "")
+ if err != nil || dir != want {
+ t.Fatalf("resolve = %q, %v; want only project %q", dir, err, want)
+ }
+}
+
+func TestResolveModuleDirNoProjects(t *testing.T) {
+ testHome(t)
+ t.Chdir(t.TempDir())
+ if _, err := resolveModuleDir("", ""); err == nil || !strings.Contains(err.Error(), "no projects") {
+ t.Fatalf("err = %v, want no-projects guidance", err)
+ }
+}
+
+func TestResolveModuleDirMultipleUnselected(t *testing.T) {
+ h := testHome(t)
+ t.Chdir(t.TempDir())
+ addProject(t, h, "acme")
+ addProject(t, h, "beta")
+ _, err := resolveModuleDir("", "")
+ if err == nil || !strings.Contains(err.Error(), "acme") || !strings.Contains(err.Error(), "beta") {
+ t.Fatalf("err = %v, want project ids listed", err)
+ }
+}
+
+func TestResolveSchemaDirMaterializesEmbedded(t *testing.T) {
+ testHome(t)
+ dir, err := resolveSchemaDir("")
+ if err != nil {
+ t.Fatalf("resolveSchemaDir: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(dir, "diagram", "diagram.cue")); err != nil {
+ t.Fatalf("embedded schema not materialized: %v", err)
+ }
+ if explicit, err := resolveSchemaDir("/my/cue"); err != nil || explicit != "/my/cue" {
+ t.Fatalf("explicit -cue = %q, %v", explicit, err)
+ }
+}
+
+func TestRunUsePersistsSelection(t *testing.T) {
+ h := testHome(t)
+ addProject(t, h, "acme")
+ addProject(t, h, "beta")
+ if err := runUse([]string{"beta"}); err != nil {
+ t.Fatalf("use: %v", err)
+ }
+ if got := h.Selection(h.ProjectsDir()); got != "beta" {
+ t.Fatalf("selection = %q, want beta", got)
+ }
+ if err := runUse([]string{"ghost"}); err == nil {
+ t.Fatal("use accepted unknown project")
+ }
+}
+
+func mustEval(t *testing.T, path string) string {
+ t.Helper()
+ resolved, err := filepath.EvalSymlinks(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return resolved
+}
diff --git a/backend/cmd/cueto/serve.go b/backend/cmd/cueto/serve.go
new file mode 100644
index 0000000..3e8a4f1
--- /dev/null
+++ b/backend/cmd/cueto/serve.go
@@ -0,0 +1,170 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+package main
+
+import (
+ "flag"
+ "fmt"
+ "io/fs"
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/stratorys/cueto/backend/internal/assets"
+ "github.com/stratorys/cueto/backend/internal/authoring"
+ "github.com/stratorys/cueto/backend/internal/config"
+ "github.com/stratorys/cueto/backend/internal/evaluation"
+ "github.com/stratorys/cueto/backend/internal/handlers"
+ "github.com/stratorys/cueto/backend/internal/home"
+ "github.com/stratorys/cueto/backend/internal/projects"
+ "github.com/stratorys/cueto/backend/internal/server"
+)
+
+// Serve defaults, overridable by config.cue in the home root and then by flags.
+// They mirror the dev server's env defaults so both surfaces behave alike.
+const (
+ serveDefaultPort = 8091
+ serveDefaultBodyBytes = 1 << 20
+ serveDefaultOutputBytes = 4 << 20
+ serveDefaultEvalTimeoutMs = 2000
+ serveDefaultMaxConcurrent = 4
+ serveSchemaDirName = "schema"
+)
+
+// runServe is the standalone, self-contained server: everything lives under the
+// cueto home (config.cue, state.json, projects/, materialized schema/), the web
+// UI and demo project ship inside the binary, and no environment variable names
+// a directory or a project.
+func runServe(args []string) error {
+ fset := flag.NewFlagSet("serve", flag.ExitOnError)
+ homeFlag := fset.String("home", "", "cueto home (default $XDG_DATA_HOME/cueto or ~/.cueto)")
+ projectsFlag := fset.String("projects", "", "projects root (default /projects)")
+ portFlag := fset.Int("port", 0, "listen port (default config.cue port or 8091)")
+ cueFlag := fset.String("cue", "", "diagram schema dir (default: embedded schema under /schema)")
+ if err := fset.Parse(args); err != nil {
+ return err
+ }
+
+ router, cfg, err := prepareServe(*homeFlag, *projectsFlag, *portFlag, *cueFlag)
+ if err != nil {
+ return err
+ }
+ log.Printf("cueto serving on http://localhost:%s (projects %s)", cfg.Port, cfg.ProjectsDir)
+ return server.Run(router, cfg.Port, cfg.EvalTimeout)
+}
+
+// prepareServe resolves the home, applies config.cue under the flag overrides,
+// materializes the embedded schema, seeds the demo project into an empty projects
+// root, and assembles the API router with the embedded web UI mounted as the
+// fallback route. Split from runServe so tests can exercise everything up to the
+// listening socket.
+func prepareServe(homeDir, projectsDir string, port int, cueDir string) (*gin.Engine, config.Config, error) {
+ if homeDir == "" {
+ root, err := home.DefaultRoot()
+ if err != nil {
+ return nil, config.Config{}, err
+ }
+ homeDir = root
+ }
+ h := home.New(homeDir)
+ if err := h.Ensure(); err != nil {
+ return nil, config.Config{}, err
+ }
+ fileCfg, err := h.LoadConfig()
+ if err != nil {
+ return nil, config.Config{}, err
+ }
+
+ if port == 0 {
+ port = fileCfg.Port
+ }
+ if port == 0 {
+ port = serveDefaultPort
+ }
+ if projectsDir == "" {
+ projectsDir = h.ProjectsDir()
+ }
+ if err := os.MkdirAll(projectsDir, 0o755); err != nil {
+ return nil, config.Config{}, err
+ }
+ if cueDir == "" {
+ cueDir = filepath.Join(h.Root(), serveSchemaDirName)
+ if err := assets.MaterializeSchema(cueDir); err != nil {
+ return nil, config.Config{}, fmt.Errorf("materialize schema: %w", err)
+ }
+ }
+
+ // First run: an empty projects root gets the demo project, so the first page
+ // a new user sees is a rendered graph rather than an empty editor.
+ manager := projects.New(projectsDir)
+ existing, err := manager.List()
+ if err != nil {
+ return nil, config.Config{}, err
+ }
+ if len(existing) == 0 {
+ if _, err := manager.Seed(assets.DemoProjectID, assets.Demo()); err != nil {
+ return nil, config.Config{}, fmt.Errorf("seed demo project: %w", err)
+ }
+ log.Printf("seeded demo project %q into %s", assets.DemoProjectID, projectsDir)
+ }
+
+ cfg := config.Config{
+ CueDir: cueDir,
+ ProjectsDir: projectsDir,
+ Port: strconv.Itoa(port),
+ MaxBodyBytes: pickInt64(fileCfg.MaxBodyBytes, serveDefaultBodyBytes),
+ MaxOutputBytes: pickInt(fileCfg.MaxOutputBytes, serveDefaultOutputBytes),
+ EvalTimeout: time.Duration(pickInt(fileCfg.EvalTimeoutMs, serveDefaultEvalTimeoutMs)) * time.Millisecond,
+ MaxConcurrent: pickInt(fileCfg.MaxConcurrent, serveDefaultMaxConcurrent),
+ }
+
+ gin.SetMode(gin.ReleaseMode)
+ router := handlers.NewRouter(evaluation.New(cfg.CueDir, cfg.EvalTimeout, cfg.MaxOutputBytes), authoring.New(), cfg, h)
+ router.NoRoute(uiHandler(assets.WebUI()))
+ return router, cfg, nil
+}
+
+func pickInt(value, fallback int) int {
+ if value != 0 {
+ return value
+ }
+ return fallback
+}
+
+func pickInt64(value, fallback int64) int64 {
+ if value != 0 {
+ return value
+ }
+ return fallback
+}
+
+// uiHandler serves the embedded web UI for any GET the API does not claim, with
+// the single-page fallback: a path that names no built asset serves index.html
+// so client-side routes and reloads land in the app.
+func uiHandler(ui fs.FS) gin.HandlerFunc {
+ fileServer := http.FileServer(http.FS(ui))
+ return func(c *gin.Context) {
+ if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
+ c.Status(http.StatusNotFound)
+ return
+ }
+ path := strings.TrimPrefix(c.Request.URL.Path, "/")
+ if path == "" {
+ path = "index.html"
+ }
+ if _, err := fs.Stat(ui, path); err != nil {
+ c.Request.URL.Path = "/"
+ }
+ fileServer.ServeHTTP(c.Writer, c.Request)
+ }
+}
diff --git a/backend/cmd/cueto/serve_test.go b/backend/cmd/cueto/serve_test.go
new file mode 100644
index 0000000..7c4f957
--- /dev/null
+++ b/backend/cmd/cueto/serve_test.go
@@ -0,0 +1,142 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stratorys/cueto/backend/internal/assets"
+)
+
+// TestPrepareServeFirstRun exercises the whole standalone bootstrap on a fresh
+// home: directories created, schema materialized, demo seeded, session resolving
+// to the demo, knowledge served from it, and the embedded UI as fallback.
+func TestPrepareServeFirstRun(t *testing.T) {
+ homeDir := filepath.Join(t.TempDir(), "cueto-home")
+ router, cfg, err := prepareServe(homeDir, "", 0, "")
+ if err != nil {
+ t.Fatalf("prepareServe: %v", err)
+ }
+ if cfg.Port != "8091" {
+ t.Fatalf("port = %s, want default 8091", cfg.Port)
+ }
+ if _, err := os.Stat(filepath.Join(homeDir, "schema", "diagram", "diagram.cue")); err != nil {
+ t.Fatalf("schema not materialized: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(homeDir, "projects", assets.DemoProjectID, "catalog.cue")); err != nil {
+ t.Fatalf("demo not seeded: %v", err)
+ }
+
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/session", nil))
+ var session struct {
+ CurrentProject string `json:"currentProject"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &session); err != nil {
+ t.Fatalf("decode session: %v", err)
+ }
+ if session.CurrentProject != assets.DemoProjectID {
+ t.Fatalf("currentProject = %q, want %s", session.CurrentProject, assets.DemoProjectID)
+ }
+
+ rec = httptest.NewRecorder()
+ router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/projects/"+assets.DemoProjectID+"/knowledge/catalog", nil))
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "blastRadius") {
+ t.Fatalf("knowledge catalog = %d %s", rec.Code, rec.Body.String())
+ }
+
+ rec = httptest.NewRecorder()
+ router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "cueto") {
+ t.Fatalf("UI root = %d", rec.Code)
+ }
+ rec = httptest.NewRecorder()
+ router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/some/spa/route", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("SPA fallback = %d, want 200 index.html", rec.Code)
+ }
+}
+
+// TestPrepareServeSecondRunKeepsProjects reruns the bootstrap over an existing
+// home and checks it neither reseeds nor duplicates anything.
+func TestPrepareServeSecondRunKeepsProjects(t *testing.T) {
+ homeDir := filepath.Join(t.TempDir(), "cueto-home")
+ if _, _, err := prepareServe(homeDir, "", 0, ""); err != nil {
+ t.Fatalf("first run: %v", err)
+ }
+ marker := filepath.Join(homeDir, "projects", assets.DemoProjectID, "marker.cue")
+ if err := os.WriteFile(marker, []byte("package main\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ router, _, err := prepareServe(homeDir, "", 0, "")
+ if err != nil {
+ t.Fatalf("second run: %v", err)
+ }
+ if _, err := os.Stat(marker); err != nil {
+ t.Fatalf("user file lost on second run: %v", err)
+ }
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/projects", nil))
+ var list struct {
+ Projects []struct {
+ ID string `json:"id"`
+ } `json:"projects"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
+ t.Fatalf("decode projects: %v", err)
+ }
+ if len(list.Projects) != 1 || list.Projects[0].ID != assets.DemoProjectID {
+ t.Fatalf("projects after second run = %+v, want only the demo", list.Projects)
+ }
+}
+
+// TestPrepareServeReadsConfigCue checks config.cue drives the port and that a
+// flag beats it.
+func TestPrepareServeReadsConfigCue(t *testing.T) {
+ homeDir := filepath.Join(t.TempDir(), "cueto-home")
+ if err := os.MkdirAll(homeDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(homeDir, "config.cue"), []byte("port: 9000\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ _, cfg, err := prepareServe(homeDir, "", 0, "")
+ if err != nil {
+ t.Fatalf("prepareServe: %v", err)
+ }
+ if cfg.Port != "9000" {
+ t.Fatalf("port = %s, want 9000 from config.cue", cfg.Port)
+ }
+ _, cfg, err = prepareServe(homeDir, "", 9500, "")
+ if err != nil {
+ t.Fatalf("prepareServe with flag: %v", err)
+ }
+ if cfg.Port != "9500" {
+ t.Fatalf("port = %s, want flag 9500 over config.cue", cfg.Port)
+ }
+}
+
+// TestPrepareServeRejectsBadConfig ensures a config.cue typo fails startup with
+// a diagnostic instead of being silently ignored.
+func TestPrepareServeRejectsBadConfig(t *testing.T) {
+ homeDir := filepath.Join(t.TempDir(), "cueto-home")
+ if err := os.MkdirAll(homeDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(homeDir, "config.cue"), []byte("prot: 9000\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := prepareServe(homeDir, "", 0, ""); err == nil {
+ t.Fatal("prepareServe accepted invalid config.cue")
+ }
+}
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index afb4ba8..bd4c6ae 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -4,7 +4,10 @@
// License: Mozilla Public License v2.0 (MPL v2.0)
// SPDX-License-Identifier: MPL-2.0
-// Command backend serves CUE evaluation for the diagram app.
+// Command backend serves CUE evaluation for the diagram app in development: it
+// is configured through the environment (.env), pairs with the Vite dev server,
+// and serves no UI itself. The packaged, self-contained equivalent is `cueto
+// serve`, which embeds the UI and schema and needs no checkout.
//
// The hand-owned schema.cue is loaded fresh from disk (CUE_DIR, default ../cue)
// and is never machine-written. The editable data.cue is supplied per request
@@ -15,14 +18,8 @@
package main
import (
- "context"
- "errors"
"log"
- "net/http"
"os"
- "os/signal"
- "syscall"
- "time"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
@@ -31,6 +28,8 @@ import (
"github.com/stratorys/cueto/backend/internal/config"
"github.com/stratorys/cueto/backend/internal/evaluation"
"github.com/stratorys/cueto/backend/internal/handlers"
+ "github.com/stratorys/cueto/backend/internal/home"
+ "github.com/stratorys/cueto/backend/internal/server"
)
func main() {
@@ -51,37 +50,17 @@ func main() {
log.Fatalf("Load config: %v", err)
}
- // Explicit server timeouts bound the connection layer that the body cap and
- // eval deadline do not: slow-client (slowloris) reads and stuck writes.
- // WriteTimeout must exceed the eval deadline or long evaluations get cut off
- // mid-response.
- server := &http.Server{
- Addr: ":" + cfg.Port,
- Handler: handlers.NewRouter(evaluation.New(cfg.CueDir, cfg.EvalTimeout, cfg.MaxOutputBytes), authoring.New(), cfg),
- ReadHeaderTimeout: 5 * time.Second,
- ReadTimeout: 15 * time.Second,
- WriteTimeout: cfg.EvalTimeout + 10*time.Second,
- IdleTimeout: 60 * time.Second,
+ // Selection state lives in the standard cueto home even for the env-driven dev
+ // server, keyed by projects root, so dev and packaged serve never clobber each
+ // other. A home that cannot resolve only disables persistence.
+ var sel handlers.SelectionStore
+ if root, homeErr := home.DefaultRoot(); homeErr == nil {
+ sel = home.New(root)
}
- // Serve until a termination signal, then drain in-flight requests so running
- // evaluations finish (or hit their own deadline) instead of being cut off.
- go func() {
- if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
- log.Fatalf("Serve: %v", err)
- }
- }()
+ router := handlers.NewRouter(evaluation.New(cfg.CueDir, cfg.EvalTimeout, cfg.MaxOutputBytes), authoring.New(), cfg, sel)
log.Printf("Listening on :%s, schema dir %s, projects dir %s", cfg.Port, cfg.CueDir, cfg.ProjectsDir)
-
- ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
- defer stop()
- <-ctx.Done()
- stop()
- log.Println("Shutting down...")
-
- shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
- defer cancel()
- if err := server.Shutdown(shutdownCtx); err != nil {
- log.Fatalf("Shutdown: %v", err)
+ if err := server.Run(router, cfg.Port, cfg.EvalTimeout); err != nil {
+ log.Fatalf("Serve: %v", err)
}
}
diff --git a/backend/go.mod b/backend/go.mod
index e0c5486..a49d5c4 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -10,61 +10,61 @@ require (
)
require (
- cuelabs.dev/go/oci/ociregistry v0.0.0-20260601085548-328ff8e2c943 // indirect
- dario.cat/mergo v1.0.0 // indirect
+ cuelabs.dev/go/oci/ociregistry v0.0.0-20260618065901-6befdbcb3cf6 // indirect
+ dario.cat/mergo v1.0.2 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
- github.com/ProtonMail/go-crypto v1.1.6 // indirect
- github.com/bytedance/gopkg v0.1.3 // indirect
- github.com/bytedance/sonic v1.15.0 // indirect
- github.com/bytedance/sonic/loader v0.5.0 // indirect
- github.com/cloudflare/circl v1.6.3 // indirect
- github.com/cloudwego/base64x v0.1.6 // indirect
+ github.com/ProtonMail/go-crypto v1.4.1 // indirect
+ github.com/bytedance/gopkg v0.1.4 // indirect
+ github.com/bytedance/sonic v1.15.2 // indirect
+ github.com/bytedance/sonic/loader v0.5.1 // indirect
+ github.com/cloudflare/circl v1.6.4 // indirect
+ github.com/cloudwego/base64x v0.1.7 // indirect
github.com/cockroachdb/apd/v3 v3.2.3 // indirect
- github.com/cyphar/filepath-securejoin v0.6.1 // indirect
+ github.com/cyphar/filepath-securejoin v0.7.0 // indirect
github.com/emicklei/proto v1.14.3 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
- github.com/gabriel-vasile/mimetype v1.4.12 // indirect
- github.com/gin-contrib/sse v1.1.0 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.13 // indirect
+ github.com/gin-contrib/sse v1.1.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.9.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
- github.com/go-playground/validator/v10 v10.30.1 // indirect
- github.com/goccy/go-json v0.10.5 // indirect
+ github.com/go-playground/validator/v10 v10.30.3 // indirect
+ github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/json-iterator/go v1.1.12 // indirect
- github.com/kevinburke/ssh_config v1.2.0 // indirect
- github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+ github.com/kevinburke/ssh_config v1.6.0 // indirect
+ github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
- github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
- github.com/pelletier/go-toml/v2 v2.3.1 // indirect
+ github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/protocolbuffers/txtpbfmt v0.0.0-20260420112717-c39628bde8b5 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
- github.com/quic-go/quic-go v0.59.0 // indirect
+ github.com/quic-go/quic-go v0.60.0 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
- github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
- github.com/skeema/knownhosts v1.3.1 // indirect
+ github.com/sergi/go-diff v1.4.0 // indirect
+ github.com/skeema/knownhosts v1.3.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
- go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
+ go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
- golang.org/x/arch v0.22.0 // indirect
- golang.org/x/crypto v0.53.0 // indirect
- golang.org/x/net v0.56.0 // indirect
+ golang.org/x/arch v0.29.0 // indirect
+ golang.org/x/crypto v0.54.0 // indirect
+ golang.org/x/net v0.57.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
- golang.org/x/sync v0.21.0 // indirect
- golang.org/x/sys v0.46.0 // indirect
- golang.org/x/text v0.38.0 // indirect
- google.golang.org/protobuf v1.36.10 // indirect
+ golang.org/x/sync v0.22.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/text v0.40.0 // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
)
diff --git a/backend/go.sum b/backend/go.sum
index a718a15..b14c09a 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -1,32 +1,32 @@
-cuelabs.dev/go/oci/ociregistry v0.0.0-20260601085548-328ff8e2c943 h1:XUtzi/yWlmuy8V6kkmVbbmirmUqcFe9Ce3gmEaHXf1Q=
-cuelabs.dev/go/oci/ociregistry v0.0.0-20260601085548-328ff8e2c943/go.mod h1:WjmQxb+W6nVNCgj8nXrF24lIz95AHwnSl36tpjDZSU8=
+cuelabs.dev/go/oci/ociregistry v0.0.0-20260618065901-6befdbcb3cf6 h1:kcpQNPyadaMvi8O9qkIRbkLAGiQr1RO1RUbrLms43XQ=
+cuelabs.dev/go/oci/ociregistry v0.0.0-20260618065901-6befdbcb3cf6/go.mod h1:WjmQxb+W6nVNCgj8nXrF24lIz95AHwnSl36tpjDZSU8=
cuelang.org/go v0.17.0 h1:PrijS5ofUD01yiG11w74I04laXKLaBiMhEYvdt8Gb/A=
cuelang.org/go v0.17.0/go.mod h1:xlly/o1wSLvxOsi5vkQGieU0rLOt7TvUIizOFtnxHRU=
-dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
-dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
+dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
+dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
-github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
-github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
+github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
+github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
-github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
-github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
-github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
-github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
-github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
-github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
-github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
-github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
-github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
-github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
+github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
+github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
+github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
+github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
+github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
+github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
+github.com/cloudflare/circl v1.6.4 h1:pOXuDTCEYyzydgUpQ0CQz3LsinKjiSk6nNP5Lt5K64U=
+github.com/cloudflare/circl v1.6.4/go.mod h1:YxarevkLlbaHuWsxG6vmYNWBEsSp4pnp7j+4VljMavY=
+github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
+github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
github.com/cockroachdb/apd/v3 v3.2.3 h1:4Zx+I3R35bFXMnltzmjP79i2cravE4jTRL6ps9Aux80=
github.com/cockroachdb/apd/v3 v3.2.3/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc=
-github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
-github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
+github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE=
+github.com/cyphar/filepath-securejoin v0.7.0/go.mod h1:ymLGms/u3BYaviIiuKFnUx8EkQEZeK6cInNoAPJA3o4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -36,10 +36,10 @@ github.com/emicklei/proto v1.14.3 h1:zEhlzNkpP8kN6utonKMzlPfIvy82t5Kb9mufaJxSe1Q
github.com/emicklei/proto v1.14.3/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
-github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
-github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
-github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
-github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
+github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
+github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
+github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
+github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
@@ -58,12 +58,12 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
-github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
-github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
+github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
+github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
-github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
-github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
+github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
@@ -79,10 +79,10 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
-github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
-github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
-github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
-github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
+github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
+github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
+github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -96,8 +96,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=
github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
-github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
-github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
+github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -111,8 +111,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
-github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
-github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
+github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -121,17 +121,19 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/protocolbuffers/txtpbfmt v0.0.0-20260420112717-c39628bde8b5 h1:Mckui8l+Wqz2Ve7XQvsE8SbHNmDWu8NA7Xce5NFJ/kM=
github.com/protocolbuffers/txtpbfmt v0.0.0-20260420112717-c39628bde8b5/go.mod h1:JSbkp0BviKovYYt9XunS95M3mLPibE9bGg+Y95DsEEY=
+github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
+github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
-github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
-github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
+github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
+github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
-github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
-github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
+github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
+github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
-github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
-github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
+github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
+github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -151,48 +153,47 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
-go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
-go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
+go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
+go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
-golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
-golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
+golang.org/x/arch v0.29.0 h1:8sSET5wB0+exBm0FGmOtdHMqjlRdV2DRD3/IV6OZgho=
+golang.org/x/arch v0.29.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
-golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
+golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
+golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
-golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
+golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
+golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
-golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
-golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
-golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
-golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
+golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
+golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
-golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
+golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
+golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
-golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
-google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
-google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
diff --git a/backend/internal/assets/assets.go b/backend/internal/assets/assets.go
new file mode 100644
index 0000000..78089c3
--- /dev/null
+++ b/backend/internal/assets/assets.go
@@ -0,0 +1,80 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+// Package assets carries the files a standalone cueto binary needs at runtime:
+// the hand-owned diagram schema module, the demo project seeded on first run,
+// and the built web UI. The schema and demo are byte-for-byte copies of cue/
+// and examples/service-catalog, enforced by this package's drift tests, so the
+// repo stays the single source of truth. webui/ holds a placeholder page that
+// the release build replaces with the real frontend dist before compiling.
+package assets
+
+import (
+ "embed"
+ "io/fs"
+ "os"
+ "path/filepath"
+)
+
+//go:embed all:cueschema
+var schemaFS embed.FS
+
+//go:embed all:demo
+var demoFS embed.FS
+
+//go:embed all:webui
+var webuiFS embed.FS
+
+// DemoProjectID is the id the seeded demo project gets under a projects root.
+const DemoProjectID = "service-catalog"
+
+// Schema returns the embedded diagram schema module (cue.mod, diagram/,
+// knowledge/), rooted so its top level is the module root.
+func Schema() fs.FS {
+ return mustSub(schemaFS, "cueschema")
+}
+
+// Demo returns the embedded demo project module, rooted at the module root.
+func Demo() fs.FS {
+ return mustSub(demoFS, "demo")
+}
+
+// WebUI returns the embedded web UI file tree (index.html at its root).
+func WebUI() fs.FS {
+ return mustSub(webuiFS, "webui")
+}
+
+// MaterializeSchema writes the embedded schema module under dst, overwriting
+// existing files, so a standalone binary can hand the evaluator a real on-disk
+// schema dir without shipping the repo. Files removed from the schema in a newer
+// binary are not cleaned up; the loader only reads the packages it asks for.
+func MaterializeSchema(dst string) error {
+ return fs.WalkDir(Schema(), ".", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ target := filepath.Join(dst, filepath.FromSlash(path))
+ if d.IsDir() {
+ return os.MkdirAll(target, 0o755)
+ }
+ content, err := fs.ReadFile(Schema(), path)
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(target, content, 0o644)
+ })
+}
+
+// mustSub roots an embedded tree at its top directory. The directory name is a
+// compile-time constant matching the embed directive, so failure is impossible
+// in a correctly built binary and would mean the binary itself is broken.
+func mustSub(fsys embed.FS, dir string) fs.FS {
+ sub, err := fs.Sub(fsys, dir)
+ if err != nil {
+ panic(err)
+ }
+ return sub
+}
diff --git a/backend/internal/assets/assets_test.go b/backend/internal/assets/assets_test.go
new file mode 100644
index 0000000..f338535
--- /dev/null
+++ b/backend/internal/assets/assets_test.go
@@ -0,0 +1,91 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+package assets
+
+import (
+ "bytes"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// TestSchemaMatchesRepo pins the embedded schema to the hand-owned cue/ module:
+// every embedded file must byte-match its repo counterpart and every repo schema
+// file must be embedded. data.cue is excluded deliberately - it is the repo's
+// default project instance, not schema a standalone binary needs.
+func TestSchemaMatchesRepo(t *testing.T) {
+ assertTreesEqual(t, Schema(), "../../../cue", func(rel string) bool {
+ return rel != "data.cue"
+ })
+}
+
+// TestDemoMatchesRepo pins the embedded demo to examples/service-catalog, so the
+// project seeded on first run is exactly the one the README documents.
+func TestDemoMatchesRepo(t *testing.T) {
+ assertTreesEqual(t, Demo(), "../../../examples/service-catalog", func(string) bool {
+ return true
+ })
+}
+
+func TestMaterializeSchemaWritesModule(t *testing.T) {
+ dst := t.TempDir()
+ if err := MaterializeSchema(dst); err != nil {
+ t.Fatalf("MaterializeSchema: %v", err)
+ }
+ for _, rel := range []string{"cue.mod/module.cue", "diagram/diagram.cue", "knowledge/knowledge.cue"} {
+ if _, err := os.Stat(filepath.Join(dst, rel)); err != nil {
+ t.Fatalf("materialized schema missing %s: %v", rel, err)
+ }
+ }
+}
+
+// assertTreesEqual checks the embedded tree and the repo directory hold the same
+// files with the same bytes, ignoring repo files rejected by include.
+func assertTreesEqual(t *testing.T, embedded fs.FS, repoDir string, include func(rel string) bool) {
+ t.Helper()
+ embeddedFiles := map[string]bool{}
+ err := fs.WalkDir(embedded, ".", func(path string, d fs.DirEntry, err error) error {
+ if err != nil || d.IsDir() {
+ return err
+ }
+ embeddedFiles[path] = true
+ want, rerr := os.ReadFile(filepath.Join(repoDir, filepath.FromSlash(path)))
+ if rerr != nil {
+ t.Errorf("embedded %s has no repo counterpart: %v", path, rerr)
+ return nil
+ }
+ got, gerr := fs.ReadFile(embedded, path)
+ if gerr != nil {
+ return gerr
+ }
+ if !bytes.Equal(got, want) {
+ t.Errorf("embedded %s differs from repo copy; re-copy it from %s", path, repoDir)
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("walk embedded: %v", err)
+ }
+ err = filepath.WalkDir(repoDir, func(path string, d fs.DirEntry, err error) error {
+ if err != nil || d.IsDir() {
+ return err
+ }
+ rel, rerr := filepath.Rel(repoDir, path)
+ if rerr != nil {
+ return rerr
+ }
+ rel = filepath.ToSlash(rel)
+ if include(rel) && !embeddedFiles[rel] {
+ t.Errorf("repo file %s is not embedded; copy it into the assets package", rel)
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("walk repo: %v", err)
+ }
+}
diff --git a/backend/internal/assets/cueschema/cue.mod/module.cue b/backend/internal/assets/cueschema/cue.mod/module.cue
new file mode 100644
index 0000000..ac665c5
--- /dev/null
+++ b/backend/internal/assets/cueschema/cue.mod/module.cue
@@ -0,0 +1,12 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+// CUE module root. Making cue/ a module turns it into an import root so exports
+// are portable. The diagram schema lives in the diagram/ subpackage and is
+// imported as "github.com/stratorys/cueto/diagram"; the default project (data.cue)
+// is package main and imports it.
+module: "github.com/stratorys/cueto"
+language: version: "v0.17.0"
diff --git a/backend/internal/assets/cueschema/diagram/diagram.cue b/backend/internal/assets/cueschema/diagram/diagram.cue
new file mode 100644
index 0000000..340fbeb
--- /dev/null
+++ b/backend/internal/assets/cueschema/diagram/diagram.cue
@@ -0,0 +1,69 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+// The diagram schema, an importable package. A user's own package derives its
+// diagram by importing this and unifying with #Diagram, so the definitions are
+// reachable across packages instead of only as same-package fields.
+package diagram
+
+#Diagram: {
+ nodes: [ID=string]: #Node & {id: ID}
+ edges: [...#Edge]
+}
+
+#Node: {
+ id: string
+ type: "entity" | "table" | "process" | "decision" | "shape" | "container"
+ // Id of the containing node when nested; a child's x/y are relative to it.
+ parent?: string
+ // Optional coordinates. A canvas-drawn node carries them; a data-derived node
+ // omits them and is auto-laid-out (its position stays view-only, never written
+ // back), so a file that derives its diagram from data can stay coordinate-free.
+ x?: number
+ y?: number
+ // Optional explicit size in graph units; the canvas falls back to a
+ // content-derived size when these are absent.
+ width?: number
+ height?: number
+ label: string
+ // Arbitrary structured payload, rendered as a key/value card. Lets a node
+ // carry domain data (records, facts) with no bespoke schema field.
+ data?: {...}
+ // Typed payload for a DB table.
+ columns?: [...#Column]
+ // Annotation payload, set only when type is "shape".
+ shape?: "rectangle" | "ellipse" | "diamond" | "line" | "text"
+ // Optional per-shape colors (any CSS color string).
+ fill?: string
+ stroke?: string
+ // Line only: drag direction (true = "\", absent = "/").
+ flip?: bool
+ icon?: string
+}
+
+#Column: {
+ name: string
+ dbType: string
+ pk?: bool
+ fk?: bool
+}
+
+#Edge: {
+ id: string
+ source: string
+ sourceHandle?: string
+ target: string
+ targetHandle?: string
+ kind: "relation" | "arrow" | "inherit" | "line"
+ // Optional free-form text drawn on the edge, edited inline on the canvas.
+ label?: string
+ card?: "1-1" | "1-n" | "n-n"
+ // Optional cosmetic routing: bend points the user dragged, stored relative to
+ // the source->target line so they follow when either endpoint moves. Each point
+ // is a fraction t of the way from source to target and a signed perpendicular
+ // offset in graph units. Absent -> the edge is auto-routed.
+ points?: [...{t: number, off: number}]
+}
diff --git a/backend/internal/assets/cueschema/knowledge/knowledge.cue b/backend/internal/assets/cueschema/knowledge/knowledge.cue
new file mode 100644
index 0000000..d4d7f39
--- /dev/null
+++ b/backend/internal/assets/cueschema/knowledge/knowledge.cue
@@ -0,0 +1,62 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+// Package knowledge provides Cueto's optional explicit knowledge contract.
+// Modules do not need to import it: Cueto continues to discover registries and
+// relations structurally. Importing it makes the metadata surface type-checked
+// and stable for CLI, HTTP, and MCP consumers.
+//
+// When a domain's label is also its collection field, bind the collection with a
+// top-level let and refer to that alias from the domain. CUE resolves an unqualified
+// `customers` inside domains.customers as the enclosing field, which is a cycle.
+package knowledge
+
+#Knowledge: {
+ metadata: {
+ title: string
+ description?: string
+ revision?: string
+ }
+
+ domains: [string]: #Domain
+ evaluations?: [string]: #Evaluation
+ observations?: [string]: #Observation
+ checks?: [string]: bool
+}
+
+#Domain: {
+ description?: string
+ collection: _
+ key?: string | *"id"
+}
+
+#Evaluation: {
+ description: string
+ input: _
+ result: _
+}
+
+// #Evaluations is the optional root-level contract for phase-six named
+// evaluations: `evaluations: knowledge.#Evaluations & { ... }`.
+#Evaluations: [string]: #Evaluation
+
+#SourceRef: {
+ kind: "file" | "uri" | "database" | "manual"
+ uri: string @uri()
+ pointer?: string
+ retrievedAt?: string
+}
+
+#Observation: {
+ entity: string
+ field: string
+ value: _
+ source: #SourceRef
+ status: "active" | "stale" | "disputed"
+ authority?: int & >=0 & <=100
+}
+
+#Observations: [string]: #Observation
diff --git a/backend/internal/assets/demo/access.cue b/backend/internal/assets/demo/access.cue
new file mode 100644
index 0000000..1c4c039
--- /dev/null
+++ b/backend/internal/assets/demo/access.cue
@@ -0,0 +1,20 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+// Who may do what. Each role is a disjunction (a set) of permissions, so roles
+// form a semilattice under unification: `roles.a & roles.b` is the intersection
+// of what both allow, `_` (top) allows anything, and two disjoint permissions
+// unify to bottom. The REPL section of the README walks through it.
+package main
+
+#Perm: "read" | "write" | "deploy" | "admin"
+
+roles: {
+ viewer: "read"
+ developer: "read" | "write"
+ operator: "read" | "write" | "deploy"
+ owner: #Perm
+}
diff --git a/backend/internal/assets/demo/catalog.cue b/backend/internal/assets/demo/catalog.cue
new file mode 100644
index 0000000..5d7fcf7
--- /dev/null
+++ b/backend/internal/assets/demo/catalog.cue
@@ -0,0 +1,100 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+// The demo the README walks through: a small engineering organization as plain
+// schema and data. No diagram is authored and nothing is imported from cueto.
+// The graph is inferred from the registries (teams, people, services) and their
+// key-set references (team, owner, techLead, dependsOn), and the knowledge
+// runtime discovers the same registries plus the named evaluations below.
+package main
+
+import "list"
+
+#TeamID: or([for id, _ in teams {id}])
+#PersonID: or([for id, _ in people {id}])
+#ServiceID: or([for id, _ in services {id}])
+
+#Team: {
+ name: string
+ channel: string
+}
+
+#Person: {
+ name: string
+ team: #TeamID
+}
+
+#Service: {
+ name: string
+ owner: #TeamID
+ techLead: #PersonID
+ tier: "critical" | "standard" | "internal"
+ dependsOn: [...#ServiceID]
+}
+
+teams: [ID=string]: #Team
+teams: {
+ platform: {name: "Platform", channel: "#team-platform"}
+ payments: {name: "Payments", channel: "#team-payments"}
+ web: {name: "Web", channel: "#team-web"}
+}
+
+people: [ID=string]: #Person
+people: {
+ alice: {name: "Alice Moreau", team: "platform"}
+ bruno: {name: "Bruno Keller", team: "payments"}
+ chloe: {name: "Chloé Diallo", team: "payments"}
+ dana: {name: "Dana Costa", team: "web"}
+}
+
+services: [ID=string]: #Service
+services: {
+ gateway: {
+ name: "API Gateway"
+ owner: "platform"
+ techLead: "alice"
+ tier: "critical"
+ }
+ billing: {
+ name: "Billing"
+ owner: "payments"
+ techLead: "bruno"
+ tier: "critical"
+ dependsOn: ["gateway", "ledger"]
+ }
+ ledger: {
+ name: "Ledger"
+ owner: "payments"
+ techLead: "chloe"
+ tier: "critical"
+ }
+ storefront: {
+ name: "Storefront"
+ owner: "web"
+ techLead: "dana"
+ tier: "standard"
+ dependsOn: ["gateway", "billing"]
+ }
+}
+
+evaluations: {
+ ownerOf: {
+ description: "Which team owns a service, and how to reach them"
+ input: {serviceId: #ServiceID}
+ result: {
+ team: services[input.serviceId].owner
+ channel: teams[services[input.serviceId].owner].channel
+ lead: people[services[input.serviceId].techLead].name
+ }
+ }
+ blastRadius: {
+ description: "Which services break if this service goes down"
+ input: {serviceId: #ServiceID}
+ result: {
+ dependents: [for id, s in services if list.Contains(s.dependsOn, input.serviceId) {id}]
+ }
+ }
+}
diff --git a/backend/internal/assets/demo/cue.mod/module.cue b/backend/internal/assets/demo/cue.mod/module.cue
new file mode 100644
index 0000000..1fe30fe
--- /dev/null
+++ b/backend/internal/assets/demo/cue.mod/module.cue
@@ -0,0 +1,11 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+// The demo project: a plain CUE module with no cueto import, shaped exactly like
+// the module POST /projects scaffolds, so it behaves like a project the app
+// created itself.
+module: "example.com/service-catalog"
+language: version: "v0.17.0"
diff --git a/backend/internal/assets/demo/deploy.cue b/backend/internal/assets/demo/deploy.cue
new file mode 100644
index 0000000..99b3ba6
--- /dev/null
+++ b/backend/internal/assets/demo/deploy.cue
@@ -0,0 +1,30 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+// How services run in each environment. Configs form a meet-semilattice under
+// unification: every concrete environment is `configBase & overlay`, the
+// greatest lower bound of both, defaults fill what the overlay leaves open, and
+// an overlay that contradicts the base is bottom, a build error.
+package main
+
+#Config: {
+ replicas: int & >=1
+ logLevel: "debug" | "info" | "error"
+ memoryMb: int & >=128
+}
+
+configBase: #Config & {
+ replicas: *1 | _
+ logLevel: *"info" | _
+ memoryMb: *256 | _
+}
+
+environments: [ID=string]: #Config
+environments: {
+ dev: configBase
+ staging: configBase & {replicas: 2}
+ prod: configBase & {replicas: 3, logLevel: "error", memoryMb: 1024}
+}
diff --git a/backend/internal/assets/webui/index.html b/backend/internal/assets/webui/index.html
new file mode 100644
index 0000000..37965ea
--- /dev/null
+++ b/backend/internal/assets/webui/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+ cueto
+
+
+
+ This build of cueto was compiled without the web UI. Release binaries embed
+ it; in development run the Vite dev server in frontend/ instead.
+
+
+
diff --git a/backend/internal/evaluation/evaluator.go b/backend/internal/evaluation/evaluator.go
index 522e1fd..80d3616 100644
--- a/backend/internal/evaluation/evaluator.go
+++ b/backend/internal/evaluation/evaluator.go
@@ -21,6 +21,7 @@ import (
"path/filepath"
"runtime/debug"
"sort"
+ "strings"
"sync"
"time"
@@ -260,6 +261,70 @@ func (e *Engine) EvalQuery(ctx context.Context, src Source, expr string) (json.R
return out, nil, nil
}
+// CompileValue builds one package from a module without imposing any diagram
+// schema or concreteness requirement. It is the generic compiler seam used by
+// the knowledge package. It deliberately reuses the evaluator's module loader,
+// overlay guard, deadline, panic recovery, and diagnostic scrubbing rather than
+// maintaining a second CUE loading path.
+func (e *Engine) CompileValue(ctx context.Context, src Source) (cue.Value, []diag.Diagnostic, error) {
+ ctx, cancel := context.WithTimeout(ctx, e.timeout)
+ defer cancel()
+
+ done := make(chan buildResult, 1)
+ go func() {
+ done <- recoverToResult(func() buildResult {
+ value, diags, err := e.buildValue(src)
+ return buildResult{root: value, diags: diags, err: err}
+ })
+ }()
+
+ select {
+ case <-ctx.Done():
+ return cue.Value{}, nil, ErrTimeout
+ case r := <-done:
+ return r.root, r.diags, r.err
+ }
+}
+
+// EncodeValue validates and encodes an already compiled value under the same
+// output limit used by Eval and EvalQuery. Generic runtime projections use this
+// instead of bypassing the evaluator with an unbounded MarshalJSON call.
+func (e *Engine) EncodeValue(value cue.Value, src Source) (json.RawMessage, []diag.Diagnostic, error) {
+ if err := value.Validate(cue.Concrete(true)); err != nil {
+ return nil, diag.From(err, src.Dir, diag.KindIncomplete), nil
+ }
+ out, err := value.MarshalJSON()
+ if err != nil {
+ return nil, diag.From(err, src.Dir, diag.KindIncomplete), nil
+ }
+ if len(out) > e.maxOutputBytes {
+ return nil, nil, ErrOutputTooLarge
+ }
+ return out, nil, nil
+}
+
+// buildValue is the diagram-independent half of build. Unlike build it neither
+// discovers views nor requires a value to be concrete: a valid abstract schema
+// is useful compiled knowledge in its own right.
+func (e *Engine) buildValue(src Source) (cue.Value, []diag.Diagnostic, error) {
+ instances, diags := e.loadModule(src, "")
+ if diags != nil {
+ return cue.Value{}, diags, nil
+ }
+ root := packageInstance(instances, src.Dir, src.Package)
+ if root == nil {
+ return cue.Value{}, nil, errors.New("no CUE instance loaded for requested package")
+ }
+ if err := root.Err; err != nil {
+ return cue.Value{}, diag.From(err, src.Dir, diag.KindParse), nil
+ }
+ value := cuecontext.New().BuildInstance(root)
+ if err := value.Err(); err != nil {
+ return cue.Value{}, diag.From(err, src.Dir, diag.KindSchema), nil
+ }
+ return value, nil, nil
+}
+
type exprResult struct {
json json.RawMessage
diags []diag.Diagnostic
@@ -550,10 +615,32 @@ func defaultView(views []view) int {
// the project explicitly rather than trust slice order. A nil result means the
// module has no package at its root.
func rootInstance(instances []*build.Instance, dir string) *build.Instance {
+ return packageInstance(instances, dir, "")
+}
+
+// packageInstance selects either the module-root package or a package directory
+// relative to it. The relative-path check prevents a package selector from
+// escaping the already trusted module root.
+func packageInstance(instances []*build.Instance, dir, pkg string) *build.Instance {
want, err := filepath.Abs(dir)
if err != nil {
return nil
}
+ if pkg != "" && pkg != "." {
+ if filepath.IsAbs(pkg) {
+ return nil
+ }
+ want = filepath.Join(want, pkg)
+ root, err := filepath.Abs(dir)
+ if err != nil {
+ return nil
+ }
+ rel, err := filepath.Rel(root, want)
+ if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
+ return nil
+ }
+ }
+ want = filepath.Clean(want)
for _, inst := range instances {
if filepath.Clean(inst.Dir) == want {
return inst
diff --git a/backend/internal/evaluation/infer.go b/backend/internal/evaluation/infer.go
index d352fd5..611edce 100644
--- a/backend/internal/evaluation/infer.go
+++ b/backend/internal/evaluation/infer.go
@@ -88,6 +88,104 @@ type registry struct {
keys []string
}
+// RegistryInfo is the diagram-independent shape discovery result shared with
+// the knowledge compiler. A registry is a top-level open-label struct whose
+// members conform to a struct pattern; no domain vocabulary is assumed.
+type RegistryInfo struct {
+ Name string
+ Members []string
+}
+
+// RegistrySchemaInfo is the schema-level companion to RegistryInfo. It reuses
+// the same registry and relation detectors that drive inferred diagrams, making
+// it suitable for a machine-readable knowledge catalog.
+type RegistrySchemaInfo struct {
+ Name string
+ Members []string
+ Fields []RegistryFieldInfo
+}
+
+type RegistryFieldInfo struct {
+ Name string
+ Type string
+ Required bool
+ Relation *RegistryRelationInfo
+}
+
+type RegistryRelationInfo struct {
+ Domain string
+ Cardinality string
+ Rule string
+}
+
+// DiscoverRegistries exposes the existing structural registry detector without
+// exposing its diagram projection types. It keeps implicit knowledge discovery
+// and inferred diagram discovery grounded in exactly the same CUE shape rule.
+func DiscoverRegistries(project cue.Value) []RegistryInfo {
+ registries := detectRegistries(project)
+ result := make([]RegistryInfo, 0, len(registries))
+ for _, registry := range registries {
+ result = append(result, RegistryInfo{Name: registry.field, Members: append([]string(nil), registry.keys...)})
+ }
+ return result
+}
+
+// DescribeRegistries exposes registry fields and discovered key-set/@ref
+// relations without importing diagram projection types. This is the one shared
+// structural discovery implementation for diagrams and the knowledge catalog.
+func DescribeRegistries(project cue.Value) []RegistrySchemaInfo {
+ registries := detectRegistries(project)
+ regNames := make(map[string]bool, len(registries))
+ for _, reg := range registries {
+ regNames[reg.field] = true
+ }
+ keySets := keySetDefs(project, regNames)
+ result := make([]RegistrySchemaInfo, 0, len(registries))
+ for _, reg := range registries {
+ refs := entityReferences(reg.schema, registries, keySets, regNames)
+ byField := map[string]reference{}
+ for _, ref := range refs {
+ byField[ref.field] = ref
+ }
+ info := RegistrySchemaInfo{Name: reg.field, Members: append([]string(nil), reg.keys...), Fields: []RegistryFieldInfo{}}
+ iter, err := reg.schema.Fields(cue.Optional(true))
+ if err != nil {
+ result = append(result, info)
+ continue
+ }
+ for iter.Next() {
+ sel := iter.Selector()
+ if !sel.IsString() {
+ continue
+ }
+ field := RegistryFieldInfo{Name: sel.Unquoted(), Type: catalogType(iter.Value()), Required: !iter.IsOptional()}
+ if ref, ok := byField[field.Name]; ok {
+ cardinality := "one"
+ if ref.list {
+ cardinality = "many"
+ }
+ field.Relation = &RegistryRelationInfo{Domain: ref.targetField, Cardinality: cardinality, Rule: ref.rule}
+ }
+ info.Fields = append(info.Fields, field)
+ }
+ sort.Slice(info.Fields, func(i, j int) bool { return info.Fields[i].Name < info.Fields[j].Name })
+ result = append(result, info)
+ }
+ return result
+}
+
+func catalogType(value cue.Value) string {
+ kind := value.IncompleteKind()
+ if kind&cue.ListKind != 0 {
+ element := value.LookupPath(cue.MakePath(cue.AnyIndex))
+ if element.Exists() {
+ return "list<" + catalogType(element) + ">"
+ }
+ return "list"
+ }
+ return kindLabel(value)
+}
+
// inferredViewName is the name of each derived view, shown in the frontend switcher.
// The model view (registries as types, drawn as tables) is the default; the instances
// view draws each concrete member as a node. Both are derived from the same detection.
diff --git a/backend/internal/evaluation/source.go b/backend/internal/evaluation/source.go
index 5e49f5a..575bac7 100644
--- a/backend/internal/evaluation/source.go
+++ b/backend/internal/evaluation/source.go
@@ -22,7 +22,11 @@ import "github.com/stratorys/cueto/backend/internal/domain"
// a name that no longer matches also falls back to the default, so a stale client
// selection never fails the eval.
type Source struct {
- Dir string // module root (contains cue.mod)
+ Dir string // module root (contains cue.mod)
+ // Package optionally selects a package below Dir for generic compilation. An
+ // empty value selects the module-root package, preserving the diagram
+ // evaluator's existing behaviour.
+ Package string
Overlay []domain.File // unsaved client buffers layered over Dir
View string // discovered view to render; empty = default
}
diff --git a/backend/internal/handlers/integration_test.go b/backend/internal/handlers/integration_test.go
index 9c7d8e6..8a55d95 100644
--- a/backend/internal/handlers/integration_test.go
+++ b/backend/internal/handlers/integration_test.go
@@ -58,7 +58,7 @@ func testConfig(t *testing.T) config.Config {
func realRouter(t *testing.T, cfg config.Config) *gin.Engine {
t.Helper()
- return NewRouter(evaluation.New(cfg.CueDir, cfg.EvalTimeout, cfg.MaxOutputBytes), authoring.New(), cfg)
+ return NewRouter(evaluation.New(cfg.CueDir, cfg.EvalTimeout, cfg.MaxOutputBytes), authoring.New(), cfg, nil)
}
// pp builds a path scoped to the default test project. ppid does the same for an
@@ -554,7 +554,7 @@ func TestConcurrencyLimit(t *testing.T) {
be := &blockingEval{entered: make(chan struct{}, 1), release: make(chan struct{})}
cfg := testConfig(t)
cfg.MaxConcurrent = 1
- router := NewRouter(be, authoring.New(), cfg)
+ router := NewRouter(be, authoring.New(), cfg, nil)
// First request occupies the only slot and blocks inside the handler.
go func() {
diff --git a/backend/internal/handlers/knowledge.go b/backend/internal/handlers/knowledge.go
new file mode 100644
index 0000000..1532524
--- /dev/null
+++ b/backend/internal/handlers/knowledge.go
@@ -0,0 +1,136 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+package handlers
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stratorys/cueto/backend/internal/domain"
+ "github.com/stratorys/cueto/backend/internal/knowledge"
+)
+
+func (h *handlers) knowledgeProject(c *gin.Context, files []domain.File) (knowledge.ProjectRef, bool) {
+ dir, ok := h.projectDir(c)
+ if !ok {
+ return knowledge.ProjectRef{}, false
+ }
+ if h.runtime == nil {
+ c.JSON(http.StatusNotImplemented, gin.H{"error": "knowledge runtime unavailable"})
+ return knowledge.ProjectRef{}, false
+ }
+ overlay := map[string][]byte{}
+ for _, f := range files {
+ overlay[f.Name] = []byte(f.Content)
+ }
+ return knowledge.ProjectRef{ModuleDir: dir, Overlay: overlay}, true
+}
+
+func knowledgeError(c *gin.Context, err error) {
+ var d *knowledge.DiagnosticError
+ if errors.As(err, &d) {
+ c.JSON(http.StatusBadRequest, gin.H{"diagnostics": d.Diagnostics})
+ return
+ }
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+}
+func (h *handlers) KnowledgeCatalog(c *gin.Context) {
+ p, ok := h.knowledgeProject(c, nil)
+ if !ok {
+ return
+ }
+ v, e := h.runtime.Catalog(c, p)
+ if e != nil {
+ knowledgeError(c, e)
+ return
+ }
+ c.JSON(http.StatusOK, v)
+}
+func (h *handlers) KnowledgeDescribe(c *gin.Context) {
+ p, ok := h.knowledgeProject(c, nil)
+ if !ok {
+ return
+ }
+ v, e := h.runtime.Describe(c, p, c.Param("domain"))
+ if e != nil {
+ knowledgeError(c, e)
+ return
+ }
+ c.JSON(http.StatusOK, v)
+}
+func (h *handlers) KnowledgeGet(c *gin.Context) {
+ p, ok := h.knowledgeProject(c, nil)
+ if !ok {
+ return
+ }
+ v, e := h.runtime.Get(c, p, c.Param("domain"), c.Param("key"))
+ if e != nil {
+ knowledgeError(c, e)
+ return
+ }
+ c.Data(http.StatusOK, "application/json", v)
+}
+func (h *handlers) KnowledgeQuery(c *gin.Context) {
+ var q knowledge.Query
+ if !bindJSON(c, &q) {
+ return
+ }
+ p, ok := h.knowledgeProject(c, nil)
+ if !ok {
+ return
+ }
+ v, e := h.runtime.Query(c, p, q)
+ if e != nil {
+ knowledgeError(c, e)
+ return
+ }
+ c.JSON(http.StatusOK, v)
+}
+func (h *handlers) KnowledgeEval(c *gin.Context) {
+ var body struct {
+ Input json.RawMessage `json:"input"`
+ }
+ if !bindJSON(c, &body) {
+ return
+ }
+ p, ok := h.knowledgeProject(c, nil)
+ if !ok {
+ return
+ }
+ v, e := h.runtime.Eval(c, p, knowledge.EvalRequest{Evaluation: c.Param("name"), Input: body.Input})
+ if e != nil {
+ knowledgeError(c, e)
+ return
+ }
+ c.JSON(http.StatusOK, v)
+}
+func (h *handlers) KnowledgeProvenance(c *gin.Context) {
+ p, ok := h.knowledgeProject(c, nil)
+ if !ok {
+ return
+ }
+ v, e := h.runtime.Provenance(c, p, c.Query("name"))
+ if e != nil {
+ knowledgeError(c, e)
+ return
+ }
+ c.JSON(http.StatusOK, v)
+}
+func (h *handlers) KnowledgeHealth(c *gin.Context) {
+ p, ok := h.knowledgeProject(c, nil)
+ if !ok {
+ return
+ }
+ v, e := h.runtime.Health(c, p)
+ if e != nil {
+ knowledgeError(c, e)
+ return
+ }
+ c.JSON(http.StatusOK, v)
+}
diff --git a/backend/internal/handlers/knowledge_test.go b/backend/internal/handlers/knowledge_test.go
new file mode 100644
index 0000000..11f5b0b
--- /dev/null
+++ b/backend/internal/handlers/knowledge_test.go
@@ -0,0 +1,219 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/stratorys/cueto/backend/internal/knowledge"
+)
+
+// knowledgeFixture is a small module with one registry domain and one named
+// evaluation, enough to exercise every knowledge endpoint end to end.
+const knowledgeFixture = `package main
+
+customers: [ID=string]: {name: string, country: string}
+customers: {
+ acme: {name: "Acme", country: "FR"}
+ globex: {name: "Globex", country: "US"}
+}
+
+evaluations: discount: {
+ description: "Evaluate a seat-count discount"
+ input: {seats: int & >=0}
+ result: {eligible: input.seats >= 10}
+}
+`
+
+func TestKnowledgeCatalogListsDomainsAndEvaluations(t *testing.T) {
+ id, router := tempWorkspace(t, map[string]string{"data.cue": knowledgeFixture})
+ rec := getJSON(router, ppid(id, "/knowledge/catalog"))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
+ }
+ var body struct {
+ Domains []struct {
+ Name string `json:"name"`
+ Kind string `json:"kind"`
+ } `json:"domains"`
+ Evaluations []struct {
+ Name string `json:"name"`
+ } `json:"evaluations"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("decode: %v (body %q)", err, rec.Body.String())
+ }
+ foundDomain, foundEval := false, false
+ for _, d := range body.Domains {
+ if d.Name == "customers" && d.Kind == "registry" {
+ foundDomain = true
+ }
+ }
+ for _, e := range body.Evaluations {
+ if e.Name == "discount" {
+ foundEval = true
+ }
+ }
+ if !foundDomain || !foundEval {
+ t.Fatalf("body = %+v, want customers domain and discount evaluation", body)
+ }
+}
+
+func TestKnowledgeDescribeReturnsMembers(t *testing.T) {
+ id, router := tempWorkspace(t, map[string]string{"data.cue": knowledgeFixture})
+ rec := getJSON(router, ppid(id, "/knowledge/domains/customers"))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
+ }
+ var body struct {
+ Members []string `json:"Members"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("decode: %v (body %q)", err, rec.Body.String())
+ }
+ if len(body.Members) != 2 {
+ t.Fatalf("members = %+v, want acme and globex", body.Members)
+ }
+}
+
+func TestKnowledgeDescribeUnknownDomain(t *testing.T) {
+ id, router := tempWorkspace(t, map[string]string{"data.cue": knowledgeFixture})
+ rec := getJSON(router, ppid(id, "/knowledge/domains/nope"))
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestKnowledgeGetReturnsRecord(t *testing.T) {
+ id, router := tempWorkspace(t, map[string]string{"data.cue": knowledgeFixture})
+ rec := getJSON(router, ppid(id, "/knowledge/domains/customers/acme"))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
+ }
+ var record map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &record); err != nil {
+ t.Fatalf("decode: %v (body %q)", err, rec.Body.String())
+ }
+ if record["name"] != "Acme" {
+ t.Fatalf("record = %+v", record)
+ }
+}
+
+func TestKnowledgeGetUnknownKey(t *testing.T) {
+ id, router := tempWorkspace(t, map[string]string{"data.cue": knowledgeFixture})
+ rec := getJSON(router, ppid(id, "/knowledge/domains/customers/nope"))
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestKnowledgeQueryFiltersRecords(t *testing.T) {
+ id, router := tempWorkspace(t, map[string]string{"data.cue": knowledgeFixture})
+ body, err := json.Marshal(knowledge.Query{
+ Domain: "customers",
+ Select: []string{"name"},
+ Where: []knowledge.Predicate{{Field: "country", Operator: "eq", Value: "FR"}},
+ })
+ if err != nil {
+ t.Fatalf("marshal query: %v", err)
+ }
+ rec := postJSON(router, ppid(id, "/knowledge/query"), body)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
+ }
+ var result knowledge.QueryResult
+ if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
+ t.Fatalf("decode: %v (body %q)", err, rec.Body.String())
+ }
+ if result.Count != 1 {
+ t.Fatalf("result = %+v, want one match", result)
+ }
+}
+
+func TestKnowledgeQueryUnknownField(t *testing.T) {
+ id, router := tempWorkspace(t, map[string]string{"data.cue": knowledgeFixture})
+ body, err := json.Marshal(knowledge.Query{
+ Domain: "customers",
+ Where: []knowledge.Predicate{{Field: "nope", Operator: "eq", Value: "x"}},
+ })
+ if err != nil {
+ t.Fatalf("marshal query: %v", err)
+ }
+ rec := postJSON(router, ppid(id, "/knowledge/query"), body)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestKnowledgeEvalSuccess(t *testing.T) {
+ id, router := tempWorkspace(t, map[string]string{"data.cue": knowledgeFixture})
+ rec := postJSON(router, ppid(id, "/knowledge/eval/discount"), []byte(`{"input":{"seats":20}}`))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
+ }
+ var result knowledge.EvalResult
+ if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
+ t.Fatalf("decode: %v (body %q)", err, rec.Body.String())
+ }
+ if result.Status != "success" || string(result.Result) != `{"eligible":true}` {
+ t.Fatalf("result = %+v", result)
+ }
+}
+
+func TestKnowledgeEvalUnknownName(t *testing.T) {
+ id, router := tempWorkspace(t, map[string]string{"data.cue": knowledgeFixture})
+ rec := postJSON(router, ppid(id, "/knowledge/eval/nope"), []byte(`{"input":{}}`))
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestKnowledgeProvenanceListsEntries(t *testing.T) {
+ id, router := tempWorkspace(t, map[string]string{"data.cue": knowledgeFixture})
+ rec := getJSON(router, ppid(id, "/knowledge/provenance"))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
+ }
+ var body struct {
+ Provenance struct {
+ Entries []struct {
+ Name string `json:"Name"`
+ } `json:"Entries"`
+ } `json:"Provenance"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("decode: %v (body %q)", err, rec.Body.String())
+ }
+ if len(body.Provenance.Entries) == 0 {
+ t.Fatalf("body = %+v, want at least one entry", body)
+ }
+}
+
+func TestKnowledgeHealthValid(t *testing.T) {
+ id, router := tempWorkspace(t, map[string]string{"data.cue": knowledgeFixture})
+ rec := getJSON(router, ppid(id, "/knowledge/health"))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
+ }
+ var health knowledge.Health
+ if err := json.Unmarshal(rec.Body.Bytes(), &health); err != nil {
+ t.Fatalf("decode: %v (body %q)", err, rec.Body.String())
+ }
+ if !health.Valid {
+ t.Fatalf("health = %+v, want valid", health)
+ }
+}
+
+func TestKnowledgeCatalogUnknownProject(t *testing.T) {
+ router := realRouter(t, testConfig(t))
+ rec := getJSON(router, ppid("does-not-exist", "/knowledge/catalog"))
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404, body = %s", rec.Code, rec.Body.String())
+ }
+}
diff --git a/backend/internal/handlers/router.go b/backend/internal/handlers/router.go
index 205f2d2..fc62fbf 100644
--- a/backend/internal/handlers/router.go
+++ b/backend/internal/handlers/router.go
@@ -16,6 +16,8 @@ import (
"github.com/stratorys/cueto/backend/internal/config"
"github.com/stratorys/cueto/backend/internal/diag"
+ "github.com/stratorys/cueto/backend/internal/evaluation"
+ "github.com/stratorys/cueto/backend/internal/knowledge"
"github.com/stratorys/cueto/backend/internal/projects"
)
@@ -25,7 +27,7 @@ import (
// every operation that touches a module is scoped to /projects/:id, so the module
// root always comes from a resolved project. Git is the only history: saves write
// the real file and the history panel reads commits read-only.
-func NewRouter(eval evalService, auth authoringService, cfg config.Config) *gin.Engine {
+func NewRouter(eval evalService, auth authoringService, cfg config.Config, sel SelectionStore) *gin.Engine {
r := gin.New()
// Trust no proxies: this backend is reached directly, so client-supplied
// X-Forwarded-For headers must not be believed.
@@ -36,9 +38,14 @@ func NewRouter(eval evalService, auth authoringService, cfg config.Config) *gin.
eval: eval,
authoring: auth,
projects: projects.New(cfg.ProjectsDir),
+ projectsDir: cfg.ProjectsDir,
+ selection: sel,
cueDir: cfg.CueDir,
maxOutputBytes: cfg.MaxOutputBytes,
}
+ if engine, ok := eval.(*evaluation.Engine); ok {
+ h.runtime = knowledge.NewRuntime(knowledge.New(engine))
+ }
// Module-independent operations.
r.GET("/config", h.Config)
@@ -47,6 +54,8 @@ func NewRouter(eval evalService, auth authoringService, cfg config.Config) *gin.
r.POST("/rewrite", h.Rewrite)
r.GET("/projects", h.ListProjects)
r.POST("/projects", h.CreateProject)
+ r.GET("/session", h.Session)
+ r.POST("/session/project", h.SetSessionProject)
// Project-scoped operations: evaluation and git-backed persistence, all rooted
// at the resolved project module.
@@ -54,6 +63,13 @@ func NewRouter(eval evalService, auth authoringService, cfg config.Config) *gin.
r.POST("/projects/:id/repl", h.EvalExpr)
r.POST("/projects/:id/repl/keys", h.ReplKeys)
r.POST("/projects/:id/vet", h.Vet)
+ r.GET("/projects/:id/knowledge/catalog", h.KnowledgeCatalog)
+ r.GET("/projects/:id/knowledge/domains/:domain", h.KnowledgeDescribe)
+ r.GET("/projects/:id/knowledge/domains/:domain/:key", h.KnowledgeGet)
+ r.POST("/projects/:id/knowledge/query", h.KnowledgeQuery)
+ r.POST("/projects/:id/knowledge/eval/:name", h.KnowledgeEval)
+ r.GET("/projects/:id/knowledge/provenance", h.KnowledgeProvenance)
+ r.GET("/projects/:id/knowledge/health", h.KnowledgeHealth)
r.GET("/projects/:id/tree", h.Tree)
r.POST("/projects/:id/save", h.WorkspaceSave)
r.GET("/projects/:id/file", h.WorkspaceFile)
diff --git a/backend/internal/handlers/session.go b/backend/internal/handlers/session.go
new file mode 100644
index 0000000..8ed4063
--- /dev/null
+++ b/backend/internal/handlers/session.go
@@ -0,0 +1,80 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+package handlers
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/stratorys/cueto/backend/internal/diag"
+)
+
+// SelectionStore persists which project is current for a projects root. The home
+// package implements it; a nil store disables persistence but keeps the
+// only-project resolution, so the endpoints degrade rather than disappear.
+type SelectionStore interface {
+ Selection(projectsDir string) string
+ SetSelection(projectsDir, id string) error
+}
+
+// setSessionRequest is the body for switching the current project.
+type setSessionRequest struct {
+ ID string `json:"id"`
+}
+
+// Session resolves the current project without any environment variable naming
+// one: the persisted selection if it still resolves, else the only project (which
+// is then persisted), else none - the frontend shows onboarding on none. The
+// projects list rides along so the client bootstraps from a single request.
+func (h *handlers) Session(c *gin.Context) {
+ ps, err := h.projects.List()
+ if err != nil {
+ writeOpError(c, err)
+ return
+ }
+ current := ""
+ if h.selection != nil {
+ if id := h.selection.Selection(h.projectsDir); id != "" {
+ if _, ok := h.projects.Resolve(id); ok {
+ current = id
+ }
+ }
+ }
+ if current == "" && len(ps) == 1 {
+ current = ps[0].ID
+ if h.selection != nil {
+ // Best-effort: a failed persist only means the same resolution runs again
+ // next time, so it must not fail the read.
+ _ = h.selection.SetSelection(h.projectsDir, current)
+ }
+ }
+ c.JSON(http.StatusOK, gin.H{"currentProject": current, "projects": ps})
+}
+
+// SetSessionProject persists the current project. An id that does not resolve to
+// a project under the root is 404, so the state can never name a project that is
+// not really there.
+func (h *handlers) SetSessionProject(c *gin.Context) {
+ var req setSessionRequest
+ if !bindJSON(c, &req) {
+ return
+ }
+ if _, ok := h.projects.Resolve(req.ID); !ok {
+ c.JSON(http.StatusNotFound, gin.H{
+ "diagnostics": []diag.Diagnostic{{Message: "unknown project", Kind: diag.KindInternal}},
+ })
+ return
+ }
+ if h.selection != nil {
+ if err := h.selection.SetSelection(h.projectsDir, req.ID); err != nil {
+ writeOpError(c, err)
+ return
+ }
+ }
+ c.Status(http.StatusNoContent)
+}
diff --git a/backend/internal/handlers/session_test.go b/backend/internal/handlers/session_test.go
new file mode 100644
index 0000000..9c1afaa
--- /dev/null
+++ b/backend/internal/handlers/session_test.go
@@ -0,0 +1,169 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+package handlers
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/stratorys/cueto/backend/internal/authoring"
+ "github.com/stratorys/cueto/backend/internal/evaluation"
+ "github.com/stratorys/cueto/backend/internal/home"
+)
+
+// scaffoldModule makes dir/id a minimal CUE module so the projects manager lists
+// it, without needing git (session resolution never reads git).
+func scaffoldModule(t *testing.T, root, id string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Join(root, id, "cue.mod"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ module := "module: \"example.com/" + id + "\"\nlanguage: version: \"v0.17.0\"\n"
+ if err := os.WriteFile(filepath.Join(root, id, "cue.mod", "module.cue"), []byte(module), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// sessionRouter builds a router over a temp projects root with a real home-backed
+// selection store, returning both so tests can inspect persisted state.
+func sessionRouter(t *testing.T, projectsRoot string) (*gin.Engine, *home.Home) {
+ t.Helper()
+ cfg := testConfig(t)
+ cfg.ProjectsDir = projectsRoot
+ h := home.New(filepath.Join(t.TempDir(), "cueto-home"))
+ return NewRouter(evaluation.New(cfg.CueDir, cfg.EvalTimeout, cfg.MaxOutputBytes), authoring.New(), cfg, h), h
+}
+
+type sessionResponse struct {
+ CurrentProject string `json:"currentProject"`
+ Projects []struct {
+ ID string `json:"id"`
+ } `json:"projects"`
+}
+
+func getSession(t *testing.T, router *gin.Engine) sessionResponse {
+ t.Helper()
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/session", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET /session = %d, body %s", rec.Code, rec.Body.String())
+ }
+ var resp sessionResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("decode session: %v", err)
+ }
+ return resp
+}
+
+func TestSessionNoProjects(t *testing.T) {
+ router, _ := sessionRouter(t, t.TempDir())
+ resp := getSession(t, router)
+ if resp.CurrentProject != "" || len(resp.Projects) != 0 {
+ t.Fatalf("session = %+v, want empty", resp)
+ }
+}
+
+func TestSessionOnlyProjectIsDefaultAndPersisted(t *testing.T) {
+ root := t.TempDir()
+ scaffoldModule(t, root, "acme")
+ router, h := sessionRouter(t, root)
+ resp := getSession(t, router)
+ if resp.CurrentProject != "acme" {
+ t.Fatalf("currentProject = %q, want acme", resp.CurrentProject)
+ }
+ if got := h.Selection(root); got != "acme" {
+ t.Fatalf("persisted selection = %q, want acme", got)
+ }
+}
+
+func TestSessionMultipleProjectsNoneSelected(t *testing.T) {
+ root := t.TempDir()
+ scaffoldModule(t, root, "acme")
+ scaffoldModule(t, root, "beta")
+ router, _ := sessionRouter(t, root)
+ resp := getSession(t, router)
+ if resp.CurrentProject != "" {
+ t.Fatalf("currentProject = %q, want empty (onboarding)", resp.CurrentProject)
+ }
+ if len(resp.Projects) != 2 {
+ t.Fatalf("projects = %+v, want 2", resp.Projects)
+ }
+}
+
+func TestSessionUsesPersistedSelection(t *testing.T) {
+ root := t.TempDir()
+ scaffoldModule(t, root, "acme")
+ scaffoldModule(t, root, "beta")
+ router, h := sessionRouter(t, root)
+ if err := h.SetSelection(root, "beta"); err != nil {
+ t.Fatal(err)
+ }
+ if resp := getSession(t, router); resp.CurrentProject != "beta" {
+ t.Fatalf("currentProject = %q, want beta", resp.CurrentProject)
+ }
+}
+
+func TestSessionStaleSelectionFallsBack(t *testing.T) {
+ root := t.TempDir()
+ scaffoldModule(t, root, "acme")
+ scaffoldModule(t, root, "beta")
+ router, h := sessionRouter(t, root)
+ if err := h.SetSelection(root, "gone"); err != nil {
+ t.Fatal(err)
+ }
+ if resp := getSession(t, router); resp.CurrentProject != "" {
+ t.Fatalf("currentProject = %q, want empty for stale selection", resp.CurrentProject)
+ }
+}
+
+func TestSetSessionProject(t *testing.T) {
+ root := t.TempDir()
+ scaffoldModule(t, root, "acme")
+ scaffoldModule(t, root, "beta")
+ router, h := sessionRouter(t, root)
+
+ body := []byte(`{"id":"beta"}`)
+ req := httptest.NewRequest(http.MethodPost, "/session/project", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNoContent {
+ t.Fatalf("POST /session/project = %d, body %s", rec.Code, rec.Body.String())
+ }
+ if got := h.Selection(root); got != "beta" {
+ t.Fatalf("persisted selection = %q, want beta", got)
+ }
+ if resp := getSession(t, router); resp.CurrentProject != "beta" {
+ t.Fatalf("currentProject after switch = %q, want beta", resp.CurrentProject)
+ }
+}
+
+func TestSetSessionProjectUnknownIs404(t *testing.T) {
+ root := t.TempDir()
+ scaffoldModule(t, root, "acme")
+ router, h := sessionRouter(t, root)
+ for _, id := range []string{"ghost", "../escape", ""} {
+ body, _ := json.Marshal(map[string]string{"id": id})
+ req := httptest.NewRequest(http.MethodPost, "/session/project", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("POST id %q = %d, want 404", id, rec.Code)
+ }
+ }
+ if got := h.Selection(root); got != "" {
+ t.Fatalf("selection = %q, want empty after rejected writes", got)
+ }
+}
diff --git a/backend/internal/handlers/shared.go b/backend/internal/handlers/shared.go
index aa076fb..3951e9f 100644
--- a/backend/internal/handlers/shared.go
+++ b/backend/internal/handlers/shared.go
@@ -18,6 +18,7 @@ import (
"github.com/stratorys/cueto/backend/internal/diag"
"github.com/stratorys/cueto/backend/internal/domain"
"github.com/stratorys/cueto/backend/internal/evaluation"
+ "github.com/stratorys/cueto/backend/internal/knowledge"
"github.com/stratorys/cueto/backend/internal/projects"
"github.com/stratorys/cueto/backend/internal/repo"
)
@@ -48,8 +49,11 @@ type handlers struct {
eval evalService
authoring authoringService
projects *projects.Manager
+ projectsDir string
+ selection SelectionStore
cueDir string
maxOutputBytes int
+ runtime knowledge.Runtime
}
// projectDir resolves the :id path param to an absolute module dir, writing a 404
diff --git a/backend/internal/home/home.go b/backend/internal/home/home.go
new file mode 100644
index 0000000..9044fe5
--- /dev/null
+++ b/backend/internal/home/home.go
@@ -0,0 +1,189 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+// Package home owns cueto's standard on-disk location: the single root that
+// holds the hand-edited config.cue, the machine-written state.json, and the
+// default projects directory. The root resolves to $XDG_DATA_HOME/cueto when
+// XDG_DATA_HOME is set, else ~/.cueto, so both the server and the CLI agree on
+// where things live without any environment variable naming a project.
+package home
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "cuelang.org/go/cue"
+ "cuelang.org/go/cue/cuecontext"
+)
+
+const (
+ configFile = "config.cue"
+ stateFile = "state.json"
+ projectsName = "projects"
+)
+
+// configSchema validates config.cue. Every field is optional; defaults live in
+// the caller (the serve command), so an absent or empty file is a valid config.
+const configSchema = `
+#Config: {
+ port?: int & >0 & <65536
+ maxBodyBytes?: int & >0
+ maxOutputBytes?: int & >0
+ evalTimeoutMs?: int & >0
+ maxConcurrent?: int & >0
+}
+`
+
+// Home is a resolved cueto root directory.
+type Home struct {
+ root string
+}
+
+// DefaultRoot resolves the standard cueto root: $XDG_DATA_HOME/cueto when
+// XDG_DATA_HOME is set (the XDG base-directory convention), else ~/.cueto.
+func DefaultRoot() (string, error) {
+ if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
+ return filepath.Join(xdg, "cueto"), nil
+ }
+ dir, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("resolve home directory: %w", err)
+ }
+ return filepath.Join(dir, ".cueto"), nil
+}
+
+// New returns a Home rooted at dir. The directory need not exist yet; Ensure
+// creates it.
+func New(root string) *Home {
+ return &Home{root: root}
+}
+
+// Root returns the home root directory.
+func (h *Home) Root() string { return h.root }
+
+// ProjectsDir returns the default projects root under the home.
+func (h *Home) ProjectsDir() string { return filepath.Join(h.root, projectsName) }
+
+// Ensure creates the root and projects directories when missing.
+func (h *Home) Ensure() error {
+ return os.MkdirAll(h.ProjectsDir(), 0o755)
+}
+
+// Config is the hand-edited server configuration from config.cue. Zero values
+// mean "not set"; the caller applies its defaults on top.
+type Config struct {
+ Port int `json:"port"`
+ MaxBodyBytes int64 `json:"maxBodyBytes"`
+ MaxOutputBytes int `json:"maxOutputBytes"`
+ EvalTimeoutMs int `json:"evalTimeoutMs"`
+ MaxConcurrent int `json:"maxConcurrent"`
+}
+
+// LoadConfig reads and validates config.cue. A missing file is an empty valid
+// config; a file that fails the schema is an error naming the violation, so a
+// typo surfaces at startup rather than as a silently ignored setting.
+func (h *Home) LoadConfig() (Config, error) {
+ content, err := os.ReadFile(filepath.Join(h.root, configFile))
+ if errors.Is(err, os.ErrNotExist) {
+ return Config{}, nil
+ }
+ if err != nil {
+ return Config{}, err
+ }
+ ctx := cuecontext.New()
+ schema := ctx.CompileString(configSchema).LookupPath(cue.ParsePath("#Config"))
+ value := schema.Unify(ctx.CompileString(string(content), cue.Filename(configFile)))
+ if err := value.Validate(); err != nil {
+ return Config{}, fmt.Errorf("%s: %w", configFile, err)
+ }
+ var cfg Config
+ if err := value.Decode(&cfg); err != nil {
+ return Config{}, fmt.Errorf("%s: %w", configFile, err)
+ }
+ return cfg, nil
+}
+
+// State is the machine-written selection state. Selections maps an absolute
+// projects root to the id of its current project, so a dev server pointed at a
+// different root never clobbers the standard root's selection.
+type State struct {
+ Selections map[string]string `json:"selections"`
+}
+
+// ReadState returns the persisted state; a missing or empty file is an empty
+// state, and a corrupt file is an error rather than a silent reset.
+func (h *Home) ReadState() (State, error) {
+ content, err := os.ReadFile(filepath.Join(h.root, stateFile))
+ if errors.Is(err, os.ErrNotExist) {
+ return State{Selections: map[string]string{}}, nil
+ }
+ if err != nil {
+ return State{}, err
+ }
+ var state State
+ if err := json.Unmarshal(content, &state); err != nil {
+ return State{}, fmt.Errorf("%s: %w", stateFile, err)
+ }
+ if state.Selections == nil {
+ state.Selections = map[string]string{}
+ }
+ return state, nil
+}
+
+// Selection returns the persisted current project id for a projects root, or ""
+// when none (or when the state file is unreadable; selection is best-effort).
+func (h *Home) Selection(projectsDir string) string {
+ state, err := h.ReadState()
+ if err != nil {
+ return ""
+ }
+ return state.Selections[absKey(projectsDir)]
+}
+
+// SetSelection persists the current project id for a projects root, creating the
+// home root when missing. It rewrites the whole file atomically (temp + rename)
+// so a crash never leaves a torn state file.
+func (h *Home) SetSelection(projectsDir, id string) error {
+ state, err := h.ReadState()
+ if err != nil {
+ return err
+ }
+ state.Selections[absKey(projectsDir)] = id
+ if err := os.MkdirAll(h.root, 0o755); err != nil {
+ return err
+ }
+ content, err := json.MarshalIndent(state, "", " ")
+ if err != nil {
+ return err
+ }
+ tmp, err := os.CreateTemp(h.root, stateFile+".*")
+ if err != nil {
+ return err
+ }
+ if _, err := tmp.Write(append(content, '\n')); err != nil {
+ tmp.Close()
+ os.Remove(tmp.Name())
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ os.Remove(tmp.Name())
+ return err
+ }
+ return os.Rename(tmp.Name(), filepath.Join(h.root, stateFile))
+}
+
+// absKey normalizes a projects root to an absolute path so the same directory
+// always maps to the same selection regardless of how the caller spelled it.
+func absKey(dir string) string {
+ abs, err := filepath.Abs(dir)
+ if err != nil {
+ return dir
+ }
+ return abs
+}
diff --git a/backend/internal/home/home_test.go b/backend/internal/home/home_test.go
new file mode 100644
index 0000000..c74ff1f
--- /dev/null
+++ b/backend/internal/home/home_test.go
@@ -0,0 +1,125 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+package home
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestDefaultRootPrefersXDG(t *testing.T) {
+ t.Setenv("XDG_DATA_HOME", "/tmp/xdg-data")
+ root, err := DefaultRoot()
+ if err != nil {
+ t.Fatalf("DefaultRoot: %v", err)
+ }
+ if root != filepath.Join("/tmp/xdg-data", "cueto") {
+ t.Fatalf("root = %q, want $XDG_DATA_HOME/cueto", root)
+ }
+}
+
+func TestDefaultRootFallsBackToDotCueto(t *testing.T) {
+ t.Setenv("XDG_DATA_HOME", "")
+ root, err := DefaultRoot()
+ if err != nil {
+ t.Fatalf("DefaultRoot: %v", err)
+ }
+ if filepath.Base(root) != ".cueto" {
+ t.Fatalf("root = %q, want ~/.cueto", root)
+ }
+}
+
+func TestEnsureCreatesProjectsDir(t *testing.T) {
+ h := New(filepath.Join(t.TempDir(), "cueto"))
+ if err := h.Ensure(); err != nil {
+ t.Fatalf("Ensure: %v", err)
+ }
+ info, err := os.Stat(h.ProjectsDir())
+ if err != nil || !info.IsDir() {
+ t.Fatalf("projects dir missing after Ensure: %v", err)
+ }
+}
+
+func TestLoadConfigMissingFileIsEmpty(t *testing.T) {
+ h := New(t.TempDir())
+ cfg, err := h.LoadConfig()
+ if err != nil {
+ t.Fatalf("LoadConfig: %v", err)
+ }
+ if cfg != (Config{}) {
+ t.Fatalf("cfg = %+v, want zero", cfg)
+ }
+}
+
+func TestLoadConfigReadsFields(t *testing.T) {
+ h := New(t.TempDir())
+ content := "port: 9000\nmaxConcurrent: 8\n"
+ if err := os.WriteFile(filepath.Join(h.Root(), "config.cue"), []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := h.LoadConfig()
+ if err != nil {
+ t.Fatalf("LoadConfig: %v", err)
+ }
+ if cfg.Port != 9000 || cfg.MaxConcurrent != 8 {
+ t.Fatalf("cfg = %+v, want port 9000, maxConcurrent 8", cfg)
+ }
+ if cfg.MaxBodyBytes != 0 {
+ t.Fatalf("unset field = %d, want zero", cfg.MaxBodyBytes)
+ }
+}
+
+func TestLoadConfigRejectsInvalid(t *testing.T) {
+ h := New(t.TempDir())
+ for name, content := range map[string]string{
+ "bad type": "port: \"nope\"\n",
+ "out of range": "port: 0\n",
+ "unknown key": "prot: 9000\n",
+ } {
+ if err := os.WriteFile(filepath.Join(h.Root(), "config.cue"), []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := h.LoadConfig(); err == nil {
+ t.Fatalf("%s: LoadConfig accepted %q", name, content)
+ } else if !strings.Contains(err.Error(), "config.cue") {
+ t.Fatalf("%s: error %q does not name config.cue", name, err)
+ }
+ }
+}
+
+func TestSelectionRoundTrip(t *testing.T) {
+ h := New(filepath.Join(t.TempDir(), "cueto"))
+ projects := t.TempDir()
+ if got := h.Selection(projects); got != "" {
+ t.Fatalf("Selection before write = %q, want empty", got)
+ }
+ if err := h.SetSelection(projects, "acme"); err != nil {
+ t.Fatalf("SetSelection: %v", err)
+ }
+ if got := h.Selection(projects); got != "acme" {
+ t.Fatalf("Selection = %q, want acme", got)
+ }
+ other := t.TempDir()
+ if err := h.SetSelection(other, "demo"); err != nil {
+ t.Fatalf("SetSelection other root: %v", err)
+ }
+ if got := h.Selection(projects); got != "acme" {
+ t.Fatalf("Selection after other root write = %q, want acme", got)
+ }
+}
+
+func TestReadStateCorruptFileErrors(t *testing.T) {
+ h := New(t.TempDir())
+ if err := os.WriteFile(filepath.Join(h.Root(), "state.json"), []byte("{"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := h.ReadState(); err == nil {
+ t.Fatal("ReadState accepted corrupt json")
+ }
+}
diff --git a/backend/internal/knowledge/catalog.go b/backend/internal/knowledge/catalog.go
new file mode 100644
index 0000000..42ae123
--- /dev/null
+++ b/backend/internal/knowledge/catalog.go
@@ -0,0 +1,334 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+package knowledge
+
+import (
+ "encoding/json"
+ "sort"
+
+ "cuelang.org/go/cue"
+
+ "github.com/stratorys/cueto/backend/internal/evaluation"
+)
+
+// Catalog is the first generic projection: top-level declarations exposed by a
+// compiled package. It deliberately contains no diagram concepts.
+type Catalog struct {
+ Entries []CatalogEntry `json:"entries"`
+ Metadata *Metadata `json:"metadata,omitempty"`
+ Domains []Domain `json:"domains"`
+ Evaluations []NamedEvaluation `json:"evaluations"`
+ Observations []Observation `json:"observations"`
+ Checks []Check `json:"checks"`
+}
+
+type CatalogEntry struct {
+ Name string `json:"name"`
+ Kind string `json:"kind"`
+}
+
+// Metadata is optional module-level context supplied through knowledge.metadata.
+type Metadata struct {
+ Title string `json:"title"`
+ Description string `json:"description,omitempty"`
+ Revision string `json:"revision,omitempty"`
+}
+
+// Domain is either explicitly declared under knowledge.domains or inferred from
+// Cueto's existing open-label registry shape. Collection retains the typed CUE
+// value for downstream projections without forcing a lossy JSON representation.
+type Domain struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+ Kind string `json:"kind"`
+ Description string `json:"description,omitempty"`
+ Key string `json:"key"`
+ KeyType string `json:"keyType"`
+ Explicit bool `json:"explicit"`
+ Fields map[string]Field `json:"fields"`
+ Collection cue.Value `json:"-"`
+}
+
+type Field struct {
+ Type string `json:"type"`
+ Required bool `json:"required"`
+ Relation *Relation `json:"relation,omitempty"`
+}
+
+type Relation struct {
+ Domain string `json:"domain"`
+ Cardinality string `json:"cardinality"`
+ Rule string `json:"rule,omitempty"`
+}
+
+// NamedEvaluation describes an optional, declared agent-facing operation.
+// Input and Output stay typed CUE values for an MCP/HTTP adapter to marshal.
+type NamedEvaluation struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+ Description string `json:"description"`
+ InputSchema Schema `json:"inputSchema"`
+ OutputSchema Schema `json:"outputSchema"`
+ Input cue.Value `json:"-"`
+ Output cue.Value `json:"-"`
+}
+
+type Schema struct {
+ Type string `json:"type"`
+ Fields map[string]Field `json:"fields,omitempty"`
+}
+
+// Observation is author-declared semantic provenance for a domain fact.
+type Observation struct {
+ Name string `json:"name"`
+ Entity string `json:"entity"`
+ Field string `json:"field"`
+ Value json.RawMessage `json:"value"`
+ Source SourceRef `json:"source"`
+ Status string `json:"status"`
+ Authority *int `json:"authority,omitempty"`
+}
+
+type SourceRef struct {
+ Kind string `json:"kind"`
+ URI string `json:"uri"`
+ Pointer string `json:"pointer,omitempty"`
+ RetrievedAt string `json:"retrievedAt,omitempty"`
+}
+
+type Check struct {
+ Name string `json:"name"`
+ Value bool `json:"value"`
+}
+
+// BuildCatalog is the schema-catalog compiler pass. It overlays optional
+// explicit metadata on the same registry/relation discovery used by diagrams.
+func BuildCatalog(root cue.Value) (Catalog, error) {
+ projected, err := (KnowledgeCatalogProjection{}).Discover(root)
+ if err != nil {
+ return Catalog{}, err
+ }
+ catalog := projected.(Catalog)
+ DiscoverExplicitKnowledge(root, &catalog)
+ discoverEvaluations(root.LookupPath(cue.ParsePath("evaluations")), "evaluations", &catalog.Evaluations)
+ discoverObservations(root.LookupPath(cue.ParsePath("observations")), &catalog.Observations)
+ explicit := map[string]int{}
+ for i, domain := range catalog.Domains {
+ if domain.Explicit {
+ explicit[domain.Name] = i
+ }
+ }
+ for _, registry := range evaluation.DescribeRegistries(root) {
+ domain := domainFromRegistry(registry)
+ if i, ok := explicit[registry.Name]; ok {
+ domain.Description = catalog.Domains[i].Description
+ domain.Key = catalog.Domains[i].Key
+ domain.Explicit = true
+ domain.Collection = catalog.Domains[i].Collection
+ catalog.Domains[i] = domain
+ continue
+ }
+ catalog.Domains = append(catalog.Domains, domain)
+ }
+ sort.Slice(catalog.Domains, func(i, j int) bool { return catalog.Domains[i].Name < catalog.Domains[j].Name })
+ return catalog, nil
+}
+
+func domainFromRegistry(registry evaluation.RegistrySchemaInfo) Domain {
+ fields := make(map[string]Field, len(registry.Fields))
+ for _, field := range registry.Fields {
+ var relation *Relation
+ if field.Relation != nil {
+ relation = &Relation{Domain: field.Relation.Domain, Cardinality: field.Relation.Cardinality, Rule: field.Relation.Rule}
+ }
+ fields[field.Name] = Field{Type: field.Type, Required: field.Required, Relation: relation}
+ }
+ return Domain{Name: registry.Name, Path: registry.Name, Kind: "registry", Key: "id", KeyType: "string", Fields: fields}
+}
+
+// KnowledgeCatalogProjection discovers a stable top-level catalog from any CUE
+// value. Rich domain and relation discovery will extend this projection rather
+// than changing the compiler contract.
+type KnowledgeCatalogProjection struct{}
+
+func (KnowledgeCatalogProjection) Name() string { return "knowledge-catalog" }
+
+func (KnowledgeCatalogProjection) Discover(value cue.Value) (any, error) {
+ catalog := Catalog{Entries: []CatalogEntry{}, Domains: []Domain{}, Evaluations: []NamedEvaluation{}, Observations: []Observation{}, Checks: []Check{}}
+ it, err := value.Fields(cue.Optional(true), cue.Definitions(true))
+ if err != nil {
+ return catalog, nil
+ }
+ for it.Next() {
+ catalog.Entries = append(catalog.Entries, CatalogEntry{
+ Name: it.Selector().String(),
+ Kind: it.Value().IncompleteKind().String(),
+ })
+ }
+ sort.Slice(catalog.Entries, func(i, j int) bool { return catalog.Entries[i].Name < catalog.Entries[j].Name })
+ return catalog, nil
+}
+
+// DiscoverExplicitKnowledge reads the optional dedicated metadata field. No
+// import is required to discover it: modules that unify it with cueto/knowledge
+// receive schema validation, while plain CUE modules remain discoverable.
+func DiscoverExplicitKnowledge(value cue.Value, catalog *Catalog) {
+ knowledge := value.LookupPath(cue.ParsePath("knowledge"))
+ if !knowledge.Exists() {
+ return
+ }
+ if metadata := knowledge.LookupPath(cue.ParsePath("metadata")); metadata.Exists() {
+ catalog.Metadata = &Metadata{
+ Title: concreteString(metadata.LookupPath(cue.ParsePath("title"))),
+ Description: concreteString(metadata.LookupPath(cue.ParsePath("description"))),
+ Revision: concreteString(metadata.LookupPath(cue.ParsePath("revision"))),
+ }
+ }
+ if domains := knowledge.LookupPath(cue.ParsePath("domains")); domains.Exists() {
+ it, err := domains.Fields()
+ if err == nil {
+ for it.Next() {
+ entry := it.Value()
+ key := concreteString(entry.LookupPath(cue.ParsePath("key")))
+ if key == "" {
+ key = "id"
+ }
+ catalog.Domains = append(catalog.Domains, Domain{
+ Name: it.Selector().Unquoted(),
+ Path: it.Selector().Unquoted(),
+ Kind: "declared",
+ Description: concreteString(entry.LookupPath(cue.ParsePath("description"))),
+ Key: key,
+ KeyType: "string",
+ Fields: map[string]Field{},
+ Explicit: true,
+ Collection: entry.LookupPath(cue.ParsePath("collection")),
+ })
+ }
+ }
+ }
+ discoverEvaluations(knowledge.LookupPath(cue.ParsePath("evaluations")), "knowledge.evaluations", &catalog.Evaluations)
+ discoverObservations(knowledge.LookupPath(cue.ParsePath("observations")), &catalog.Observations)
+ if checks := knowledge.LookupPath(cue.ParsePath("checks")); checks.Exists() {
+ it, err := checks.Fields()
+ if err == nil {
+ for it.Next() {
+ v, err := it.Value().Bool()
+ if err == nil {
+ catalog.Checks = append(catalog.Checks, Check{Name: it.Selector().Unquoted(), Value: v})
+ }
+ }
+ }
+ }
+ sort.Slice(catalog.Domains, func(i, j int) bool { return catalog.Domains[i].Name < catalog.Domains[j].Name })
+ sort.Slice(catalog.Evaluations, func(i, j int) bool { return catalog.Evaluations[i].Name < catalog.Evaluations[j].Name })
+ sort.Slice(catalog.Observations, func(i, j int) bool { return catalog.Observations[i].Name < catalog.Observations[j].Name })
+ sort.Slice(catalog.Checks, func(i, j int) bool { return catalog.Checks[i].Name < catalog.Checks[j].Name })
+}
+
+func discoverEvaluations(evaluations cue.Value, path string, target *[]NamedEvaluation) {
+ if !evaluations.Exists() {
+ return
+ }
+ it, err := evaluations.Fields()
+ if err != nil {
+ return
+ }
+ for it.Next() {
+ entry := it.Value()
+ result := entry.LookupPath(cue.ParsePath("result"))
+ // output is read only as a compatibility bridge for phase-two modules.
+ if !result.Exists() {
+ result = entry.LookupPath(cue.ParsePath("output"))
+ }
+ *target = append(*target, NamedEvaluation{
+ Name: it.Selector().Unquoted(), Path: path + "." + it.Selector().Unquoted(),
+ Description: concreteString(entry.LookupPath(cue.ParsePath("description"))),
+ InputSchema: schemaFor(entry.LookupPath(cue.ParsePath("input"))), OutputSchema: schemaFor(result),
+ Input: entry.LookupPath(cue.ParsePath("input")), Output: result,
+ })
+ }
+}
+
+func discoverObservations(observations cue.Value, target *[]Observation) {
+ if !observations.Exists() {
+ return
+ }
+ it, err := observations.Fields()
+ if err != nil {
+ return
+ }
+ for it.Next() {
+ entry := it.Value()
+ value, err := entry.LookupPath(cue.ParsePath("value")).MarshalJSON()
+ if err != nil {
+ value = json.RawMessage("null")
+ }
+ source := entry.LookupPath(cue.ParsePath("source"))
+ observation := Observation{
+ Name: it.Selector().Unquoted(), Entity: concreteString(entry.LookupPath(cue.ParsePath("entity"))), Field: concreteString(entry.LookupPath(cue.ParsePath("field"))), Value: value,
+ Status: concreteString(entry.LookupPath(cue.ParsePath("status"))),
+ Source: SourceRef{Kind: concreteString(source.LookupPath(cue.ParsePath("kind"))), URI: concreteString(source.LookupPath(cue.ParsePath("uri"))), Pointer: concreteString(source.LookupPath(cue.ParsePath("pointer"))), RetrievedAt: concreteString(source.LookupPath(cue.ParsePath("retrievedAt")))},
+ }
+ if authority, err := entry.LookupPath(cue.ParsePath("authority")).Int64(); err == nil {
+ n := int(authority)
+ observation.Authority = &n
+ }
+ *target = append(*target, observation)
+ }
+}
+
+func concreteString(value cue.Value) string {
+ result, err := value.String()
+ if err != nil {
+ return ""
+ }
+ return result
+}
+
+func schemaFor(value cue.Value) Schema {
+ schema := Schema{Type: typeFor(value)}
+ if value.IncompleteKind()&cue.StructKind == 0 {
+ return schema
+ }
+ schema.Fields = map[string]Field{}
+ it, err := value.Fields(cue.Optional(true))
+ if err != nil {
+ return schema
+ }
+ for it.Next() {
+ if it.Selector().IsString() {
+ schema.Fields[it.Selector().Unquoted()] = Field{Type: typeFor(it.Value()), Required: !it.IsOptional()}
+ }
+ }
+ return schema
+}
+
+func typeFor(value cue.Value) string {
+ kind := value.IncompleteKind()
+ if kind&cue.ListKind != 0 {
+ element := value.LookupPath(cue.MakePath(cue.AnyIndex))
+ if element.Exists() {
+ return "list<" + typeFor(element) + ">"
+ }
+ return "list"
+ }
+ switch {
+ case kind&cue.StringKind != 0:
+ return "string"
+ case kind&cue.IntKind != 0:
+ return "int"
+ case kind&(cue.FloatKind|cue.NumberKind) != 0:
+ return "number"
+ case kind&cue.BoolKind != 0:
+ return "bool"
+ case kind&cue.StructKind != 0:
+ return "struct"
+ default:
+ return "value"
+ }
+}
diff --git a/backend/internal/knowledge/compiler.go b/backend/internal/knowledge/compiler.go
new file mode 100644
index 0000000..a415c89
--- /dev/null
+++ b/backend/internal/knowledge/compiler.go
@@ -0,0 +1,121 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+package knowledge
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "sort"
+
+ "cuelang.org/go/cue"
+ "cuelang.org/go/cue/format"
+
+ "github.com/stratorys/cueto/backend/internal/domain"
+ "github.com/stratorys/cueto/backend/internal/evaluation"
+)
+
+// CueCompiler adapts the existing bounded evaluator into the generic compiler
+// contract. It never reimplements CUE loading, overlay validation, diagnostics,
+// source scrubbing, deadlines, or panic recovery.
+type CueCompiler struct {
+ engine *evaluation.Engine
+}
+
+func New(engine *evaluation.Engine) *CueCompiler { return &CueCompiler{engine: engine} }
+
+var _ Compiler = (*CueCompiler)(nil)
+
+func (c *CueCompiler) Compile(ctx context.Context, request CompileRequest) (*CompiledKnowledge, error) {
+ src := sourceFrom(request)
+ value, diagnostics, err := c.engine.CompileValue(ctx, src)
+ if err != nil {
+ return nil, err
+ }
+
+ result := &CompiledKnowledge{
+ Revision: revisionFor(value, request),
+ Value: value,
+ Diagnostics: diagnostics,
+ Health: Health{Valid: len(diagnostics) == 0, Diagnostics: diagnostics},
+ }
+ if len(diagnostics) > 0 {
+ return result, nil
+ }
+
+ // Whole-module health deliberately remains the evaluator's Vet operation:
+ // this preserves its sibling-package coverage and diagnostic de-duplication.
+ healthDiagnostics, err := c.engine.Vet(ctx, src)
+ if err != nil {
+ return nil, err
+ }
+ result.Health = Health{Valid: len(healthDiagnostics) == 0, Diagnostics: healthDiagnostics}
+
+ catalog, err := BuildCatalog(value)
+ if err != nil {
+ return nil, err
+ }
+ result.Catalog = catalog
+ return result, nil
+}
+
+func (c *CueCompiler) encode(value cue.Value, request CompileRequest) (json.RawMessage, []Diagnostic, error) {
+ return c.engine.EncodeValue(value, sourceFrom(request))
+}
+
+func sourceFrom(request CompileRequest) evaluation.Source {
+ names := make([]string, 0, len(request.Overlay))
+ for name := range request.Overlay {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ overlay := make([]domain.File, 0, len(names))
+ for _, name := range names {
+ overlay = append(overlay, domain.File{Name: name, Content: string(request.Overlay[name])})
+ }
+ return evaluation.Source{Dir: request.ModuleDir, Package: request.Package, Overlay: overlay}
+}
+
+// revisionFor prefers the normalized compiled syntax, so a change in an on-disk
+// module changes the revision even when no overlay is present. The request hash
+// remains a safe fallback for malformed inputs that have no buildable value.
+func revisionFor(value cue.Value, request CompileRequest) string {
+ formatted, err := format.Node(value.Syntax(cue.Final()))
+ if err != nil {
+ return revision(request)
+ }
+ h := sha256.New()
+ h.Write([]byte(request.ModuleDir))
+ h.Write([]byte{0})
+ h.Write([]byte(request.Package))
+ h.Write([]byte{0})
+ h.Write(formatted)
+ return hex.EncodeToString(h.Sum(nil))
+}
+
+// revision is a deterministic fallback identity for a compile request that did
+// not produce a CUE value. A workspace or git adapter may later replace this
+// with a commit hash.
+func revision(request CompileRequest) string {
+ h := sha256.New()
+ h.Write([]byte(request.ModuleDir))
+ h.Write([]byte{0})
+ h.Write([]byte(request.Package))
+ names := make([]string, 0, len(request.Overlay))
+ for name := range request.Overlay {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ for _, name := range names {
+ h.Write([]byte{0})
+ h.Write([]byte(name))
+ h.Write([]byte{0})
+ h.Write(request.Overlay[name])
+ }
+ return hex.EncodeToString(h.Sum(nil))
+}
diff --git a/backend/internal/knowledge/compiler_test.go b/backend/internal/knowledge/compiler_test.go
new file mode 100644
index 0000000..58b1867
--- /dev/null
+++ b/backend/internal/knowledge/compiler_test.go
@@ -0,0 +1,107 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+package knowledge
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "cuelang.org/go/cue"
+
+ "github.com/stratorys/cueto/backend/internal/evaluation"
+)
+
+func testModule(t *testing.T, files map[string]string) string {
+ t.Helper()
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "cue.mod"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "cue.mod", "module.cue"), []byte("module: \"example.com/knowledge\"\nlanguage: version: \"v0.17.0\"\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ for name, content := range files {
+ path := filepath.Join(dir, name)
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return dir
+}
+
+func TestCompileBuildsGenericValueAndCatalog(t *testing.T) {
+ dir := testModule(t, map[string]string{
+ "data.cue": "package main\n\n#Person: {name: string}\npeople: {marty: #Person & {name: \"Marty\"}}\n",
+ })
+ compiler := New(evaluation.New("", time.Second, 1<<20))
+
+ got, err := compiler.Compile(context.Background(), CompileRequest{ModuleDir: dir})
+ if err != nil {
+ t.Fatalf("Compile: %v", err)
+ }
+ if !got.Health.Valid || len(got.Diagnostics) != 0 {
+ t.Fatalf("health=%+v diagnostics=%+v, want clean compilation", got.Health, got.Diagnostics)
+ }
+ if !got.Value.LookupPath(cue.ParsePath("people.marty.name")).Exists() {
+ t.Fatal("compiled value does not contain people.marty.name")
+ }
+ if len(got.Catalog.Entries) != 2 || got.Catalog.Entries[0].Name != "#Person" || got.Catalog.Entries[1].Name != "people" {
+ t.Fatalf("catalog=%+v, want #Person and people", got.Catalog.Entries)
+ }
+}
+
+func TestCompileOverlaysAndSelectsPackage(t *testing.T) {
+ dir := testModule(t, map[string]string{
+ "data.cue": "package main\n\nroot: true\n",
+ "sub/sub.cue": "package sub\n\nvalue: \"disk\"\n",
+ })
+ compiler := New(evaluation.New("", time.Second, 1<<20))
+ request := CompileRequest{
+ ModuleDir: dir,
+ Package: "sub",
+ Overlay: map[string][]byte{
+ "sub/overlay.cue": []byte("package sub\n\nextra: 42\n"),
+ },
+ }
+
+ got, err := compiler.Compile(context.Background(), request)
+ if err != nil {
+ t.Fatalf("Compile: %v", err)
+ }
+ if !got.Value.LookupPath(cue.ParsePath("value")).Exists() || !got.Value.LookupPath(cue.ParsePath("extra")).Exists() {
+ t.Fatalf("selected package value misses disk or overlay fields: %v", got.Value)
+ }
+ if got.Revision == revision(CompileRequest{ModuleDir: dir, Package: "sub"}) {
+ t.Fatal("overlay must contribute to revision")
+ }
+}
+
+func TestBuildCatalogDescribesSchema(t *testing.T) {
+ dir := testModule(t, map[string]string{"data.cue": "package main\n\nproducts: [ID=string]: {sku: string}\nproducts: {starter: {sku: \"starter\"}}\n#ProductID: or([for id, _ in products {id}])\ncustomers: [ID=string]: {name: string, country?: string, productIds: [...#ProductID]}\ncustomers: {acme: {name: \"Acme\", productIds: [\"starter\"]}}\n"})
+ compiled, err := New(evaluation.New("", time.Second, 1<<20)).Compile(context.Background(), CompileRequest{ModuleDir: dir})
+ if err != nil || len(compiled.Diagnostics) != 0 {
+ t.Fatalf("Compile = %+v, %v", compiled.Diagnostics, err)
+ }
+ var customers Domain
+ for _, domain := range compiled.Catalog.Domains {
+ if domain.Name == "customers" {
+ customers = domain
+ }
+ }
+ if customers.Kind != "registry" || !customers.Fields["name"].Required || customers.Fields["country"].Required {
+ t.Fatalf("customers = %+v", customers)
+ }
+ if relation := customers.Fields["productIds"].Relation; relation == nil || relation.Domain != "products" || relation.Cardinality != "many" {
+ t.Fatalf("relation = %+v", relation)
+ }
+}
diff --git a/backend/internal/knowledge/diagnostics.go b/backend/internal/knowledge/diagnostics.go
new file mode 100644
index 0000000..a557986
--- /dev/null
+++ b/backend/internal/knowledge/diagnostics.go
@@ -0,0 +1,13 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+package knowledge
+
+import "github.com/stratorys/cueto/backend/internal/diag"
+
+// Diagnostic preserves the existing structured, source-scrubbed diagnostic
+// contract while giving the compiler its own public vocabulary.
+type Diagnostic = diag.Diagnostic
diff --git a/backend/internal/knowledge/diagram.go b/backend/internal/knowledge/diagram.go
new file mode 100644
index 0000000..c6af50a
--- /dev/null
+++ b/backend/internal/knowledge/diagram.go
@@ -0,0 +1,24 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+package knowledge
+
+import "cuelang.org/go/cue"
+
+// DiagramProjection is the compatibility projection for explicitly authored
+// diagrams. It is intentionally separate from compilation: the compiler never
+// requires a module to import Cueto's diagram schema. The legacy inferred-view
+// projection can be moved here in the next phase once its discovery rules are
+// expressed against the generic catalog.
+type DiagramProjection struct{}
+
+func (DiagramProjection) Name() string { return "diagram" }
+
+func (DiagramProjection) Discover(value cue.Value) (any, error) {
+ // An authored diagram remains a normal CUE declaration. Returning the value
+ // preserves its types and provenance for a later JSON/UI adapter.
+ return value.LookupPath(cue.ParsePath("diagram")), nil
+}
diff --git a/backend/internal/knowledge/evaluate.go b/backend/internal/knowledge/evaluate.go
new file mode 100644
index 0000000..edb1043
--- /dev/null
+++ b/backend/internal/knowledge/evaluate.go
@@ -0,0 +1,20 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+package knowledge
+
+import "cuelang.org/go/cue"
+
+// EvaluationProjection discovers an optional, CUE-native knowledge.evaluations
+// namespace. Values are intentionally retained as CUE values for later typed
+// query/export adapters.
+type EvaluationProjection struct{}
+
+func (EvaluationProjection) Name() string { return "evaluations" }
+
+func (EvaluationProjection) Discover(value cue.Value) (any, error) {
+ return value.LookupPath(cue.ParsePath("knowledge.evaluations")), nil
+}
diff --git a/backend/internal/knowledge/evaluation_test.go b/backend/internal/knowledge/evaluation_test.go
new file mode 100644
index 0000000..dffefb8
--- /dev/null
+++ b/backend/internal/knowledge/evaluation_test.go
@@ -0,0 +1,50 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+package knowledge
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stratorys/cueto/backend/internal/evaluation"
+)
+
+func TestEvaluateOverlaysInputAndReturnsOnlyResult(t *testing.T) {
+ dir := testModule(t, map[string]string{"data.cue": `package main
+evaluations: enterpriseDiscount: {
+ description: "Evaluate enterprise discount eligibility"
+ input: {customerId: string, seats: int & >=0}
+ result: {
+ eligible: input.seats >= 100
+ discountPercent: 15
+ }
+}
+`})
+ runtime := NewRuntime(New(evaluation.New("", time.Second, 1<<20)))
+ result, err := runtime.Eval(context.Background(), ProjectRef{ModuleDir: dir}, EvalRequest{
+ Evaluation: "enterpriseDiscount",
+ Input: []byte(`{"customerId":"acme","seats":120}`),
+ })
+ if err != nil {
+ t.Fatalf("Eval: %v", err)
+ }
+ if result.Status != "success" || result.Evaluation != "enterpriseDiscount" || string(result.Result) != `{"eligible":true,"discountPercent":15}` || result.Revision == "" {
+ t.Fatalf("result = %+v", result)
+ }
+}
+
+func TestEvaluateRejectsInputOutsideCueSchema(t *testing.T) {
+ dir := testModule(t, map[string]string{"data.cue": `package main
+evaluations: seats: {description: "x", input: {seats: int & >=0}, result: {ok: true}}
+`})
+ runtime := NewRuntime(New(evaluation.New("", time.Second, 1<<20)))
+ _, err := runtime.Eval(context.Background(), ProjectRef{ModuleDir: dir}, EvalRequest{Evaluation: "seats", Input: []byte(`{"seats":-1}`)})
+ if err == nil {
+ t.Fatal("invalid evaluation input succeeded")
+ }
+}
diff --git a/backend/internal/knowledge/health.go b/backend/internal/knowledge/health.go
new file mode 100644
index 0000000..ef5fe62
--- /dev/null
+++ b/backend/internal/knowledge/health.go
@@ -0,0 +1,15 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+package knowledge
+
+// Health reports whether the complete module passes CUE validity checks. Compile
+// can still return a selected package value when a sibling is unhealthy, which is
+// useful for editors; callers that need a CI gate inspect Health.Valid.
+type Health struct {
+ Valid bool
+ Diagnostics []Diagnostic
+}
diff --git a/backend/internal/knowledge/model.go b/backend/internal/knowledge/model.go
new file mode 100644
index 0000000..35f05e9
--- /dev/null
+++ b/backend/internal/knowledge/model.go
@@ -0,0 +1,43 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+// Package knowledge defines Cueto's diagram-independent compiled knowledge
+// model. It is intentionally small in phase one: callers receive the compiled
+// CUE value plus projections that can evolve without making diagrams the core
+// product contract.
+package knowledge
+
+import (
+ "context"
+
+ "cuelang.org/go/cue"
+)
+
+// CompileRequest selects one package in a CUE module and overlays unsaved CUE
+// files. Overlay keys are module-relative filenames; validation is delegated to
+// the established evaluation loader so all adapters enforce the same guard.
+type CompileRequest struct {
+ ModuleDir string
+ Package string
+ Overlay map[string][]byte
+}
+
+// CompiledKnowledge is a generic CUE compilation result. Value remains a CUE
+// value deliberately: projections consume the typed, unified graph rather than
+// lossy JSON. Catalog, health, and diagnostics are transport-safe summaries.
+type CompiledKnowledge struct {
+ Revision string
+ Value cue.Value
+ Catalog Catalog
+ Diagnostics []Diagnostic
+ Health Health
+}
+
+// Compiler is the stable entry point shared by CLI, HTTP, MCP, and diagram
+// adapters. Operational failures use error; source failures are diagnostics.
+type Compiler interface {
+ Compile(ctx context.Context, request CompileRequest) (*CompiledKnowledge, error)
+}
diff --git a/backend/internal/knowledge/provenance.go b/backend/internal/knowledge/provenance.go
new file mode 100644
index 0000000..24f28f1
--- /dev/null
+++ b/backend/internal/knowledge/provenance.go
@@ -0,0 +1,69 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+package knowledge
+
+import (
+ "path/filepath"
+ "sort"
+
+ "cuelang.org/go/cue"
+ "cuelang.org/go/cue/ast"
+)
+
+// Provenance maps top-level declarations to their CUE source. It is generic;
+// diagram element provenance remains an adapter concern until it is generalized.
+type Provenance struct {
+ Entries []ProvenanceEntry
+}
+
+type ProvenanceEntry struct {
+ Name string
+ File string
+ Line int
+}
+
+type ProvenanceProjection struct{}
+
+func (ProvenanceProjection) Name() string { return "provenance" }
+
+func (ProvenanceProjection) Discover(value cue.Value) (any, error) {
+ result := Provenance{Entries: []ProvenanceEntry{}}
+ it, err := value.Fields(cue.Optional(true), cue.Definitions(true))
+ if err != nil {
+ return result, nil
+ }
+ for it.Next() {
+ node := it.Value().Source()
+ if node == nil || !node.Pos().IsValid() {
+ continue
+ }
+ pos := node.Pos()
+ result.Entries = append(result.Entries, ProvenanceEntry{Name: it.Selector().String(), File: filepath.Base(pos.Filename()), Line: pos.Line()})
+ }
+ // A unified value may not retain a single conjunct as Value.Source (for
+ // example a registry combines its pattern and concrete members). Its syntax
+ // still retains the root field positions, which gives the generic runtime a
+ // useful declaration-level fallback without diagram-specific AST parsing.
+ if len(result.Entries) == 0 {
+ if root, ok := value.Syntax(cue.Raw()).(*ast.StructLit); ok {
+ for _, decl := range root.Elts {
+ field, ok := decl.(*ast.Field)
+ if !ok || !field.Pos().IsValid() {
+ continue
+ }
+ name, _, err := ast.LabelName(field.Label)
+ if err != nil {
+ continue
+ }
+ pos := field.Pos()
+ result.Entries = append(result.Entries, ProvenanceEntry{Name: name, File: filepath.Base(pos.Filename()), Line: pos.Line()})
+ }
+ }
+ }
+ sort.Slice(result.Entries, func(i, j int) bool { return result.Entries[i].Name < result.Entries[j].Name })
+ return result, nil
+}
diff --git a/backend/internal/knowledge/query.go b/backend/internal/knowledge/query.go
new file mode 100644
index 0000000..d37a3da
--- /dev/null
+++ b/backend/internal/knowledge/query.go
@@ -0,0 +1,23 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+package knowledge
+
+import (
+ "context"
+ "encoding/json"
+
+ "github.com/stratorys/cueto/backend/internal/evaluation"
+)
+
+// Query evaluates a CUE expression against the same guarded module source used
+// by Compile. It is a convenience on the concrete compiler, not part of the
+// phase-one Compiler interface.
+func (c *CueCompiler) Query(ctx context.Context, request CompileRequest, expression string) (json.RawMessage, []Diagnostic, error) {
+ return c.engine.EvalQuery(ctx, sourceFrom(request), expression)
+}
+
+var _ = evaluation.ErrTimeout
diff --git a/backend/internal/knowledge/runtime.go b/backend/internal/knowledge/runtime.go
new file mode 100644
index 0000000..a7e436d
--- /dev/null
+++ b/backend/internal/knowledge/runtime.go
@@ -0,0 +1,584 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+package knowledge
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "reflect"
+ "sort"
+ "strings"
+
+ "cuelang.org/go/cue"
+ "cuelang.org/go/cue/ast"
+ "cuelang.org/go/cue/parser"
+
+ "github.com/stratorys/cueto/backend/internal/evaluation"
+)
+
+// ProjectRef is the transport-neutral identity of knowledge being addressed.
+// An HTTP adapter may derive ModuleDir from a project id; CLI and MCP can pass a
+// module directly. Overlays preserve the editor's unsaved-buffer semantics.
+type ProjectRef struct {
+ ModuleDir string
+ Package string
+ Overlay map[string][]byte
+}
+
+// Query is the safe, data-oriented agent query model. It never accepts CUE
+// source: records are exported and filtered in Go against the catalog.
+type Query struct {
+ Domain string `json:"domain"`
+ Select []string `json:"select"`
+ Where []Predicate `json:"where,omitempty"`
+ Limit int `json:"limit,omitempty"`
+ Expand []Expansion `json:"expand,omitempty"`
+}
+
+type Predicate struct {
+ Field string `json:"field"`
+ Operator string `json:"operator"`
+ Value any `json:"value,omitempty"`
+}
+
+// Expansion is reserved for bounded relation expansion. The initial safe query
+// implementation rejects it rather than silently issuing unbounded graph walks.
+type Expansion struct {
+ Field string `json:"field"`
+ Select []string `json:"select,omitempty"`
+ Limit int `json:"limit,omitempty"`
+}
+
+type QueryResult struct {
+ Result json.RawMessage `json:"result"`
+ Count int `json:"count"`
+}
+
+// DomainDescription gives agents a stable description without exposing the
+// entire compiled module.
+type DomainDescription struct {
+ Domain
+ Members []string
+}
+
+// EvalRequest selects one optional named evaluation. Inputs are declared by the
+// CUE contract in phase two; parameter binding is deliberately deferred until
+// the contract defines input substitution semantics.
+type EvalRequest struct {
+ Evaluation string `json:"evaluation"`
+ Input json.RawMessage `json:"input"`
+}
+
+type EvalResult struct {
+ Status string `json:"status"`
+ Result json.RawMessage `json:"result"`
+ Revision string `json:"revision"`
+ Evaluation string `json:"evaluation"`
+}
+
+type ProvenanceResult struct {
+ Name string
+ Provenance Provenance
+ Observations []Observation `json:"observations"`
+}
+
+// Runtime is the transport-independent knowledge service shared by CLI, HTTP,
+// MCP, and visual adapters. Its methods intentionally express knowledge tasks,
+// not CUE loader or HTTP concerns.
+type Runtime interface {
+ Catalog(context.Context, ProjectRef) (Catalog, error)
+ Describe(context.Context, ProjectRef, string) (DomainDescription, error)
+ Get(context.Context, ProjectRef, string, string) (json.RawMessage, error)
+ Query(context.Context, ProjectRef, Query) (QueryResult, error)
+ Eval(context.Context, ProjectRef, EvalRequest) (EvalResult, error)
+ Provenance(context.Context, ProjectRef, string) (ProvenanceResult, error)
+ Health(context.Context, ProjectRef) (Health, error)
+}
+
+// CueRuntime is the first Runtime implementation. It delegates all CUE work to
+// CueCompiler, preserving its evaluator limits, overlay guard, and diagnostics.
+type CueRuntime struct {
+ compiler *CueCompiler
+}
+
+func NewRuntime(compiler *CueCompiler) *CueRuntime { return &CueRuntime{compiler: compiler} }
+
+var _ Runtime = (*CueRuntime)(nil)
+
+func (r *CueRuntime) Catalog(ctx context.Context, project ProjectRef) (Catalog, error) {
+ compiled, err := r.compile(ctx, project)
+ if err != nil {
+ return Catalog{}, err
+ }
+ return compiled.Catalog, nil
+}
+
+func (r *CueRuntime) Describe(ctx context.Context, project ProjectRef, name string) (DomainDescription, error) {
+ compiled, err := r.compile(ctx, project)
+ if err != nil {
+ return DomainDescription{}, err
+ }
+ for _, domain := range compiled.Catalog.Domains {
+ if domain.Name != name {
+ continue
+ }
+ result := DomainDescription{Domain: domain}
+ for _, entry := range compiled.Catalog.Entries {
+ if entry.Name == name {
+ // Membership is intentionally supplied only for structural registries;
+ // explicit collections can be arbitrary CUE values.
+ for _, registry := range implicitRegistries(compiled.Value) {
+ if registry.Name == name {
+ result.Members = registry.Members
+ }
+ }
+ break
+ }
+ }
+ return result, nil
+ }
+ return DomainDescription{}, fmt.Errorf("unknown domain %q", name)
+}
+
+func (r *CueRuntime) Get(ctx context.Context, project ProjectRef, domain, key string) (json.RawMessage, error) {
+ compiled, err := r.compile(ctx, project)
+ if err != nil {
+ return nil, err
+ }
+ collection, ok := domainCollection(compiled, domain)
+ if !ok {
+ return nil, fmt.Errorf("unknown domain %q", domain)
+ }
+ value := collection.LookupPath(cue.MakePath(cue.Str(key)))
+ if !value.Exists() {
+ return nil, fmt.Errorf("unknown %s entry %q", domain, key)
+ }
+ result, diagnostics, err := r.compiler.encode(value, compileRequest(project))
+ if err != nil {
+ return nil, err
+ }
+ if len(diagnostics) > 0 {
+ return nil, &DiagnosticError{Diagnostics: diagnostics}
+ }
+ return result, nil
+}
+
+func (r *CueRuntime) Query(ctx context.Context, project ProjectRef, query Query) (QueryResult, error) {
+ if query.Domain == "" {
+ return QueryResult{}, fmt.Errorf("query domain is required")
+ }
+ if len(query.Expand) > 0 {
+ return QueryResult{}, fmt.Errorf("relation expansion is not available in the initial safe query API")
+ }
+ compiled, err := r.compile(ctx, project)
+ if err != nil {
+ return QueryResult{}, err
+ }
+ var domain Domain
+ found := false
+ for _, candidate := range compiled.Catalog.Domains {
+ if candidate.Name == query.Domain {
+ domain, found = candidate, true
+ break
+ }
+ }
+ if !found {
+ return QueryResult{}, fmt.Errorf("unknown domain %q", query.Domain)
+ }
+ if err := validateQuery(domain, query); err != nil {
+ return QueryResult{}, err
+ }
+ limit := query.Limit
+ if limit == 0 {
+ limit = 100
+ }
+ if limit < 0 || limit > 1000 {
+ return QueryResult{}, fmt.Errorf("query limit must be between 1 and 1000")
+ }
+ collection, ok := domainCollection(compiled, query.Domain)
+ if !ok {
+ return QueryResult{}, fmt.Errorf("unknown domain %q", query.Domain)
+ }
+ raw, diagnostics, err := r.compiler.encode(collection, compileRequest(project))
+ if err != nil {
+ return QueryResult{}, err
+ }
+ if len(diagnostics) > 0 {
+ return QueryResult{}, &DiagnosticError{Diagnostics: diagnostics}
+ }
+ var records map[string]map[string]any
+ if err := json.Unmarshal(raw, &records); err != nil {
+ return QueryResult{}, fmt.Errorf("domain %q is not a record collection", query.Domain)
+ }
+ keys := make([]string, 0, len(records))
+ for key := range records {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ result := make([]map[string]any, 0, min(limit, len(keys)))
+ for _, key := range keys {
+ record := records[key]
+ if !matches(record, query.Where) {
+ continue
+ }
+ selected := map[string]any{"id": key}
+ if len(query.Select) == 0 {
+ for field, value := range record {
+ selected[field] = value
+ }
+ } else {
+ for _, field := range query.Select {
+ if field != "id" {
+ selected[field] = record[field]
+ }
+ }
+ }
+ result = append(result, selected)
+ if len(result) == limit {
+ break
+ }
+ }
+ encoded, err := json.Marshal(result)
+ if err != nil {
+ return QueryResult{}, err
+ }
+ return QueryResult{Result: encoded, Count: len(result)}, nil
+}
+
+// Repl retains trusted arbitrary CUE evaluation for developer tooling. It is not
+// part of Runtime, so MCP and other agent transports default to Query instead.
+func (r *CueRuntime) Repl(ctx context.Context, project ProjectRef, expression string) (json.RawMessage, []Diagnostic, error) {
+ return r.compiler.Query(ctx, compileRequest(project), expression)
+}
+
+func domainCollection(compiled *CompiledKnowledge, name string) (cue.Value, bool) {
+ collection := compiled.Value.LookupPath(cue.MakePath(cue.Str(name)))
+ for _, candidate := range compiled.Catalog.Domains {
+ if candidate.Name == name && candidate.Explicit && candidate.Collection.Exists() {
+ collection = candidate.Collection
+ break
+ }
+ }
+ return collection, collection.Exists()
+}
+
+func validateQuery(domain Domain, query Query) error {
+ known := func(field string) bool { _, ok := domain.Fields[field]; return ok }
+ for _, field := range query.Select {
+ if field != "id" && !known(field) {
+ return fmt.Errorf("unknown field %q for domain %q", field, domain.Name)
+ }
+ }
+ for _, predicate := range query.Where {
+ if !known(predicate.Field) {
+ return fmt.Errorf("unknown field %q for domain %q", predicate.Field, domain.Name)
+ }
+ switch predicate.Operator {
+ case "eq", "neq", "in", "exists", "gt", "gte", "lt", "lte":
+ default:
+ return fmt.Errorf("unsupported query operator %q", predicate.Operator)
+ }
+ if predicate.Operator == "in" {
+ if _, ok := predicate.Value.([]any); !ok {
+ return fmt.Errorf("operator in requires an array value")
+ }
+ }
+ }
+ return nil
+}
+
+func matches(record map[string]any, predicates []Predicate) bool {
+ for _, predicate := range predicates {
+ value, exists := record[predicate.Field]
+ switch predicate.Operator {
+ case "exists":
+ if !exists || value == nil {
+ return false
+ }
+ case "eq":
+ if !exists || !reflect.DeepEqual(value, predicate.Value) {
+ return false
+ }
+ case "neq":
+ if exists && reflect.DeepEqual(value, predicate.Value) {
+ return false
+ }
+ case "in":
+ values, _ := predicate.Value.([]any)
+ found := false
+ for _, candidate := range values {
+ if reflect.DeepEqual(value, candidate) {
+ found = true
+ break
+ }
+ }
+ if !exists || !found {
+ return false
+ }
+ case "gt", "gte", "lt", "lte":
+ cmp, ok := compare(value, predicate.Value)
+ if !ok {
+ return false
+ }
+ if (predicate.Operator == "gt" && cmp <= 0) || (predicate.Operator == "gte" && cmp < 0) || (predicate.Operator == "lt" && cmp >= 0) || (predicate.Operator == "lte" && cmp > 0) {
+ return false
+ }
+ }
+ }
+ return true
+}
+
+func compare(left, right any) (int, bool) {
+ if l, ok := left.(float64); ok {
+ r, ok := right.(float64)
+ if !ok {
+ return 0, false
+ }
+ if l < r {
+ return -1, true
+ }
+ if l > r {
+ return 1, true
+ }
+ return 0, true
+ }
+ if l, ok := left.(string); ok {
+ r, ok := right.(string)
+ if !ok {
+ return 0, false
+ }
+ if l < r {
+ return -1, true
+ }
+ if l > r {
+ return 1, true
+ }
+ return 0, true
+ }
+ return 0, false
+}
+
+func (r *CueRuntime) Eval(ctx context.Context, project ProjectRef, request EvalRequest) (EvalResult, error) {
+ compiled, err := r.compile(ctx, project)
+ if err != nil {
+ return EvalResult{}, err
+ }
+ for _, evaluation := range compiled.Catalog.Evaluations {
+ if evaluation.Name != request.Evaluation {
+ continue
+ }
+ if len(request.Input) == 0 || !json.Valid(request.Input) {
+ return EvalResult{}, fmt.Errorf("evaluation input must be valid JSON")
+ }
+ withInput := projectWithEvaluationInput(project, evaluation.Path, evaluation.Name, request.Input)
+ executed, err := r.compiler.Compile(ctx, compileRequest(withInput))
+ if err != nil {
+ return EvalResult{}, err
+ }
+ if len(executed.Diagnostics) > 0 {
+ return EvalResult{}, &DiagnosticError{Diagnostics: executed.Diagnostics}
+ }
+ value := evaluationValue(executed.Value, evaluation.Path, evaluation.Name, "result")
+ if !value.Exists() {
+ // Phase-two compatibility: output remains readable until callers migrate.
+ value = evaluationValue(executed.Value, evaluation.Path, evaluation.Name, "output")
+ }
+ result, diagnostics, err := r.compiler.encode(value, compileRequest(withInput))
+ if err != nil {
+ return EvalResult{}, err
+ }
+ if len(diagnostics) > 0 {
+ return EvalResult{}, &DiagnosticError{Diagnostics: diagnostics}
+ }
+ return EvalResult{Status: "success", Result: result, Revision: executed.Revision, Evaluation: evaluation.Name}, nil
+ }
+ return EvalResult{}, fmt.Errorf("unknown evaluation %q", request.Evaluation)
+}
+
+func projectWithEvaluationInput(project ProjectRef, path, name string, input json.RawMessage) ProjectRef {
+ overlay := make(map[string][]byte, len(project.Overlay)+1)
+ for name, content := range project.Overlay {
+ overlay[name] = content
+ }
+ label, _ := json.Marshal(name)
+ prefix := "evaluations"
+ if path == "knowledge.evaluations" {
+ prefix = "knowledge: evaluations"
+ }
+ overlay["cueto_evaluation_input.cue"] = []byte(fmt.Sprintf("package main\n\n%s: {%s: {input: %s}}\n", prefix, label, input))
+ project.Overlay = overlay
+ return project
+}
+
+func evaluationValue(root cue.Value, path, name, field string) cue.Value {
+ selectors := []cue.Selector{cue.Str("evaluations"), cue.Str(name), cue.Str(field)}
+ if path == "knowledge.evaluations" {
+ selectors = append([]cue.Selector{cue.Str("knowledge")}, selectors...)
+ }
+ return root.LookupPath(cue.MakePath(selectors...))
+}
+
+func (r *CueRuntime) Provenance(ctx context.Context, project ProjectRef, name string) (ProvenanceResult, error) {
+ compiled, err := r.compile(ctx, project)
+ if err != nil {
+ return ProvenanceResult{}, err
+ }
+ provenance, err := sourceProvenance(project)
+ if err != nil {
+ return ProvenanceResult{}, err
+ }
+ result := provenance
+ if len(result.Entries) == 0 {
+ // Retain the value-based projection as a fallback for future runtimes
+ // that compile from a non-filesystem source.
+ projection, err := (ProvenanceProjection{}).Discover(compiled.Value)
+ if err != nil {
+ return ProvenanceResult{}, err
+ }
+ result = projection.(Provenance)
+ }
+ if name == "" {
+ return ProvenanceResult{Provenance: result, Observations: compiled.Catalog.Observations}, nil
+ }
+ filtered := Provenance{Entries: []ProvenanceEntry{}}
+ for _, entry := range result.Entries {
+ if entry.Name == name {
+ filtered.Entries = append(filtered.Entries, entry)
+ }
+ }
+ semantic := make([]Observation, 0)
+ for _, observation := range compiled.Catalog.Observations {
+ if observation.Name == name || observation.Entity == name {
+ semantic = append(semantic, observation)
+ }
+ }
+ if len(filtered.Entries) == 0 && len(semantic) == 0 {
+ return ProvenanceResult{}, fmt.Errorf("no provenance for %q", name)
+ }
+ return ProvenanceResult{Name: name, Provenance: filtered, Observations: semantic}, nil
+}
+
+// sourceProvenance is declaration-level provenance from the prepared module
+// source. Unified CUE values do not reliably retain one source conjunct, so the
+// runtime intentionally parses source here, just as diagram authoring already
+// does. Editor overlays replace on-disk files before parsing.
+func sourceProvenance(project ProjectRef) (Provenance, error) {
+ files := map[string][]byte{}
+ root, err := filepath.Abs(project.ModuleDir)
+ if err != nil {
+ return Provenance{}, err
+ }
+ packageDir := root
+ if project.Package != "" && project.Package != "." {
+ if filepath.IsAbs(project.Package) {
+ return Provenance{}, fmt.Errorf("package %q escapes module root", project.Package)
+ }
+ packageDir = filepath.Join(root, project.Package)
+ rel, err := filepath.Rel(root, packageDir)
+ if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
+ return Provenance{}, fmt.Errorf("package %q escapes module root", project.Package)
+ }
+ }
+ err = filepath.WalkDir(packageDir, func(path string, entry os.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if entry.IsDir() {
+ if entry.Name() == "cue.mod" || entry.Name() == ".git" {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if !strings.HasSuffix(entry.Name(), ".cue") {
+ return nil
+ }
+ rel, err := filepath.Rel(root, path)
+ if err != nil {
+ return err
+ }
+ contents, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ files[rel] = contents
+ return nil
+ })
+ if err != nil {
+ return Provenance{}, err
+ }
+ for name, content := range project.Overlay {
+ files[name] = content
+ }
+
+ result := Provenance{Entries: []ProvenanceEntry{}}
+ names := make([]string, 0, len(files))
+ for name := range files {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ for _, name := range names {
+ file, err := parser.ParseFile(name, files[name])
+ if err != nil {
+ continue // compilation owns syntax diagnostics
+ }
+ for _, decl := range file.Decls {
+ field, ok := decl.(*ast.Field)
+ if !ok {
+ continue
+ }
+ label, _, err := ast.LabelName(field.Label)
+ if err != nil {
+ continue
+ }
+ result.Entries = append(result.Entries, ProvenanceEntry{Name: label, File: name, Line: field.Pos().Line()})
+ }
+ }
+ return result, nil
+}
+
+func (r *CueRuntime) Health(ctx context.Context, project ProjectRef) (Health, error) {
+ compiled, err := r.compiler.Compile(ctx, compileRequest(project))
+ if err != nil {
+ return Health{}, err
+ }
+ return compiled.Health, nil
+}
+
+func (r *CueRuntime) compile(ctx context.Context, project ProjectRef) (*CompiledKnowledge, error) {
+ compiled, err := r.compiler.Compile(ctx, compileRequest(project))
+ if err != nil {
+ return nil, err
+ }
+ if len(compiled.Diagnostics) > 0 {
+ return nil, &DiagnosticError{Diagnostics: compiled.Diagnostics}
+ }
+ return compiled, nil
+}
+
+func compileRequest(project ProjectRef) CompileRequest {
+ return CompileRequest{ModuleDir: project.ModuleDir, Package: project.Package, Overlay: project.Overlay}
+}
+
+func implicitRegistries(value cue.Value) []evaluation.RegistryInfo {
+ return evaluation.DiscoverRegistries(value)
+}
+
+// DiagnosticError preserves source diagnostics through Runtime's error-returning
+// methods. HTTP and MCP adapters can inspect it with errors.As.
+type DiagnosticError struct {
+ Diagnostics []Diagnostic
+}
+
+func (e *DiagnosticError) Error() string {
+ if len(e.Diagnostics) == 0 {
+ return "knowledge diagnostics"
+ }
+ return e.Diagnostics[0].Message
+}
diff --git a/backend/internal/knowledge/runtime_test.go b/backend/internal/knowledge/runtime_test.go
new file mode 100644
index 0000000..2b74d7c
--- /dev/null
+++ b/backend/internal/knowledge/runtime_test.go
@@ -0,0 +1,185 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+package knowledge
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/stratorys/cueto/backend/internal/evaluation"
+)
+
+func TestSafeQueryFiltersAndProjectsRecords(t *testing.T) {
+ dir := testModule(t, map[string]string{
+ "data.cue": `package main
+customers: [ID=string]: {name: string, country: string, spend: number}
+customers: {
+ acme: {name: "Acme", country: "FR", spend: 120}
+ globex: {name: "Globex", country: "US", spend: 40}
+}
+`,
+ })
+ runtime := NewRuntime(New(evaluation.New("", time.Second, 1<<20)))
+ result, err := runtime.Query(context.Background(), ProjectRef{ModuleDir: dir}, Query{
+ Domain: "customers", Select: []string{"name", "spend"}, Limit: 1,
+ Where: []Predicate{{Field: "spend", Operator: "gte", Value: float64(100)}},
+ })
+ if err != nil {
+ t.Fatalf("Query: %v", err)
+ }
+ if result.Count != 1 {
+ t.Fatalf("count = %d, want 1", result.Count)
+ }
+ var records []map[string]any
+ if err := json.Unmarshal(result.Result, &records); err != nil {
+ t.Fatalf("decode result: %v", err)
+ }
+ if len(records) != 1 || records[0]["id"] != "acme" || records[0]["name"] != "Acme" || records[0]["country"] != nil {
+ t.Fatalf("records = %+v", records)
+ }
+}
+
+func TestSafeQueryRejectsUnknownFieldsAndExpressions(t *testing.T) {
+ dir := testModule(t, map[string]string{"data.cue": "package main\ncustomers: [string]: {name: string}\ncustomers: {acme: {name: \"Acme\"}}\n"})
+ runtime := NewRuntime(New(evaluation.New("", time.Second, 1<<20)))
+ _, err := runtime.Query(context.Background(), ProjectRef{ModuleDir: dir}, Query{Domain: "customers", Where: []Predicate{{Field: "unknown", Operator: "eq", Value: "x"}}})
+ if err == nil {
+ t.Fatal("unknown field query succeeded")
+ }
+}
+
+const describeGetFixture = `package main
+customers: [ID=string]: {name: string, country: string}
+customers: {
+ acme: {name: "Acme", country: "FR"}
+ globex: {name: "Globex", country: "US"}
+}
+`
+
+func TestDescribeReturnsDomainAndMembers(t *testing.T) {
+ dir := testModule(t, map[string]string{"data.cue": describeGetFixture})
+ runtime := NewRuntime(New(evaluation.New("", time.Second, 1<<20)))
+ result, err := runtime.Describe(context.Background(), ProjectRef{ModuleDir: dir}, "customers")
+ if err != nil {
+ t.Fatalf("Describe: %v", err)
+ }
+ if result.Kind != "registry" || len(result.Members) != 2 {
+ t.Fatalf("result = %+v", result)
+ }
+}
+
+func TestDescribeUnknownDomain(t *testing.T) {
+ dir := testModule(t, map[string]string{"data.cue": describeGetFixture})
+ runtime := NewRuntime(New(evaluation.New("", time.Second, 1<<20)))
+ if _, err := runtime.Describe(context.Background(), ProjectRef{ModuleDir: dir}, "nope"); err == nil {
+ t.Fatal("unknown domain succeeded")
+ }
+}
+
+func TestGetReturnsOneRecord(t *testing.T) {
+ dir := testModule(t, map[string]string{"data.cue": describeGetFixture})
+ runtime := NewRuntime(New(evaluation.New("", time.Second, 1<<20)))
+ result, err := runtime.Get(context.Background(), ProjectRef{ModuleDir: dir}, "customers", "acme")
+ if err != nil {
+ t.Fatalf("Get: %v", err)
+ }
+ var record map[string]any
+ if err := json.Unmarshal(result, &record); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if record["name"] != "Acme" {
+ t.Fatalf("record = %+v", record)
+ }
+}
+
+func TestGetUnknownKey(t *testing.T) {
+ dir := testModule(t, map[string]string{"data.cue": describeGetFixture})
+ runtime := NewRuntime(New(evaluation.New("", time.Second, 1<<20)))
+ if _, err := runtime.Get(context.Background(), ProjectRef{ModuleDir: dir}, "customers", "nope"); err == nil {
+ t.Fatal("unknown key succeeded")
+ }
+}
+
+func TestProvenanceListsDeclarationsAndFiltersByName(t *testing.T) {
+ dir := testModule(t, map[string]string{"data.cue": describeGetFixture})
+ runtime := NewRuntime(New(evaluation.New("", time.Second, 1<<20)))
+ all, err := runtime.Provenance(context.Background(), ProjectRef{ModuleDir: dir}, "")
+ if err != nil {
+ t.Fatalf("Provenance: %v", err)
+ }
+ if len(all.Provenance.Entries) == 0 {
+ t.Fatalf("provenance = %+v, want at least one entry", all)
+ }
+ filtered, err := runtime.Provenance(context.Background(), ProjectRef{ModuleDir: dir}, "customers")
+ if err != nil {
+ t.Fatalf("Provenance(customers): %v", err)
+ }
+ // The fixture declares "customers" twice (the registry pattern and the
+ // concrete members), so both declaration sites are expected back.
+ if len(filtered.Provenance.Entries) != 2 || filtered.Provenance.Entries[0].Name != "customers" {
+ t.Fatalf("filtered = %+v", filtered)
+ }
+}
+
+func TestProvenanceUnknownName(t *testing.T) {
+ dir := testModule(t, map[string]string{"data.cue": describeGetFixture})
+ runtime := NewRuntime(New(evaluation.New("", time.Second, 1<<20)))
+ if _, err := runtime.Provenance(context.Background(), ProjectRef{ModuleDir: dir}, "nope"); err == nil {
+ t.Fatal("unknown name succeeded")
+ }
+}
+
+func TestSourceProvenanceRejectsPackageEscapingModuleRoot(t *testing.T) {
+ parent := t.TempDir()
+ module := filepath.Join(parent, "module")
+ if err := os.MkdirAll(filepath.Join(module, "cue.mod"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(module, "cue.mod", "module.cue"), []byte("module: \"example.com/knowledge\"\nlanguage: version: \"v0.17.0\"\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(module, "data.cue"), []byte(describeGetFixture), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ secret := filepath.Join(parent, "secret")
+ if err := os.MkdirAll(secret, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(secret, "leak.cue"), []byte("package main\nleaked: name: \"hidden\"\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ for _, pkg := range []string{"..", "../secret", secret} {
+ if _, err := sourceProvenance(ProjectRef{ModuleDir: module, Package: pkg}); err == nil {
+ t.Fatalf("package %q escaped module root", pkg)
+ }
+ }
+}
+
+func TestHealthReflectsModuleValidity(t *testing.T) {
+ runtime := NewRuntime(New(evaluation.New("", time.Second, 1<<20)))
+ valid := testModule(t, map[string]string{"data.cue": describeGetFixture})
+ result, err := runtime.Health(context.Background(), ProjectRef{ModuleDir: valid})
+ if err != nil {
+ t.Fatalf("Health: %v", err)
+ }
+ if !result.Valid || len(result.Diagnostics) != 0 {
+ t.Fatalf("result = %+v, want a clean module", result)
+ }
+
+ invalid := testModule(t, map[string]string{"data.cue": "package main\nbad: 1\nbad: \"x\"\n"})
+ result, err = runtime.Health(context.Background(), ProjectRef{ModuleDir: invalid})
+ if err != nil {
+ t.Fatalf("Health: %v", err)
+ }
+ if result.Valid || len(result.Diagnostics) == 0 {
+ t.Fatalf("result = %+v, want an invalid module", result)
+ }
+}
diff --git a/backend/internal/knowledge/schema.go b/backend/internal/knowledge/schema.go
new file mode 100644
index 0000000..ddafccd
--- /dev/null
+++ b/backend/internal/knowledge/schema.go
@@ -0,0 +1,28 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+package knowledge
+
+import "cuelang.org/go/cue"
+
+// Projection is a consumer of a compiled CUE value. Projections may provide
+// diagrams, catalogs, provenance, or named evaluations without changing the
+// generic compiler.
+type Projection interface {
+ Name() string
+ Discover(cue.Value) (any, error)
+}
+
+// SchemaProjection is reserved for richer schema introspection. In phase one it
+// exposes the same declaration inventory as the catalog while keeping schema
+// concerns behind their own named extension point.
+type SchemaProjection struct{}
+
+func (SchemaProjection) Name() string { return "schema" }
+
+func (SchemaProjection) Discover(value cue.Value) (any, error) {
+ return KnowledgeCatalogProjection{}.Discover(value)
+}
diff --git a/backend/internal/projects/projects.go b/backend/internal/projects/projects.go
index 0ccf863..739aee9 100644
--- a/backend/internal/projects/projects.go
+++ b/backend/internal/projects/projects.go
@@ -14,6 +14,7 @@ package projects
import (
"errors"
+ "io/fs"
"os"
"path/filepath"
"regexp"
@@ -117,6 +118,41 @@ func (m *Manager) Create(name string) (Project, error) {
return Project{ID: id, Name: id}, nil
}
+// Seed creates a project by copying a prepared module tree (the embedded demo)
+// instead of the scaffold, then makes the same single initial commit Create
+// makes. Like Create it refuses an id that already names a non-empty directory,
+// so seeding can run on every startup and only ever act on a fresh root.
+func (m *Manager) Seed(id string, src fs.FS) (Project, error) {
+ if !projectIDPattern.MatchString(id) {
+ return Project{}, ErrInvalidName
+ }
+ dir := filepath.Join(m.root, id)
+ if entries, err := os.ReadDir(dir); err == nil && len(entries) > 0 {
+ return Project{}, ErrExists
+ }
+ err := fs.WalkDir(src, ".", func(path string, d fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ target := filepath.Join(dir, filepath.FromSlash(path))
+ if d.IsDir() {
+ return os.MkdirAll(target, 0o755)
+ }
+ content, err := fs.ReadFile(src, path)
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(target, content, 0o644)
+ })
+ if err != nil {
+ return Project{}, err
+ }
+ if err := initCommit(dir); err != nil {
+ return Project{}, err
+ }
+ return Project{ID: id, Name: id}, nil
+}
+
// isModule reports whether dir is a CUE module root (carries a cue.mod directory).
func isModule(dir string) bool {
info, err := os.Stat(filepath.Join(dir, moduleMarker))
diff --git a/backend/internal/projects/seed_test.go b/backend/internal/projects/seed_test.go
new file mode 100644
index 0000000..8e13040
--- /dev/null
+++ b/backend/internal/projects/seed_test.go
@@ -0,0 +1,71 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+package projects
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+ "testing/fstest"
+
+ git "github.com/go-git/go-git/v5"
+)
+
+func demoTree() fstest.MapFS {
+ return fstest.MapFS{
+ "cue.mod/module.cue": &fstest.MapFile{Data: []byte("module: \"example.com/demo\"\nlanguage: version: \"v0.17.0\"\n")},
+ "catalog.cue": &fstest.MapFile{Data: []byte("package main\n")},
+ }
+}
+
+func TestSeedWritesTreeAndCommits(t *testing.T) {
+ root := t.TempDir()
+ m := New(root)
+
+ p, err := m.Seed("demo", demoTree())
+ if err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+ if p.ID != "demo" {
+ t.Fatalf("id = %q, want demo", p.ID)
+ }
+ dir := filepath.Join(root, "demo")
+ for _, rel := range []string{"cue.mod/module.cue", "catalog.cue"} {
+ if _, err := os.Stat(filepath.Join(dir, rel)); err != nil {
+ t.Fatalf("seeded file missing %s: %v", rel, err)
+ }
+ }
+ repository, err := git.PlainOpen(dir)
+ if err != nil {
+ t.Fatalf("open git: %v", err)
+ }
+ if _, err := repository.Head(); err != nil {
+ t.Fatalf("head after seed: %v", err)
+ }
+ if ps, err := m.List(); err != nil || len(ps) != 1 || ps[0].ID != "demo" {
+ t.Fatalf("list after seed = %+v, %v", ps, err)
+ }
+}
+
+func TestSeedRefusesExistingProject(t *testing.T) {
+ root := t.TempDir()
+ m := New(root)
+ if _, err := m.Seed("demo", demoTree()); err != nil {
+ t.Fatalf("first seed: %v", err)
+ }
+ if _, err := m.Seed("demo", demoTree()); !errors.Is(err, ErrExists) {
+ t.Fatalf("second seed err = %v, want ErrExists", err)
+ }
+}
+
+func TestSeedRejectsInvalidID(t *testing.T) {
+ m := New(t.TempDir())
+ if _, err := m.Seed("../escape", demoTree()); !errors.Is(err, ErrInvalidName) {
+ t.Fatalf("err = %v, want ErrInvalidName", err)
+ }
+}
diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go
new file mode 100644
index 0000000..9847094
--- /dev/null
+++ b/backend/internal/server/server.go
@@ -0,0 +1,58 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+// Package server owns the HTTP serving loop shared by the dev server command and
+// cueto serve: explicit connection timeouts, serve-until-signal, and a graceful
+// drain so running evaluations finish (or hit their own deadline) instead of
+// being cut off.
+package server
+
+import (
+ "context"
+ "errors"
+ "log"
+ "net/http"
+ "os/signal"
+ "syscall"
+ "time"
+)
+
+// Run serves handler on :port until SIGINT or SIGTERM, then drains in-flight
+// requests. evalTimeout sizes the write timeout: it must exceed the evaluation
+// deadline or long evaluations get cut off mid-response.
+func Run(handler http.Handler, port string, evalTimeout time.Duration) error {
+ // Explicit server timeouts bound the connection layer that the body cap and
+ // eval deadline do not: slow-client (slowloris) reads and stuck writes.
+ srv := &http.Server{
+ Addr: ":" + port,
+ Handler: handler,
+ ReadHeaderTimeout: 5 * time.Second,
+ ReadTimeout: 15 * time.Second,
+ WriteTimeout: evalTimeout + 10*time.Second,
+ IdleTimeout: 60 * time.Second,
+ }
+
+ errCh := make(chan error, 1)
+ go func() {
+ if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ errCh <- err
+ }
+ }()
+
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+ defer stop()
+ select {
+ case err := <-errCh:
+ return err
+ case <-ctx.Done():
+ }
+ stop()
+ log.Println("Shutting down...")
+
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+ return srv.Shutdown(shutdownCtx)
+}
diff --git a/cue/knowledge/knowledge.cue b/cue/knowledge/knowledge.cue
new file mode 100644
index 0000000..d4d7f39
--- /dev/null
+++ b/cue/knowledge/knowledge.cue
@@ -0,0 +1,62 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+// Package knowledge provides Cueto's optional explicit knowledge contract.
+// Modules do not need to import it: Cueto continues to discover registries and
+// relations structurally. Importing it makes the metadata surface type-checked
+// and stable for CLI, HTTP, and MCP consumers.
+//
+// When a domain's label is also its collection field, bind the collection with a
+// top-level let and refer to that alias from the domain. CUE resolves an unqualified
+// `customers` inside domains.customers as the enclosing field, which is a cycle.
+package knowledge
+
+#Knowledge: {
+ metadata: {
+ title: string
+ description?: string
+ revision?: string
+ }
+
+ domains: [string]: #Domain
+ evaluations?: [string]: #Evaluation
+ observations?: [string]: #Observation
+ checks?: [string]: bool
+}
+
+#Domain: {
+ description?: string
+ collection: _
+ key?: string | *"id"
+}
+
+#Evaluation: {
+ description: string
+ input: _
+ result: _
+}
+
+// #Evaluations is the optional root-level contract for phase-six named
+// evaluations: `evaluations: knowledge.#Evaluations & { ... }`.
+#Evaluations: [string]: #Evaluation
+
+#SourceRef: {
+ kind: "file" | "uri" | "database" | "manual"
+ uri: string @uri()
+ pointer?: string
+ retrievedAt?: string
+}
+
+#Observation: {
+ entity: string
+ field: string
+ value: _
+ source: #SourceRef
+ status: "active" | "stale" | "disputed"
+ authority?: int & >=0 & <=100
+}
+
+#Observations: [string]: #Observation
diff --git a/examples/service-catalog/access.cue b/examples/service-catalog/access.cue
new file mode 100644
index 0000000..1c4c039
--- /dev/null
+++ b/examples/service-catalog/access.cue
@@ -0,0 +1,20 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+// Who may do what. Each role is a disjunction (a set) of permissions, so roles
+// form a semilattice under unification: `roles.a & roles.b` is the intersection
+// of what both allow, `_` (top) allows anything, and two disjoint permissions
+// unify to bottom. The REPL section of the README walks through it.
+package main
+
+#Perm: "read" | "write" | "deploy" | "admin"
+
+roles: {
+ viewer: "read"
+ developer: "read" | "write"
+ operator: "read" | "write" | "deploy"
+ owner: #Perm
+}
diff --git a/examples/service-catalog/catalog.cue b/examples/service-catalog/catalog.cue
new file mode 100644
index 0000000..5d7fcf7
--- /dev/null
+++ b/examples/service-catalog/catalog.cue
@@ -0,0 +1,100 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+// The demo the README walks through: a small engineering organization as plain
+// schema and data. No diagram is authored and nothing is imported from cueto.
+// The graph is inferred from the registries (teams, people, services) and their
+// key-set references (team, owner, techLead, dependsOn), and the knowledge
+// runtime discovers the same registries plus the named evaluations below.
+package main
+
+import "list"
+
+#TeamID: or([for id, _ in teams {id}])
+#PersonID: or([for id, _ in people {id}])
+#ServiceID: or([for id, _ in services {id}])
+
+#Team: {
+ name: string
+ channel: string
+}
+
+#Person: {
+ name: string
+ team: #TeamID
+}
+
+#Service: {
+ name: string
+ owner: #TeamID
+ techLead: #PersonID
+ tier: "critical" | "standard" | "internal"
+ dependsOn: [...#ServiceID]
+}
+
+teams: [ID=string]: #Team
+teams: {
+ platform: {name: "Platform", channel: "#team-platform"}
+ payments: {name: "Payments", channel: "#team-payments"}
+ web: {name: "Web", channel: "#team-web"}
+}
+
+people: [ID=string]: #Person
+people: {
+ alice: {name: "Alice Moreau", team: "platform"}
+ bruno: {name: "Bruno Keller", team: "payments"}
+ chloe: {name: "Chloé Diallo", team: "payments"}
+ dana: {name: "Dana Costa", team: "web"}
+}
+
+services: [ID=string]: #Service
+services: {
+ gateway: {
+ name: "API Gateway"
+ owner: "platform"
+ techLead: "alice"
+ tier: "critical"
+ }
+ billing: {
+ name: "Billing"
+ owner: "payments"
+ techLead: "bruno"
+ tier: "critical"
+ dependsOn: ["gateway", "ledger"]
+ }
+ ledger: {
+ name: "Ledger"
+ owner: "payments"
+ techLead: "chloe"
+ tier: "critical"
+ }
+ storefront: {
+ name: "Storefront"
+ owner: "web"
+ techLead: "dana"
+ tier: "standard"
+ dependsOn: ["gateway", "billing"]
+ }
+}
+
+evaluations: {
+ ownerOf: {
+ description: "Which team owns a service, and how to reach them"
+ input: {serviceId: #ServiceID}
+ result: {
+ team: services[input.serviceId].owner
+ channel: teams[services[input.serviceId].owner].channel
+ lead: people[services[input.serviceId].techLead].name
+ }
+ }
+ blastRadius: {
+ description: "Which services break if this service goes down"
+ input: {serviceId: #ServiceID}
+ result: {
+ dependents: [for id, s in services if list.Contains(s.dependsOn, input.serviceId) {id}]
+ }
+ }
+}
diff --git a/examples/service-catalog/cue.mod/module.cue b/examples/service-catalog/cue.mod/module.cue
new file mode 100644
index 0000000..1fe30fe
--- /dev/null
+++ b/examples/service-catalog/cue.mod/module.cue
@@ -0,0 +1,11 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+// The demo project: a plain CUE module with no cueto import, shaped exactly like
+// the module POST /projects scaffolds, so it behaves like a project the app
+// created itself.
+module: "example.com/service-catalog"
+language: version: "v0.17.0"
diff --git a/examples/service-catalog/deploy.cue b/examples/service-catalog/deploy.cue
new file mode 100644
index 0000000..99b3ba6
--- /dev/null
+++ b/examples/service-catalog/deploy.cue
@@ -0,0 +1,30 @@
+// cueto
+//
+// Copyright: 2026, Lucas Jahier - Stratorys
+// License: Mozilla Public License v2.0 (MPL v2.0)
+// SPDX-License-Identifier: MPL-2.0
+
+// How services run in each environment. Configs form a meet-semilattice under
+// unification: every concrete environment is `configBase & overlay`, the
+// greatest lower bound of both, defaults fill what the overlay leaves open, and
+// an overlay that contradicts the base is bottom, a build error.
+package main
+
+#Config: {
+ replicas: int & >=1
+ logLevel: "debug" | "info" | "error"
+ memoryMb: int & >=128
+}
+
+configBase: #Config & {
+ replicas: *1 | _
+ logLevel: *"info" | _
+ memoryMb: *256 | _
+}
+
+environments: [ID=string]: #Config
+environments: {
+ dev: configBase
+ staging: configBase & {replicas: 2}
+ prod: configBase & {replicas: 3, logLevel: "error", memoryMb: 1024}
+}
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index be86aaa..5b00c05 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -9,7 +9,10 @@
import type { Diagram, DiagramEdge, DiagramNode, EditorFile, Provenance } from "./model";
-const BASE = import.meta.env.VITE_API_URL ?? "http://localhost:8091";
+// Empty means same-origin: the packaged `cueto serve` binary serves the UI and
+// the API from one address. Dev keeps the Vite server and sets VITE_API_URL to
+// the Go backend (see .env.example).
+const BASE = import.meta.env.VITE_API_URL ?? "";
// The current project id, woven into every project-scoped request path. Every
// module-touching endpoint (eval, vet, repl, save, history, file, tree) is served
@@ -140,6 +143,10 @@ export interface CueMeta {
packages: CuePackage[];
}
+export interface KnowledgeField { type: string; required: boolean; relation?: { domain: string; cardinality: string } }
+export interface KnowledgeDomain { name: string; description?: string; fields: Record }
+export interface KnowledgeCatalog { domains: KnowledgeDomain[]; evaluations: { name: string; description: string; inputSchema: { fields?: Record } }[]; observations: unknown[] }
+
export interface EvalErr {
ok: false;
error: string;
@@ -270,6 +277,10 @@ async function get(
return errorResult(errorBody, response.status);
}
+export function getKnowledgeCatalog(): Promise<({ ok: true; catalog: KnowledgeCatalog } | EvalErr)> {
+ return get(proj() + "/knowledge/catalog", async (response) => ({ catalog: await readJson(response) }));
+}
+
// sendJSON is post generalized to any mutating method (PATCH/DELETE), with an
// optional body. Same transport + error shaping as post/get.
async function sendJSON(
@@ -414,6 +425,22 @@ export function listProjects(): Promise {
});
}
+// getSession is the bootstrap request: the server resolves the current project
+// (the persisted selection when it still exists, else the only project, else "")
+// and returns the projects list with it, so the client needs no local storage.
+export function getSession(): Promise<({ ok: true; currentProject: string; projects: ProjectMeta[] }) | EvalErr> {
+ return get("/session", async (response) => {
+ const body = await readJson<{ currentProject?: string; projects?: ProjectMeta[] }>(response);
+ return { currentProject: body.currentProject ?? "", projects: body.projects ?? [] };
+ });
+}
+
+// setSessionProject persists the current project server-side, the same state
+// `cueto use` writes, so every browser and the CLI agree on the default.
+export function setSessionProject(id: string): Promise<{ ok: true } | EvalErr> {
+ return post("/session/project", { id }, async () => ({}));
+}
+
// createProject git-initializes a new project directory under the root, scaffolds a
// minimal module, and makes one initial commit, returning its id. A name that
// collides with an existing project comes back as an error result (HTTP 409).
diff --git a/frontend/src/components/InspectorPanel.vue b/frontend/src/components/InspectorPanel.vue
index 72f64f3..c3b204e 100644
--- a/frontend/src/components/InspectorPanel.vue
+++ b/frontend/src/components/InspectorPanel.vue
@@ -14,16 +14,18 @@ import { ref, watch } from "vue";
import AnalysisPanel from "./AnalysisPanel.vue";
import ElementInspector from "./ElementInspector.vue";
import HistoryPanel from "./HistoryPanel.vue";
+import KnowledgePanel from "./KnowledgePanel.vue";
import { useDiagramCanvas } from "../composables/useDiagramCanvas";
import { useHighlight } from "../composables/useHighlight";
-type Tab = "analysis" | "inspector" | "history";
+type Tab = "analysis" | "knowledge" | "inspector" | "history";
const tab = ref("analysis");
const { clearHighlight } = useHighlight();
const { selectedElementId } = useDiagramCanvas();
const tabs: { id: Tab; label: string }[] = [
{ id: "analysis", label: "Analysis" },
+ { id: "knowledge", label: "Knowledge" },
{ id: "inspector", label: "Inspector" },
{ id: "history", label: "History" },
];
@@ -65,6 +67,7 @@ watch(selectedElementId, (id, prev) => {
{{ name }}: {{ field.type }}? → {{ field.relation.domain }}
+
+
Named evals
{{ item.name }} — {{ item.description }}
+
+
+
diff --git a/frontend/src/composables/useProjects.ts b/frontend/src/composables/useProjects.ts
index f26d021..e9cadd4 100644
--- a/frontend/src/composables/useProjects.ts
+++ b/frontend/src/composables/useProjects.ts
@@ -6,18 +6,17 @@
// The workspace projects: the list under the projects root (each a git repo plus a
// CUE module) and the open one. Module-level singleton like the other canvas
-// composables. The current id is mirrored into the URL (?project=) and localStorage
-// so a reload / shared link lands on the same project, set on the api layer so every
-// project-scoped request targets it, and switching reloads the canvas from the
-// project's files.
+// composables. The current project is resolved server-side (GET /session): the
+// persisted selection when it still exists, else the only project, else none.
+// The URL (?project=) still wins for shareable links, and every open persists
+// the choice back (POST /session/project), the same state `cueto use` writes,
+// so browsers and the CLI agree without any client-side storage.
import { computed, ref } from "vue";
import type { EvalErr, ProjectMeta, ProjectOk } from "../api";
-import { createProject as apiCreate, listProjects, setProject } from "../api";
+import { createProject as apiCreate, getSession, listProjects, setProject, setSessionProject } from "../api";
import { loadProject } from "./useCueSync";
-const STORAGE_KEY = "cueto.currentProject";
-
// The projects (sorted by id) and the id of the open one. currentProjectId is
// exported at module level so other composables can read it without a circular
// use*() call (same pattern as useEditorFiles).
@@ -39,17 +38,18 @@ export const currentProject = computed(
() => projects.value.find((p) => p.id === currentProjectId.value) ?? null,
);
-// The desired project id from the URL query, else localStorage, else null.
+// The desired project id from the URL query (a shared or reloaded link).
function preferredProjectId(): string | null {
- const fromUrl = new URLSearchParams(window.location.search).get("project");
- return fromUrl || localStorage.getItem(STORAGE_KEY);
+ return new URLSearchParams(window.location.search).get("project");
}
-// Persist the current id to localStorage and reflect it in the URL (without a
-// navigation), so the address bar is shareable and a reload lands on it.
+// Persist the current id server-side and reflect it in the URL (without a
+// navigation), so the address bar is shareable and a reload lands on it. The
+// server write is best-effort: on failure the next bootstrap simply resolves
+// without it.
function persistCurrent(): void {
if (!currentProjectId.value) return;
- localStorage.setItem(STORAGE_KEY, currentProjectId.value);
+ void setSessionProject(currentProjectId.value);
const url = new URL(window.location.href);
url.searchParams.set("project", currentProjectId.value);
window.history.replaceState(null, "", url);
@@ -80,17 +80,19 @@ function leaveHome(): void {
atHome.value = false;
}
-// Bootstrap on app mount: load the list, then open the previously chosen project
-// (URL/localStorage) if it still exists. With no saved pick - or one that no longer
-// resolves - nothing is opened and the onboarding view takes over, listing the
-// projects to load or offering to create one. `ready` flips once the pick (if any)
-// has loaded, so the shell is never shown before its project is in.
+// Bootstrap on app mount: one GET /session returns the projects and the
+// server-resolved current project. A ?project= in the URL wins when it still
+// exists (shared links stay shareable). With nothing resolved - multiple
+// projects and no selection - nothing is opened and the onboarding view takes
+// over. `ready` flips once the pick (if any) has loaded, so the shell is never
+// shown before its project is in.
async function init(): Promise {
- await refresh();
- const preferred = preferredProjectId();
+ const session = await getSession();
+ projects.value = session.ok ? session.projects : [];
const known = (id: string | null): id is string =>
!!id && projects.value.some((p) => p.id === id);
- const target = known(preferred) ? preferred : "";
+ const preferred = preferredProjectId();
+ const target = known(preferred) ? preferred : session.ok && known(session.currentProject) ? session.currentProject : "";
if (target) await open(target);
ready.value = true;
}