Contributor-facing material for anyone extending the dev-loop surface, the federation wrap, or the native-runtime packaging. For the consumer-facing inner loop (how to run it, connect an editor, and connect an agent), see How-to: The dev loop and How-to: Agent context over MCP.

Dev loop: how the goal is wired internally

The dev goal runs five cooperating components in one JVM:

  • LSP server binds the TCP port (default 8487) and speaks LSP to whatever editor or agent connects. It serves diagnostics, hover, completion, go-to-definition, and find-references off the most recent classified GraphitronSchema and the validator’s report on it. State is in-memory only; no LSP cache lives on disk. Its didSave notification is the primary fast path into the generator dispatch when an editor is attached.

  • MCP server binds a second loopback port (8488) and speaks the Model Context Protocol over Streamable HTTP to an MCP-aware agent. It serves the handshake instructions string, an about prompt, a directives resource, and a set of read-only tools that answer from the session’s fact store: catalog discovery (catalog.tables, catalog.describe, and the semantic catalog.search), schema, code for the @service / @condition / @record bindings, diagnostics and diagnostics.aggregate, status, and docs.search over the bundled manual. What the dev loop hands it is the session’s store handle and reader, so it reads what the latest pass captured and never feeds the generator dispatch; the connection is read-only. It lives in its own graphitron-mcp module so its heavy native (semantic-index) dependencies stay off the plugin’s own compile surface, and that module compiles against the store’s schema and jOOQ alone. Hosted in embedded Jetty; the literal 8488 is pinned by a DevMojoTest assertion on DevMojo.DEFAULT_MCP_PORT.

  • Schema watcher is a WatchService over the consumer’s .graphqls source roots; the headless fallback when no editor is attached. On a save, it debounces same-file events that arrive in clusters (some editors emit several MODIFY events per save) and signals the generator dispatch. The LSP didSave path feeds the same debounce, so the two routes coalesce on a single regen when both fire.

  • Classpath watcher is a WatchService over the consumer’s compiled jOOQ output (target/classes/<jooqPackage>/). When mvn compile in another terminal lands new .class files for jOOQ tables and columns, the watcher signals the generator dispatch the same way a schema save does. This is what lets a jOOQ schema regen pick up automatically without a dev-session restart.

  • Generator dispatch is the lifecycle thread that consumes wake-up events from either watcher, loads the (possibly-updated) classpath, runs the generator over the (possibly-updated) .graphqls sources, and feeds the resulting GraphitronSchema and emit results to the LSP server’s in-memory state and to disk via the idempotent writer.

The session mints four store readers, not one, and each states a ReadBudget the compiler forces it to state. Three go to the LSP behind one StoreAccess: an interactive one for the cursor grain (answering, every surface an editor blocks a cursor on), an annotation one for the surfaces that answer about a region of a document rather than a coordinate in it (annotating, which is inlay hints), and a session-wide one with a larger budget for the whole-workspace diagnostics drain and the directive-vocabulary load (answeringAll and readingSessionGraph). The fourth goes to the MCP server at turn scale.

