Delegates
Fallen-8 has no query language, and that is the point. Instead of a Cypher/Gremlin/SQL dialect, a query in Fallen-8 is C#: a small delegate fragment that the engine compiles at runtime and runs directly against the in-memory graph. This page owns that design decision, the shape and contract of every fragment kind, how compilation and caching work, the context parameter, and the POST /delegates/validate compile-check.
No query language, on purpose
Section titled “No query language, on purpose”Fallen-8 deliberately ships without a query language. There is no Cypher, no Gremlin, no SQL dialect, and none is planned. Owning a query language is a large, permanent commitment: a grammar, a parser, a planner, an optimizer, and a lifetime of compatibility promises. That responsibility was declined on purpose, not left as a to-do.
Queries are expressed as C# instead. You hand the engine a tiny delegate fragment (return (v) => v.Label == "person";) and it compiles that fragment to a real .NET delegate and invokes it inside the traversal at full in-memory speed. There is no query string to parse, no intermediate representation, no impedance layer between what you wrote and what runs. The precompiled flavor (stored queries, below) removes even the per-request compile.
This is a deliberate fit for the era of code-generating agents. An agent already writes C# fluently; asking it to emit a three-line lambda is easier and less error-prone than teaching it a bespoke query dialect, and the fragment runs against the graph with nothing lost in translation. The absence of a query language is a feature of Fallen-8, not a gap in it.
Anatomy of a fragment
Section titled “Anatomy of a fragment”A fragment is the body of a factory method whose return type is one of the delegate types below and whose single parameter is a TraversalContext context. So a fragment must return a lambda of the matching shape:
return (v) => v.Label == "person";- The
returnprefix is required: the fragment is a method body, not a bare expression. - End the statement with
;. - The lambda parameter name (
v,e,p, …) is yours to choose; only its type is fixed by the delegate kind. - For multi-step logic use a block-body lambda:
return (v) => { var n = v.GetOutDegree(); return n > 2; };. Statements may also precede thereturn(the fragment is a whole method body):var min = 2; return (v) => v.GetOutDegree() > min;. - A
null/empty fragment means “match everything” (filters) or “no custom cost” (costs).
The generated method also receives context, so the returned lambda may close over it (see below).
The delegate contract
Section titled “The delegate contract”Every fragment compiles against one of the delegate types in Delegates.cs. Filters return bool (return false to drop the element); costs return double (the step weight). The context parameter is always in scope.
| Delegate kind | Lambda receives | Returns | Accepted in |
|---|---|---|---|
VertexFilter |
VertexModel |
bool |
path vertexFilter; subgraph vertexFilter + Vertex pattern |
EdgeFilter |
EdgeModel |
bool |
path edgeFilter; subgraph edgeFilter + Edge pattern |
EdgePropertyFilter |
string (the edge’s type, edgePropertyId) |
bool |
path edgePropertyFilter; subgraph Edge pattern |
VertexCost |
VertexModel |
double |
path vertexCost |
EdgeCost |
EdgeModel |
double |
path edgeCost |
GraphElementFilter |
AGraphElementModel |
bool |
/delegates/validate only: no live REST slot produces it today |
LabelFilter |
string (label) |
bool |
nowhere: declared in Delegates.cs but currently unused, and /delegates/validate rejects it |
Accessor surface
Section titled “Accessor surface”The lambda parameter is a live engine model object. Read it through these members (properties are typed values behind TryGetProperty<T>, not C# fields).
AGraphElementModel: base of both VertexModel and EdgeModel:
| Member | Purpose |
|---|---|
int Id |
Element id |
string Label |
Element label (may be null) |
bool TryGetProperty<T>(out T value, string key) |
Typed property read; false if absent, not a T, or null - never throws |
bool AnyPropertyValueMatches(Func<string,bool> valuePredicate) |
Full-text test across the element’s string property values (names never reach the predicate; reserved embedding entries are skipped): v.AnyPropertyValueMatches(s => s.Contains("Tech", StringComparison.OrdinalIgnoreCase)) |
int GetPropertyCount() |
Number of properties |
DateTime GetCreationDate() / GetModificationDate() |
Timestamps |
bool TryGetEmbedding(out ReadOnlySpan<float> vector, string name = "default") |
Named embedding, if present |
bool TryGetEmbeddingModelStamp(out string stamp, string name = "default") |
The model stamp stored next to a provider-written embedding (absent for bring-your-own vectors) |
VertexModel adds:
| Member | Purpose |
|---|---|
uint GetOutDegree() / GetInDegree() |
Degree counts |
IReadOnlyDictionary<string, IReadOnlyList<EdgeModel>> OutEdges / InEdges |
Adjacency grouped by edge-property id, or null when the vertex has no edges in that direction: guard before dereferencing |
bool TryGetOutEdge(out IReadOnlyList<EdgeModel> edges, string edgePropertyId) / TryGetInEdge(…) |
One adjacency group; false with a null result when the group is absent |
bool TryGetOutEdgesSpan(out ReadOnlySpan<EdgeModel> edges, string edgePropertyId) / TryGetInEdgesSpan(…) |
The same group, allocation-free; the span is a ref struct, so iterate it in scope |
EdgeModel adds: VertexModel SourceVertex, VertexModel TargetVertex, string EdgePropertyId (all read-only).
Some public engine members are not usable in a fragment, because the compile does not reference the assembly their return type lives in and the call fails with CS0012: GetAllProperties() (ImmutableDictionary<,>), plus GetAllNeighbors() and GetOutgoingEdgeIds() / GetIncomingEdgeIds() (List<>). Reach for GetPropertyCount / TryGetProperty / AnyPropertyValueMatches and the adjacency members above instead. Compilation and caching lists what the compile does reference.
The context parameter
Section titled “The context parameter”context is a TraversalContext: the per-request semantic state, embedded once before the traversal starts. It is empty (HasQueryVector == false) unless the request carries a semantic block; that block is the code-free way to supply a query vector and is owned by semantic-traversal.md. A fragment reads it to blend similarity into a filter or cost:
| Member | Purpose |
|---|---|
bool HasQueryVector |
Whether a query vector was supplied |
bool TrySimilarity(AGraphElementModel element, out float score) |
Score the element’s embedding against the query vector; false if unavailable/non-finite |
ReadOnlySpan<float> QueryVector |
The raw query vector |
string EmbeddingName / VectorDistanceMetric Metric |
Which embedding and metric are scored |
Example, keep vertices whose embedding is close to the query vector:
return (v) => context.TrySimilarity(v, out var s) && s > 0.8f;
Compilation and caching
Section titled “Compilation and caching”Fragments are wrapped into a provider class and compiled with Roslyn (CodeGenerationHelper) into the strongly-typed Delegates.* types, then loaded into a collectible AssemblyLoadContext so the assembly can be unloaded once it is no longer referenced. The compile is the expensive step, so identical work is cached and never recompiled:
| Surface | Cache | Key | Size | Eviction |
|---|---|---|---|---|
| Path filters/costs | GeneratedCodeCache (process-wide, static) |
the (filter, cost) pair, by value equality: numeric bounds (maxDepth/maxResults/maxPathWeight) and pathAlgorithmName are applied at traversal time and excluded from the key |
1024 entries | 60 s sliding; collectible context unloads on eviction |
| Subgraph filters | provider cache in CodeGenerationHelper |
the generated provider source string | 256 entries | 60 s sliding; collectible context unloads on eviction |
So repeating the same fragment set reuses one compiled artifact, and two path requests that differ only in a numeric bound share the same cached traverser. Compile timings and cache hit/miss counters are exported as metrics; see observability.md.
There is no sandbox: compiled code runs in-process with full trust. That is exactly why the code endpoints sit behind authentication; security.md owns that posture (dynamic code execution itself is always on; the API key controls access). These runtime-compiled delegates are not plugins: that is a separate discovery mechanism.
The compile environment is narrow, and that narrowness is the de-facto type allowlist. It imports a fixed set of usings (System, System.Linq, NoSQL.GraphDB.Core.Model, NoSQL.GraphDB.Core.Index.Vector; subgraph fragments also get NoSQL.GraphDB.Core.Algorithms), so TraversalContext resolves by simple name, and it references only the runtime core (System.Private.CoreLib plus the mscorlib / System / System.Core / System.Runtime facades), System.Linq and the engine assembly. A type from any other assembly is out of reach even fully qualified: System.Text.RegularExpressions.Regex fails with CS1069, and an engine member whose signature names List<> or ImmutableDictionary<,> fails with CS0012 (see accessor surface).
Size caps are the first structural guard and they are uniform: every generator rejects a fragment longer than 100,000 characters before Roslyn runs and a generated source over 1,000,000 characters, so POST /path, POST /delegates/validate, PUT /subgraph and the stored queries that go through the same generators all inherit both, on top of the 1 MiB request-size limit every code endpoint carries (413 beyond it). There is still no compile timeout. Execution is bounded only cooperatively, and only on /path (security): a fragment that loops forever occupies its request thread.
Where fragments are accepted
Section titled “Where fragments are accepted”| Surface | Fragment slots | Doc |
|---|---|---|
POST /path/{from}/to/{to} |
filter.vertexFilter / edgeFilter / edgePropertyFilter, cost.vertexCost / edgeCost |
path-finding.md |
PUT /subgraph |
top-level vertexFilter / edgeFilter plus per-pattern filters |
subgraphs.md |
POST /storedquery |
the same fragments, compiled once and invoked by name | stored-queries.md |
POST /delegates/validate |
compile-check a single fragment (below) | this doc |
MCP f8_paths / f8_subgraph |
vertexFilter / edgeFilter / edgePropertyFilter / vertexCost / edgeCost on f8_paths, vertexFilter / edgeFilter on f8_subgraph, bridged to POST /path and PUT /subgraph; offered only with the code capability |
MCP server |
Validating a fragment: POST /delegates/validate
Section titled “Validating a fragment: POST /delegates/validate”Compile-checks one fragment without running it: nothing is emitted, loaded, or executed. Use it to give an editor or agent instant feedback before submitting a query. It carries the same authentication as the query endpoints (security.md): 401 without a credential when a key is configured, 429 when rate-limited, 400 for a missing body or an unknown delegateKind.
Request: { "delegateKind": "<kind>", "fragment": "<C#>" }. delegateKind is case-insensitive and must be one of VertexFilter, EdgeFilter, EdgePropertyFilter, VertexCost, EdgeCost, GraphElementFilter; LabelFilter is not accepted. Response:
{ "valid": false, "diagnostics": [ { "line": 1, "column": 19, "endLine": 1, "endColumn": 19, "id": "CS1002", "severity": "error", "message": "; expected" } ]}valid is true when the fragment compiles with no errors (warnings are reported but do not block). Diagnostic positions are 1-based and already mapped to fragment coordinates, line 1 / column 1 is the first character you sent. A null/empty fragment is valid with no diagnostics; an oversized one fails with id F8LIMIT.
curl -X POST http://localhost:8080/delegates/validate \ -H "Content-Type: application/json" \ -H "X-Api-Key: $F8_API_KEY" \ -d '{ "delegateKind": "VertexFilter", "fragment": "return (v) => v.TryGetProperty(out int age, \"age\") && age > 30;" }'$body = @{ delegateKind = "VertexFilter" fragment = 'return (v) => v.TryGetProperty(out int age, "age") && age > 30;'} | ConvertTo-JsonInvoke-RestMethod -Method Post -Uri http://localhost:8080/delegates/validate ` -Headers @{ "X-Api-Key" = $env:F8_API_KEY } -ContentType "application/json" -Body $bodyAuthoring notes and common pitfalls
Section titled “Authoring notes and common pitfalls”| Symptom | Cause | Fix |
|---|---|---|
CS1002: ; expected |
Missing trailing ; |
End the fragment with ; |
| “not all code paths return a value” | Missing return, or logic placed outside the lambda |
Prefix with return; put branching inside a block-body lambda |
CS1061: '…' does not contain '…' |
Using an EdgeModel member on a VertexFilter (or reading a property as a C# field) |
Match the kind’s parameter type; read properties via TryGetProperty<T> |
| A filter silently drops elements it should keep | TryGetProperty<T> with T different from the stored value’s CLR type reads as absent (there is no numeric widening, and no throw) |
Request the exact stored type (e.g. out int, out double, out string) |
500 from /path or /subgraph on a fragment that compiled fine |
The fragment threw during the traversal, most often dereferencing a null OutEdges / InEdges (no edges in that direction) or a null Label |
Guard for null. A runtime throw is not a compile error, so /delegates/validate cannot catch it |
CS0012: the type '…' is defined in an assembly that is not referenced |
The member’s return type lives outside the referenced assemblies (List<>, ImmutableDictionary<,>) |
Use the substitutes listed under Accessor surface |
See also
Section titled “See also”- Path finding: the path request and its filter/cost slots
- Subgraphs: subgraph pattern filters
- Stored queries: named, precompiled fragment sets
- Semantic traversal: the code-free
semanticblock that populatescontext - Security: the API key that gates access to these always-on code endpoints
- Observability: codegen compile and cache metrics
- Studio: the browser delegate editor (static IntelliSense over this accessor surface, validate-as-you-type) and the NL-assist UI
- MCP server: the agent channel that forwards inline fragments on
f8_paths/f8_subgraph - Graph model: elements, properties, and transactions the fragments read
- REST API: OpenAPI document and Scalar reference