docsLanguage

MemQL Sense & the DSL Spec

MemQL Sense is the language-intelligence service for .memql files: tokenize (syntax highlighting), complete (context-aware autocompletion), diagnose (errors and warnings), hover (symbol info), signature help, and go-to-definition. It is what colors and assists the source you read in the MemQL VS Code extension.

Sense has one consumer today and two delivery paths into it:

  1. The VS Code extension (see MemQL in VS Code), over an offline language server (cmd/memql-lsp) that embeds this package and reads .memql from disk with no cluster and no auth. This is the normal path.
  2. The gRPC surface on MemqlService.Stream, which serves the same brain to a client holding a live cluster connection.

Both drive the same package -- a delivery mechanism is not a fork of the brain, and neither path changed a wire contract.

The Cockpit Editor was the second consumer until the Cockpit's TUI was removed (memql#4550). The gRPC surface stayed: it is the contract, not the caller, and removing it because its only caller went away would delete the path a future one would need.

This document covers two things and how they relate:

  1. The DSL spec (dslspec) -- the single machine-readable source of truth for the MemQL authoring surface, from which Sense is driven and which any editor can fetch as portable JSON.
  2. MemQL Sense itself -- the language operations, how their completion / hover / diagnose behaviour is projected from the spec, and the CI drift guard that keeps the spec honest against the live grammar.

The DSL spec: one source of truth

component/language/dslspec is the single machine-readable source of truth for the MemQL DSL authoring surface:

  • the top-level constructs an author may write (concept, query, mutation, logic, automation, action, capability, spec, trait, shape, tool, prompt, provider, builtin, policy, rule, seed, use), each tagged with its category, its body sub-blocks, and whether its signature names a bound concept;
  • the keywords, operators, and field types the grammar accepts;
  • the annotations each construct allows;
  • the legal-next rules that drive context-aware completion.

Why it exists

Before dslspec, Sense carried its completion / hover / diagnose tables as hand-maintained Go literals. They drifted from the grammar with nothing to catch it -- they still listed the retired has operator, the deprecated array(T) field type, and the receiver-function model, while omitting live struct-form constructs (logic / trait / policy / seed). dslspec is the durable fix: ONE spec that Sense is generated and driven from, that a CI drift test pins against the parser and annotation registry, and that exports as portable JSON for any editor.

Source-of-truth boundaries

The spec is deliberately split on where each fact already lives:

  • Annotations and their per-construct legality are DERIVED, not re-listed. They come from component/language/annotations -- the registry that already backs the load-time gate. dslspec inverts that registry into a per-annotation view (name to doc plus the construct keywords it is legal on), so the spec cannot disagree with the engine's own enforcement.
  • Operators and the expression functions are projected from the function catalog (component/language/functions): each operator row of functions.Operators() -- its form, what it does, its absence rule and its precedence level -- and each function and method of functions.Catalog(), with its signature and tier. Sense's hover cards and the generated docs read the same two tables, so an operator or a function is described once.
  • Constructs, keywords, field types, and legal-next rules have no pre-existing registry -- the truth was split across the parser's top-level dispatch and the struct-form rewriter. dslspec is the source of truth for those, and a drift test introspects the parser and rewriter to assert the spec stays in lockstep.

The package is a near-leaf: it imports only the annotations registry and the function catalog, so both Sense and the gRPC/SDK export layer can consume it without an import cycle.

The workspace graph (cross-reference resolution)

The spec answers "what is legal here" from the grammar; it cannot answer "does fylo.concepts resolve" or "is order declared there". Those are cross-reference questions about the whole .memql tree, and Sense answers them from a second source: a workspace graph built from the dslimports tree -- the same file/tree resolution memqllint uses -- and handed to the service alongside the registry (sense.WorkspaceGraph; adapter in component/memql/workspace_graph.go, built by BuildOfflineSense). The registry is a flat vocabulary (concept/function/shape names); the graph is structured (namespaces, kinds, per-module declared ids) -- the input import and reference diagnostics and segment-aware use-line completion consume.

Every graph answer is tri-state (ResolvedYes / ResolvedNo / ResolvedUnknown). The third state is load-bearing: a product bundle imports engine namespaces it does not carry (common, platform, identity), and the edit-path registry can be stale between saves, so a fact the workspace cannot prove is Unknown and callers stay silent rather than emit a false squiggle -- mirroring the load-side verifier's own conservatism (dslimports.missingIsProvable). The graph is built from the file tree independently of engine boot, so it survives a workspace with a broken reference -- exactly when reference resolution is needed most.


MemQL Sense: the language operations