The split is by who is waiting, not by what budget they wait under: a reader serializes its reads, so sharing one queues a hover behind whatever is already running for no better reason than sharing a connection. The annotation reader is the case that makes the distinction concrete, since it carries the same budget as the interactive one and is a separate reader anyway. Its work scales with the region an editor happens to be showing rather than with the cursor, an editor reissues it on every scroll, and nobody is looking at a hint the way they are looking at a jump; over a large schema with every hint axis enabled such a request can spend the whole interactive budget and be aborted, and before it had a reader of its own every hover and jump queued behind it waited that out. So when adding a surface, pick the door by asking who is blocked on the answer, and read StoreAccess’s class javadoc for the reasoning in full. A statement that overruns its budget is aborted by the database and arrives as a `StoreAnswer.OutOfBudget arm, which each surface answers in an exhaustive switch: interactive surfaces keep what they were showing, the drain publishes nothing at all rather than an empty list that would clear the developer’s warnings, the vocabulary keeps its last good value, and an MCP tool fails the call rather than returning an empty result an agent would read as absence. The budgets bound a statement, not a request; where a request’s cost matters the bound is the budget times the statement count the *StatementCountTest tier pins.

One JVM, two loopback ports (LSP and MCP), one process tree. There’s no daemon, no client/server split, no shared cache directory. The Mojo binds, watches, regenerates, and serves; on Ctrl+C the JVM shutdown hook closes the LSP socket, the MCP server, the WatchService instances, and the debounce executor cleanly.

The idempotent-write coupling is what makes the loop usable as an editor backend. Because JavaFile.writeToPath writes only files whose rendered content actually changed (SHA-256 comparison) and deletes orphans in rewrite-owned sub-packages, a schema edit that touches one type rewrites that type’s files and leaves every other generated file byte-identical on disk. The IDE’s incremental compiler, Quarkus quarkus:dev, and Spring Boot DevTools all detect changes by mtime; unchanged files keep their mtimes, so only the actually-changed files trigger an IDE recompile. Two effects fall out: editor latency is proportional to the edit (not to the schema size), and git diff after a dev session shows only what a human-readable summary of the schema edit would predict.

flowchart TD
    subgraph JVM["dev JVM (one process)"]
        Editor["editor / agent"] -. LSP/TCP :8487 .-> LSP["LSP server"]
        Editor -. MCP/HTTP :8488 .-> MCP["MCP server<br/>(read-only tools +<br/>about prompt)"]
        LSP -- didSave<br/>(primary) --> Disp["generator dispatch"]
        SW["schema watcher<br/>(.graphqls,<br/>headless fallback)"] -- debounced<br/>save events --> Disp
        CW["classpath watcher<br/>(target/classes/&lt;jooq&gt;)"] -- .class change<br/>events --> Disp
        Disp -- run --> Gen["generator<br/>(classify + emit)"]
        Gen -- in-memory<br/>GraphitronSchema --> LSP
        Gen -- emit results --> Writer["JavaFile.writeToPath<br/>(idempotent + orphan sweep)"]
        Writer -- only-changed<br/>files written --> Sources["target/generated-sources/graphitron"]
    end
    Sources -- mtime change --> IDE["IDE recompile<br/>(IntelliJ / Quarkus / DevTools)"]
Multi-module projects

Running mvn graphitron:dev from inside a sub-module of a multi-module build (for example cd opptak-subgraph && mvn graphitron:dev, for faster startup and scoped logs) picks up the @service / @condition / @record classes declared in sibling modules automatically: completion, hover, go-to-definition, find-references, and the unknown-class diagnostics all resolve against them. Graphitron reads the parent pom’s <modules> list to find the siblings, so this works for the standard reactor layout without running from the aggregator.

The one caveat: a sibling must have been compiled at least once, so its target/classes exists on disk. graphitron:dev does not build siblings for you; run mvn install (or mvn -pl <sibling> compile) once after a checkout. If the dev goal reports that it resolved a single module and found no siblings, check that the parent pom’s <modules> lists the module you started from.

Federation: how the wrap is wired

@link is the opt-in. Graphitron.buildSchema(…​) checks the parsed SDL for an @link to a federation spec; if present, it routes through federation-graphql-java-support to wrap the schema with the _Service.sdl field, the _Entity union, and the _entities resolver. If absent, the build skips the wrap entirely and emits a vanilla schema. A consumer who wants federation just adds the @link; a consumer who doesn’t gets no federation surface, no _entities, no _Service. The opt-in is the SDL declaration, nothing else.

<schemaInput tag> is the second federation entry point. It exists because @tag(name: "…​") directives are only meaningful to a federation gateway, and a consumer setting tag values has implicitly committed to federation 2. The plugin synthesises an @link with import: ["@tag"] if none was declared, fails the build if @link is declared but "@tag" is missing from import, and stays out of the way if neither is set. The decision lives in the plugin so it’s visible at build configuration; the runtime never sees the synthesis logic.

Graphitron.buildSchema does the wrap, not the consumer. A second Federation.transform(…​) call by the consumer would double-add _Service and _Entity and break composition; the contract is the consumer never wraps. The two-arg form takes a federation customizer (fed → fed.fetchEntities(…​)) for cases where a consumer needs to override fetchEntities for hand-rolled entity types.

The fetchEntities seam lives where it does because the default fetcher only knows about Graphitron-classified types: @node types resolve via the NodeId path, types with a @key directive resolve via column-value lookup, and both share the same per-type batched SELECT. Anything outside that classification surface (hand-rolled objects, types from a non-Graphitron source) needs a custom fetcher; the customizer lets the consumer plug one in without touching `buildSchema’s wiring.

For the consumer-facing federation transport (the @link declaration, the two-arg buildSchema customizer, the don’t-double-wrap rule), see How-to: Apollo Federation transport.

sequenceDiagram
    participant SDL as .graphqls SDL
    participant Build as Graphitron.buildSchema
    participant Wrap as federation-graphql-java-support
    participant Engine as graphql-java engine
    participant Fetch as _entities resolver
    participant DB as PostgreSQL

    SDL->>Build: parse + classify
    alt @link present
        Build->>Wrap: wrap(schema, fetchEntities)
        Wrap-->>Build: federation-wrapped schema<br/>(adds _Service.sdl, _entities)
    else no @link
        Build-->>Engine: vanilla schema (no federation surface)
    end
    Build-->>Engine: ready
    Engine->>Fetch: _entities([{__typename, key…}, …])
    alt @node type
        Fetch->>DB: SELECT by NodeId-decoded keys (batched per type)
    else @key type
        Fetch->>DB: SELECT by key-column tuples (batched per type)
    else custom fetcher (fed.fetchEntities)
        Fetch->>Fetch: consumer-supplied resolver
    end
    DB-->>Fetch: rows
    Fetch-->>Engine: typed results

Native runtime dependency

There isn’t one. mvn graphitron:dev runs an LSP server backed by a tree-sitter-based GraphQL parser whose two native pieces, the tree_sitter_graphql grammar and the libtree-sitter runtime, both ship in the no.sikt:graphitron-tree-sitter-natives jar and are extracted at startup. You do not install anything: no brew install, no vcpkg install, no from-source build on Debian/Ubuntu, and no LD_LIBRARY_PATH / JAVA_TOOL_OPTIONS wiring (NixOS included).

Supported host architectures are linux-x86_64, linux-aarch64, macos-aarch64, and windows-x86_64. Intel-Mac (macos-x86_64) is not shipped; on an unsupported host the LSP fails fast at startup naming the os.name / os.arch it saw and the supported set.

If the bundled runtime fails to load after extraction (a noexec java.io.tmpdir, a corrupt extract, or a missing system C runtime), the LSP surfaces a single startup error naming the extracted path rather than an opaque UnsatisfiedLinkError. See LSP requirements for the startup-diagnostics detail.

Tracing the LSP request path

When an editor session stops responding, LspTrace attributes the wall clock to a named phase. It is off by default and allocates no span while off, so it sits directly on per-keystroke paths.

Turn it on with a system property or an environment variable:

mvn graphitron:dev -Dgraphitron.lsp.trace=true

# or, when the editor spawns the server and you cannot add a flag
GRAPHITRON_LSP_TRACE=true

An editor can also flip it mid-session by sending lsp4j’s $/setTrace with messages or verbose, which is the route when the session is already misbehaving and you would rather not restart it and lose the state that provoked it. Note the asymmetry: $/setTrace off does stop tracing, but the trace value on the initialize handshake can only turn tracing on. Most clients send trace: off there as boilerplate whether or not anyone asked, and honouring that would silence a deliberately-set graphitron.lsp.trace before a single phase had been traced.