Sense is exposed via gRPC on MemqlService.Stream. The core is pure Go (component/memql/sense/, no gRPC dependency); the gRPC handlers live in component/grpc/sense_handlers.go.

OperationWhat it returns
TokenizeSemantic tokens for syntax highlighting -- keywords, identifiers, strings, annotations, concept ids, and the operators (||, .? and => each one token)
CompleteContext-aware autocompletion -- constructs, annotations, concepts, builtins, keywords; at an expression position, exactly what the tier manifest admits there (see Expressions)
DiagnoseErrors and warnings from the lexer, parser, and semantic validation. A retired spelling is a parser error whose code is the parser's rule id (retired_cond_call), which the language server's rewrite quick fix keys on. Given the document's path in the tree, the engine's load of the document as well: load refusals, each coded with its lower_* rule id
HoverSymbol info at the cursor -- function docs, concept schemas, annotation docs, tool and prompt docs, and cards for operators, catalog functions and retired spellings that say where the item runs at the cursor's position. Resolves a BARE concept short name too (#2753): candidate in shape candidate candidateFull is ambient under rule 25, so it is matched by trailing segment against the registry. A collision across namespaces (invocation is both v1:worker:invocation and v1:observability:invocation) is broken by the document's own domain; where that cannot decide, hover returns nothing rather than the wrong concept. The domain comes from the document path, carried by SenseHoverMsg.file_path on the gRPC surface (#2760) and by the document URI over LSP
SignatureHelpParameter help inside call arguments, from the function catalog first, then the builtins and the registry's declared args
DefinitionGo-to-definition (F12) -- resolves the construct reference under the cursor to the file and position that declares it (#2754). Backed by dslimports.Index.DeclarationSites, which finds the declaring file from the declaration index and recovers the line/column by re-lexing that file's raw source (the AST carries no positions). Colliding names are narrowed by the referencing file's own domain, and where that cannot decide it returns nothing rather than jumping to the wrong file. Exposed on both surfaces: textDocument/definition over LSP and SenseDefinitionMsg / SenseDefinitionResult over gRPC (#2760). The result carries a WORKSPACE-RELATIVE path, never a URI -- the LSP maps it to file://, and a gRPC client resolves it against however it addresses the tree, so the wire stays neutral between the two delivery paths

Driven from the spec

Sense's completion no longer hardcodes its tables. At startup it builds the spec once (dslspec.Build()) and projects it into the lookup shapes completion needs:

  • Top-level construct completion is the spec's construct list. Typing mut at the file top now offers mutation; the full struct-form set (logic / trait / policy / seed) is present because the spec carries it. This replaced the stale hand-coded func / use / concept set.
  • Annotation completion filters by the receiver the cursor sits in, using the same registry projection the load-time gate enforces -- so Sense never offers an annotation the engine would reject.

Because both the keyword set and the import behaviour are read from the spec rather than hardcoded, the drift test keeps them honest.

Context-aware completion

Two behaviours are worth calling out because they come straight from the spec's ConceptInSignature flag and SuggestImportWhenMissing legal-next rule:

  • Concept-after-construct. Concept-binding constructs name their bound concept in the signature: mutation <Concept> <name>, query <Concept> <name>, seed <Concept> <name>, and the @row form of shape <Concept> <name>. Right after the keyword, completion suggests a concept (filtered by the partial prefix), with registry-known concepts offered first.
  • Import suggestion. When the spec's legal-next rule for that construct sets SuggestImportWhenMissing, any matching concept that is NOT already in file scope (no use <domain>.concepts.{ Concept } line in the source) ALSO gets an "import" completion whose insert text prepends the missing use ...concepts.{ Concept } line -- so authoring a fresh file pulls the concept into scope in one keystroke. Concepts already imported are suppressed from the import set (the bare concept suggestion stands).
  • Segment-aware use line (#2732). An import is use <ns>.<kind>.{ id, ... }, and each segment now completes against the workspace graph: after use the namespaces; after use <ns>. the module kinds (concepts / queries / mutations / ...); inside the brace list the ids that module declares, minus the ids already listed. This replaces the old behaviour where typing fylo. fell through to the offer-everything body bucket and dumped the whole symbol table -- the classifier now claims the braced id list before it can be mistaken for a function body. Backed by the graph, so it needs no registry.
  • Kind-filtered invocation (#2733). In a behavioral body an invocation is <verb> <name>(...) where the verb -- query / mutation / logic -- names the kind it can bind. Completing the name now offers only functions of that kind (a query <name> position lists queries, never mutations, logic, or tools), instead of dumping the whole registry. The verb requires body depth, so the top-level query <Concept> <name> declaration is unaffected.
  • Reference positions that name a construct (#2740). A concept's @relationship(... target=<cursor>) offers concepts, in the form the slot uses: the common bare target=user binds the SHORT name, and the quoted target="v1:ns:leaf" binds the canonical id -- and only at the target= value, never at type= / field= / direction=. Both are matched from the raw text before tokenizing (the quoted form's unterminated string zeroes the lexer's tokens), confined to a single well-formed annotation call so an unclosed @relationship( elsewhere cannot poison a later target=.

Expressions: position-aware completion and hover

Every expression in a .memql file sits in one of the twelve positions the tier manifest names (component/language/tiers), and the position decides what the expression may contain: a query filter is pushed down to SQL, a logic body runs in process (see Where each expression runs). Sense works out the position under the cursor, then completes and hovers from the manifest and the function catalog (component/language/functions), so the editor offers what the load accepts there and nothing it refuses.

Finding the position

Sense reads the position from the source text, not from a parse (component/memql/sense/exprpos.go): a buffer is mid-edit and unparseable most of the time, and a detector that needed a parse would go quiet exactly while an author types.

PositionThe cursor is in
queryFiltera query's filter row => ... clause and its continuation lines, or the query of a tool's @handler(query="...")
queryRefinea query's refine row => ... clause
sorta query's sort clause
specBodythe body of spec <bound> <name> = row => ... or trait <name> = row => ...
triggerFilteran automation's @filter(row => ...)
rowAuthzArgumentan argument of @rowAuthz(...)
toolDefaulta tool field's @default(...)
promptInputa prompt field's @default(...)
automationConditionan if, else if, for or switch head in an automation, or a precondition's check:
stepArgumentthe arguments of a kind-prefixed call in an automation (mutation createTodo(title: ...)), or a name := ... statement there
logicBodya statement in a logic body
mutationValuea key: <value> line in a mutation's insert, update or stamp block

A line continues a clause when a bracket opened above is still open, when it opens with a binary operator (&&, ||, ==, ?, :, ...), or when the line above ended on one.

With the position come the lambda parameters in scope and the concept the position's parameter reads. filter row => and refine row => bind row to the query's concept; a spec binds the concept or shape in its signature, and over an @actor shape its parameter is spelled actor and is the envelope; @filter(row => binds row to the concept named by the automation's @trigger(concept="...") or by its graph.node.* event topic. The parameter is whatever name the author wrote. A lambda opened inside the expression -- row.tags.any(t => ... -- puts its parameter in scope until the bracket it sits in closes. The pre-v1 spellings of the pushdown positions (a filter with no lambda header, a filter { } block, a spec ... { return ... } body) are detected too. The parser refuses each of them: completion offers a filter with no header its header and nothing else, and hover shows the construct as the rewrite writes it.

Completion

At an expression position completion offers what the manifest admits there, and nothing else. The three literal positions -- a sort key, a row-authz argument, a tool default -- keep the annotation's or clause's own completion, since each takes a literal. Everywhere else the list is:

  • The lambda parameters in scope, innermost first, then the names that statements above the cursor bound in the same body (rows := query activeUsers(status: "active")), whose detail is local.
  • The reserved roots the position evaluates with: args, actor, now and config in a query filter, a refine clause, a logic body and a mutation value; now in a spec body; the run's event as well in an automation's conditions and call arguments (compiler.IsBodyRoot is the list a body binds). In a trigger filter args is the automation's declared args, bound from the triggering event's payload before the filter runs, and args. completes the args of the automation the annotation decorates -- declared below the cursor.
  • The catalog functions the position admits. In a query filter or a spec body an in-process function is still offered, because row.expiresAt < addDuration(now, "P1D") is legal, but its detail reads "Computes before the query; cannot read the row" and it sorts last.
  • Specs and traits, applied to the receiver they read: isActiveRecord(row), requiresOwner(actor). A row spec bound to a different concept than the position's is not offered.
  • nil, true and false; the construct-call kinds only where a statement's whole value starts (x := , return ), since a call nested in an expression is refused. Where a statement starts, the completer for statements answers instead: the words that open one (if, for, switch, parallel, return, and publish in an automation), each construct the body may call inserted with its kind, and, before the first statement, the construct's args block and an automation's preconditions. After a statement on its line it offers what may follow it: else after an if's block, wait any after a parallel's, and on surface(...), retry(n) and on error continue after the statements that take them.

After a dot, completion offers members. row. offers the bound concept's fields, each with its type, then the row intrinsics (id, createdAt, ...); actor. in an actor spec offers the envelope. row.tags. offers the methods of the field's type -- in a query filter or a spec body only the ones that push down (any, all, count on a list), because an in-process method over the row is a load refusal. args.tags. offers every method the position admits, since an arg does not read the row.

A clause whose value is a lambda -- a query's filter and refine, a trigger @filter -- offers its header, row => ..., and nothing else until the header is written: the parser refuses every other spelling of the clause, so no bare field name, payload. member or bare row. intrinsic is offered in its place. Past where the header goes, such a clause offers nothing (filter status == |). Once the header is written, the list above applies.

No retired spelling is ever offered: cond, concat and coalesce do not reach the list anywhere, and neither does payload., which offers nothing.

Hover

Hover over an operator, a catalog function or method, a spec or trait applied in an expression, or a retired spelling shows a card with one shape, plain text in sentence case:

  1. A memql code block with the signature (lower(value string) string), the operator in use (a ?? b), the application (isActiveRecord(row) bool, requiresOwner(actor) bool), or -- for a retired spelling -- its replacement.
  2. One sentence saying what it does: the first sentence of the catalog entry or of the spec's description, or the operator's description followed by its absence rule.
  3. Where it runs at the cursor's position: "Pushed down to SQL.", "Runs in process.", "Runs in process before the query, on values that do not read the row.", or, in a refine clause, "Runs in process, over the rows of the page the query read."
  4. A legality line, only when the position refuses or restricts the item, naming the fix in one sentence: "Not allowed on the row in a query filter: lower runs in process. Compare the row against a computed value: row.email == lower(args.email)." A call whose arguments demonstrably do not read the row is a plan constant, legal where it stands, and gets no legality line.
  5. For an entry that replaces retired spellings, the ones it replaces: "Replaces len(x) and count(x)." A spec or trait replaces its bare name and its spec <name> / trait <name> reference.

A spec or trait's card says where the application runs: a row predicate in a filter or spec body is compiled into the SQL, a spec over the actor is decided in process from the caller before the query, and every in-process position runs both in process. The catalog is consulted before the registry, as the evaluator does, so a spec never takes a function's card.

Operator cards come from the one operator table, functions.Operators(). The same glyphs outside an expression keep their own meaning and get no operator card: in in a for head is the loop keyword, and a map key's : is not the conditional's.

A retired spelling's card reads the parser's own refusal table, parser.V1RetiredForms(), by its stable rule ids, so the hover and the load refusal cannot say different things: "cond(p, a, b) is retired in edition 2026. Write p ? a : b instead; memqlmigrate --rewrite=expressions rewrites it." when(, ?., ; as a connective, has, not in, null, $args.x, spec <name> inside an expression, .contains(...) and every retired function called by name are hovered this way. contains with one argument is the live traversal and count alone on a line is the query's count clause, so neither is flagged. TestRetiredHoverIsTheParsersTable fails when the parser retires a form Sense neither hovers nor names as unhoverable; the comma connective is the one it names, because a comma also separates arguments.

The predicate positions' legacy spellings -- a filter with no lambda header, a spec or trait with a { return ... } body, an @filter whose argument is not a lambda -- are hovered on the filter keyword, the body's return or the spec / trait keyword of its header, and @filter. Their card shows the author's own construct as memqlmigrate --rewrite=expressions would write it, because Sense runs that rewrite over the construct under the cursor: filter status == args.owner && isActiveRecord shows filter row => row.status == args.owner && isActiveRecord(row). Where the rewrite refuses the construct, the card shows the table's form.

Every retired spelling is also a parse error, which Diagnose reports with the parser's rule id as its code, so the hover explains the squiggle and the quick fix below removes it.

The rewrite quick fix

The language server (cmd/memql-lsp/codeaction.go) turns memqlmigrate --rewrite=expressions into two code actions:

  • Rewrite to edition 2026, a quick fix on a diagnostic the rewrite clears: a retired spelling, or another parse error that rewriting its construct removes.
  • Rewrite the file to edition 2026 (source.fixAll.memql), the same for every construct in the file. VS Code runs it on save when source.fixAll is in editor.codeActionsOnSave.

The edit is the one the codemod makes, against the specs and traits the workspace and the engine's core domains declare, with open buffers as they are rather than as saved. The unit is one construct with the annotations above it: a construct holding a clause the rewrite refuses, such as a relationship traversal, is offered nothing and keeps its diagnostic, and so is a construct whose rewrite would not parse. The edit replaces only the lines that change, so undo and the cursor behave as they do after a hand-made edit. The gRPC surface has no code action; a client there runs the codemod.

Signature help

Inside a call's arguments signature help reads the catalog first: one signature per function or method, including the relationship traversals, whose optional leading as label is marked optional. The highlighted parameter skips the label when the call leaves it out. Builtins the catalog does not describe come next, then the registry's queries, mutations and logic, each signature built from its declared args { }.


Edit-time diagnostics

Diagnose runs parse errors plus a set of authoring rules over the authored source. The load-mirroring rules carry stable code strings so editors and agents can key on them:

CodeSeverityRule
actor-unknown-propertyErroractor.<member> names something outside the closed envelope (#2625) -- same tables as the load-time gate, pinned by a conformance test.
actor-undeclaredErrorA query/mutation/logic/automation body reads actor.* without @actor in the preamble -- the edit-time mirror of the engine's load rule (#2621), sharing the loader's own detection so squiggle and boot error cannot drift.
unknown-import-moduleWarningA Form-B import use <ns>.<kind>.{ ... } whose kind segment names no module in a workspace-owned namespace (use fylo.concept.{...} where the module is concepts). Resolved against the workspace graph (#2730).
unknown-import-symbolWarningAn imported id that the resolved module does not declare (use fylo.concepts.{ oder }).
signature-binds-wrong-kindErrorA query/mutation/shape/seed signature binds a name that IS declared, just not as a concept -- shape todos ... where todos is a query (#2762). The sibling rule below only asks whether the name exists at all, so a wrong-kind binding sailed through and surfaced as a boot failure instead. An explicit import does NOT suppress it: importing the query is exactly how the author got here. A name the workspace has never seen is left to unknown-signature-concept, since it may arrive at runtime via MEMQL_DSL_PATH. spec is out of scope by construction -- it binds a shape XOR concept, and the extractor covers only the four concept-binding keywords. Measured over dsl/: 680 signature bindings, zero flagged
unknown-signature-conceptErrorA query/mutation/shape/seed <Concept> <name> whose bound concept exists nowhere and is not imported (mutation full ... with no concept full). Error, because boot itself CrashLoops on an unresolvable signature concept. Extracted with the boot-pinned regex (dslimports.SignatureConceptRefs) and resolved with the load side's own missingIsProvable conservatism, so an external or unimported-but-global concept is never flagged (#2731).
bare-row-intrinsicWarningA filter names a row intrinsic bare instead of through its parameter -- id on a continuation line of filter row => row.a == 1, where row.id was meant (#2779). The engine refuses the load for it (id is not defined here), and test/dslconformance/conformance_test.go fails CI on it; this rule is the edit-time half, which lands on the token and names row.id. A filter with no lambda header never reaches it: the parser refuses that first, and the rule runs only on a file that parses. Detection is the same sense.ScanBareRowIntrinsics the tree-wide gate calls, so squiggle and CI cannot disagree; it reads clause TEXT rather than parsed predicate structure, so ||-joined and parenthesized predicates are covered, and string-literal contents are excluded.
bare-row-intrinsic-sort-keyWarningA sort key names a row intrinsic bare (sort "createdAt", "desc") instead of through the row. namespace (#2786) -- the ordering half of the rule above, with the same ambiguity (sort "id" can name the row id or a payload property called id). The engine refuses it in an authored file at load, where both row. namespace gates run (memql#3629); the runtime and SDK sort surfaces still accept a bare key from a caller. Detection is the same sense.ScanBareRowIntrinsicSortKeys the tree-wide TestSortKeysUseRowNamespace calls. It is a SIBLING of the filter scanner, not a branch inside it: a filter names fields as code (so that scanner blanks string literals) while a sort names them as string literals, so this one reads literal contents. It opens a clause only on sort followed by a string literal, which keeps a construct field of the form sort string @enum("createdAt", ...) from being read as a sort clause; the whitespace skip is unicode-aware so it agrees with the rewriter's TrimSpace. It does NOT separate a provider params entry spelled sort "createdAt" from a directionless sort clause -- the two are byte-identical, and telling them apart needs enclosing-construct state the scanner does not carry.
redundant-enabledHint@enabled restates the default (#2610).
redundant-versionHint@version("1.0.0") restates the default (#2613).

There is no discarded-args-description rule any more (memql#3336). @description on an args field is now REJECTED by the parser, so a file carrying one never parses and the editor gets the load error itself through the parse-error path -- the same way every other rejected form surfaces (@default on an args field, @useConcept, func (Query)), none of which carries a bespoke rule either. The retired Hint said the annotation was "parsed and discarded", which stopped being true, and it lived in the semantic phase, which does not run when the parse fails.

The two import checks resolve against the workspace graph rather than the flat registry, and they run even in the registry-less fallback (a workspace whose engine boot tripped) because the graph needs no registry. They are deliberately conservative: a reference the workspace cannot prove wrong -- an id under an external engine namespace the bundle imports but does not carry, or anything when the graph is absent -- resolves inconclusively and is left silent, so a legitimate cross-namespace import never squiggles. That is why they are Warnings, not Errors.

Load refusals

Diagnose finds what lexing, parsing and rewriting find. What the load's lowering refuses -- a field the bound concept does not declare, a context spec applied to the row, a read through an optional object written with ., an in-process expression over its cost budget -- needs the engine's registries, so Sense runs it as a second pass, DiagnoseLoad, over the document's text (memql#5434). Each refusal is an Error on the node it refuses, its message is the refusal's own sentence (the node, the position, the reason and the fix), and its code is the stable rule id the language reference lists: lower_unknown_field, lower_context_spec_on_row, lower_optional_hop, lower_cost_over_budget, and the rest of the lower_* family. A code action keys on the code, never on the message.

What makes it the load's answer rather than a guess:

  • The document's path in the tree is required, relative to the DSL root (planner/queries.memql). It is what the load derives a construct's namespace from: a query's bare concept resolves through its own domain, a spec's binding through its own domain first. Without it a name two domains declare (account, run) binds to the wrong one or to none, and the pass would show errors the load never makes -- so a document with no path, or whose directory is no domain the engine loaded (an untitled buffer, a file outside the tree, _reference/), gets no pass at all.
  • The document replaces its file. The engine already holds the file as saved; the pass answers what the load would say were the buffer saved there. The buffer's concepts, shapes and specs take the place of the file's, and a spec the buffer no longer declares is gone. Unsaved text is what is loaded.
  • One squiggle per fault. Where a load refusal overlaps a diagnostic Diagnose already reports, the more severe of the two is kept, and Diagnose's on a tie: a bare id in a filter is Diagnose's bare-row-intrinsic warning and the load's lower_unknown_name error, and the error -- which names the same fix, row.id -- replaces the warning (sense.MergeLoadDiagnostics).
  • Only what Lower reads is compiled: concepts and shapes as the scope, and the queries, specs and traits that hold the lowered positions. A construct that fails before Lower sees it (a parse error, a concept that does not resolve) is Diagnose's to report or the load's, never this pass's, and one construct's parse error does not hide another's refusal. An automation's trigger filter is not covered: the automations loader re-parses the filter from compiled text and cannot say where in the author's file the refused node is, so those refusals stay with the load (memqllint, boot).

The pass costs the load of the document's constructs. Over the 320 files of this repository's tree the median is under a millisecond and the 90th percentile about 3 ms; the largest file of queries, identity/queries.memql, takes about 90 ms. So neither surface makes a keystroke wait on it:

  • The language server publishes Diagnose's diagnostics as it always did, then runs the load off the request path, one pass per document at a time, and publishes again with both when the pass answers for the text still in the buffer. Until then the last pass's refusals are carried across the edit -- one on a line the edit did not touch keeps its place, or moves with the lines after it, and one on an edited line is dropped rather than drawn on the wrong text -- so a squiggle does not blink off at every pause in typing.
  • The gRPC Diagnose runs the pass when SenseDiagnoseMsg.file_path carries the document's tree path, and replies once with both. A stream runs one pass at a time: a request that arrives while one runs waits behind it, a newer one takes its place, and the request it displaces is answered at once with Diagnose's diagnostics and no load refusals -- the newer request's pass answers for the document. The pass in flight and the one waiting stop when the stream ends. A client diagnosing on every keystroke may send no path and ask with it when the author pauses or saves (sdk/go/sense.Client.Diagnose takes the path as its third argument).

The offline build models the engine a node boots, storefront packs included (packs/anchor), so a product domain relating a concept to a pack's (use wholesale.concepts.{ application }) builds with its registry and its load pass instead of refusing every file.

Snippet completions (#2629)

Completion items can carry LSP snippet syntax. CompletionItem has an IsSnippet flag; the LSP layer maps it to InsertTextFormat=Snippet, and every item now declares its format explicitly -- snippet syntax sent WITHOUT the flag inserts literally, dollar signs visible in the buffer, which is why the flag exists rather than sniffing the text.

What ships as a snippet:

SnippetWhere
args { ... }, insert { ... }, ...Inside a construct body -- opens the block with the cursor inside, offered beside the plain block keyword.
filter row => row.field == "value"Inside a query body. A v1 filter is a clause whose value is a lambda over the row, not a block, so its snippet writes the header.
query / spec / trait / mutation / logic / automation / concept skeletonsTop level -- full declarations with tabstops at the names, sorted BELOW the bare construct keyword so they never displace it. The spec and trait skeletons are spec <Concept> <name> = row => ... and trait <name> = row => ....
@filter(...)Where the construct takes @filter -- inserts @filter(row => row.field == "value"), a lambda over the triggering row.
The use <domain>.concepts.{ X } importNow places the cursor after the bound name (it was multi-line plain text before -- correct, just cursor-less).

A Go consumer without snippet support calls PlainInsertText(), which collapses placeholders to their default text and drops tabstops. Generated snippet text escapes literal $, }, and \ so it cannot be misread as a tabstop or a placeholder terminator. The IsSnippet flag is mirrored in the proto envelope and the Go SDK, with a wire round-trip test.

Block-specific completion (#2628)

Inside a named block, completion offers THAT block's set. The block labels have been declared in nextrules.go since the table was written, but a label is not a detector -- a NextRule has zero effect until the classifier computes its label, which is what this closes:

BlockOffers
args { }Field types (string, bool, enum(...), ...). enum completes to the TYPE form (#2618); the ! required sigil is documented on the item rather than offered as one.
filter { } (pre-v1, refused at parse)Nothing. The parser reads the braces as a map literal and refuses a filter that is not a lambda, so every name the block once offered -- the reserved heads, the bound concept's bare fields -- is a spelling it refuses. A v1 filter is a clause, and completes as an expression position (see Expressions).
insert { } / update { }accept / stamp in the post-#2616 short form, plus the bound concept's fields.
shape { }The bound concept's fields.

The editor teaches the POST-epoch surface deliberately: offering the superseded long forms (@required, string @enum(...), @cache(ttl=) -- the last of which still parses, but @cache(N) is the form the corpus uses) here would undo the grammar-epoch migration, and a conformance test asserts no completion item's insert text carries one. A second gate requires every declared NextRule label to be either computed by the classifier or explicitly listed as not-yet-detected, so a rule added to the table can never sit silently dead again.

Receiver-filtered and construct-scoped completion (#2627)

With the enclosing construct resolved, completion is construct-aware in the two places it matters:

  • Annotations. @ offers only the construct's legal annotations, mapped keyword -> Construct.AnnotationReceiver -> the registry's receiver list. Three edges: the CONCEPT construct's receiver key is "" (a real key -- not "unresolved"), an unbacked construct (today exactly use) falls back to the union rather than offering nothing, and no detection at all (EOF) keeps the union. A registry-driven conformance test asserts that every annotation offered for every construct is legal for that receiver, so the never-offer-what-the- engine-rejects contract survives future registry edits.
  • Body blocks and statements. A body offers ITS construct's BodyBlocks from the spec (query: args/filter/shape; mutation: args/insert/update; logic: args; automation: args and precondition) and, in a logic or an automation, the statement starts described under Completion -- a logic body never offers filter or publish, a shape body never offers insert.

The context model

Every completion request resolves ONE enclosing-construct answer (#2626): a backward walk over the token stream pairs each still-open brace with the header that opened it, and the innermost frame whose label is a dslspec construct keyword is the enclosure. It yields the construct keyword, its annotations-registry receiver, the declared name, and the chain of enclosing named blocks (args, insert/stamp, precondition, an if or for block, ...). A top-level @ is the one inverted case -- it PRECEDES its construct, so the receiver comes from the next header below the cursor, and at EOF there is none (the completer falls back to the union of every receiver's annotations).

The lowercase construct keywords lex as plain identifiers (only concept and use are lexer keywords), so headers are matched by identifier literal against the spec's construct set -- a construct a future grammar epic adds is recognized automatically.

Populating the receiver is what finally engages the per-receiver annotation filter: @ above a query no longer offers mutation-only annotations like @mergeFields.

Member completion (#2624)

Typing a dot after a known accessor root completes ONLY that root's members -- a dot context never offers keywords, builtins, functions, or concepts, and an unknown root offers nothing:

RootMembers offered
actor.The canonical auth envelope from the dslspec property table (#2623), aliases sorted after canonical members.
event.The event envelope: topic, kind, payload, actor, timestamp.
event.actor.id only -- the emitter's identity stamp (G4), a different object from the auth envelope.
args.The enclosing construct's declared args fields (any function kind; the automation BARE-name completion shares the same declared-field scanner, so the two can never disagree). In a trigger filter, which is written above its automation, the fields are the args of the automation the annotation decorates.
row. (the position's lambda parameter)The bound concept's fields, each with its declared type, then the row intrinsics. actor. in a spec over an @actor shape offers the envelope.
payload.Nothing at an expression position: it was the pre-v1 spelling of a payload field, and edition 2026 refuses a bare payload. A v1 expression reads row.<field>.

Both lexer shapes are detected: a trailing dot (actor.) and a mid-member position (actor.us, where the prefix filters).

The CI drift guard

dslspec is only useful if it stays true to the live grammar. A CI drift test (component/language/dslspec/drift_test.go) is that pin: it FAILS when the live grammar (the parser's top-level dispatch plus the struct-form rewriter) or the annotations registry moves ahead of the spec.

The test imports the parser and the annotation registry. Production dslspec code imports the parser too, and derives from it rather than restating it: the two labels naming the language (Edition, GrammarVersion, memql#5362), the construct set (parser.ConstructKeywords, the one table of words that open a top-level statement) and the body clauses each construct takes (parser.BodyClauses). The parser imports nothing from dslspec, so neither creates an import cycle. What dslspec still writes by hand is what the parser cannot say -- each construct's category, doc and annotation receiver -- and the test compares that catalog against the parser's own lists (parser.TopLevelDeclKeywords, parser.StructFormKeywords), not a hand-copied literal, naming the exact drifted symbol on failure (for example "add X to dslspec constructs()" / "remove Y"). Add a new construct to the grammar without its catalog entry in the spec, and this test goes red.

The SpecVersion constant (1.1.0) versions the JSON envelope shape, not its contents: a new construct or annotation does NOT move it. A backward-incompatible change to the envelope moves the major, so a consuming editor can detect a contract mismatch; an additive one moves the minor (1.1.0 added edition and grammarVersion).

The syntax<->Sense parity gate (#2734)

Most of Sense's vocabulary is projected from the spec at runtime, so it cannot drift. But a few surfaces added for cross-reference resolution (the invocation kind-filter, #2733) keep HAND-MAINTAINED tables that key on construct keyword or invocation verb -- behavioralConstruct / nonBehavioralConstruct / invocationVerbKind (context.go) and invocationKeywordsForConstruct (complete.go). A conformance gate (component/memql/sense/parity_test.go) pins them to the same SoT:

  • Every dslspec construct must be classified EXACTLY once as behavioral (its body invokes other constructs) or non-behavioral -- a construct added to the grammar lands in neither set and fails, naming the file to update.
  • The two invocation tables must agree (a construct is behavioral IFF invocationKeywordsForConstruct offers the call verbs).
  • Every verb in invocationVerbKind must be a real invocation keyword per parser.InvocationKindKeywords().

This is how "keep MemQL Sense updated whenever the syntax or rules change" is enforced: adding a construct to the grammar, removing or renaming an invocation keyword, or letting the two invocation tables disagree fails this gate in CI until Sense is brought back in sync. (It does not yet force Sense to handle a NEW invocation verb added to an existing construct -- the verb pin is a one-directional subset check.) Follows the #2628 detected/undetected-label conformance pattern.


Portable JSON export (for editors)

The whole authoring surface serializes to portable, indented JSON -- the form a Monaco / CodeMirror grammar is generated from. Its top-level shape is:

json
{
"version": "1.1.0",
"edition": "2026",
"grammarVersion": "<parser.GrammarVersion>",
"constructs": [ ... ],
"annotations": [ ... ],
"keywords": [ ... ],
"operators": [ ... ],
"fieldTypes": [ ... ],
"nextRules": [ ... ],
"builtins": [ ... ]
}

edition and grammarVersion name the language the document describes -- the same two facts ServerHello states on connect -- so an editor can compare the grammar it was built from with the cluster's. A grammar version is a label: two compare equal or different, never older or newer (see MemQL in VS Code for how the extension orders them).

An editor fetches it over gRPC through the Go SDK:

go
import "github.com/znasllc-io/memql/sdk/go/dslspec"
 
client := dslspec.NewClient(dispatcher)
spec, err := client.Fetch(ctx) // spec.JSON (portable JSON), spec.Version

sdk/go/dslspec is read-only by construction -- there is no write path. The spec is assembled from the engine's own grammar tables and annotation registry, so an editor that fetches it derives its language intelligence from the EXACT same source of truth Sense is driven from. The Version mirrors the embedded SpecVersion so a consumer can check the contract version without parsing the document.


Where this lives

PieceLocation
The spec (source of truth)component/language/dslspec/ (spec.go, constructs.go, lexicon.go, nextrules.go, punctuation.go, json.go)
The drift guardcomponent/language/dslspec/drift_test.go
Sense core (pure Go)component/memql/sense/
The load passcomponent/memql/sense_load_pass.go (the engine's side), component/memql/sense/diagnose_load.go (DiagnoseLoad and the merge), cmd/memql-lsp/loadpass.go (the editor's scheduling)
Sense gRPC handlerscomponent/grpc/sense_handlers.go
Spec fetch SDKsdk/go/dslspec/
Sense SDK (Tokenize / Hover / ...)sdk/go/sense/

See also