Prefer the file sink when chasing a hang

Send output to a file:

mvn graphitron:dev -Dgraphitron.lsp.trace=true \
                   -Dgraphitron.lsp.trace.file=/tmp/lsp-trace.log

Stderr is the default and is fine for ordinary "why is this slow" work, where it has the advantage that mainstream clients surface a language server’s stderr in an editor output panel. For a hang, use the file. Writes are synchronous, so a sink whose reader has stopped draining blocks the thread that is emitting, and two of the instrumented sites (file.reparse and file.typeIndex) emit while holding the workspace mutator lock. A client that spawns the server and never reads its stderr can therefore fill the pipe buffer and stall the server inside its critical section, which is the instrument manufacturing the symptom you are trying to attribute. A file never blocks that way.

The synchronous write is deliberate rather than an oversight: what reaches the sink is what happened right up to a kill, and an asynchronous drain would lose the tail exactly when the tail is the evidence.

Output never goes to stdout under any configuration, and never through slf4j: the stdio launcher speaks JSON-RPC on stdout, so a stray byte would desynchronise the framing, and in that deployment the classpath usually carries slf4j-api with no backend bound so a logger-based seam would emit nothing at all. It also does not go back over the connection via window/logMessage, which would be the editor-visible channel: that would route the diagnosis through the very channel under suspicion, serialised behind every other response and emitted from inside the workspace lock.

Phases slower than graphitron.lsp.trace.slowMs (default 100) are tagged SLOW.

Reading a trace

Each phase emits an open line and a close line, so a phase that never returns shows up as an unmatched >. That is the point: it separates "stuck here" from "slow everywhere", which a duration-only format cannot. Every line is stamped with the time of day, which is what lets an unmatched > be lined up against the moment the editor froze, against the editor’s own log, or against a build swap in another window. A one-off header line carries the date, the resolved threshold and the pid, so a file read days later describes its own provenance.

14:32:07.301 lsp-trace header date=2026-08-03 slowMs=100 pid=48213
14:32:07.412 lsp-trace > 6 workspace.mutate thread=graphitron-dev-conn-1
14:32:07.412 lsp-trace > 7   file.reparse thread=graphitron-dev-conn-1
14:32:07.413 lsp-trace < 7   file.reparse 1.1ms thread=graphitron-dev-conn-1 bytes=23781
14:32:07.414 lsp-trace > 8   file.typeIndex thread=graphitron-dev-conn-1
14:32:07.428 lsp-trace < 8   file.typeIndex 13.8ms thread=graphitron-dev-conn-1 bytes=23781 declared=400
14:32:07.434 lsp-trace < 6 workspace.mutate 21.9ms thread=graphitron-dev-conn-1 open=1 queued=1

Three things to read off a trace:

Signal What it means

thread= repeated across a request and a notification

Both ran on the same lsp4j thread. While a notification’s phases run, the server is not reading the connection, so client requests queue and can time out rather than merely being slow. The diagnostics drain is exempt by construction: it runs on its own graphitron-lsp-diagnostics-drain thread, so its spans never carry the connection thread and its cost never blocks the reader.

workspace.notify visibly above zero

The recalculate listener has stopped being a flag-and-submit. It hands the drain to the connection’s drain executor and returns, so notify should be trivially small; the drain’s real cost is the publishDiagnostics.drain span on the graphitron-lsp-diagnostics-drain thread. Check queued= on the mutate line and files= on that drain span.

workspace.snapshot far above zero

A read request waited on the mutator lock, so something else is holding it.

The diagnostics walk is deliberately one span for the whole directive loop rather than one per directive, which would bury the log under hundreds of lines per file. To get at the per-site cost, subtract diagnostics.validatorReport from its enclosing diagnostics.compute and divide by the directives= count on the same line.