A code-to-column change-impact knowledge graph for AI coding agents.
cascade mcp (stdio, for an AI client) and cascade view (HTTP, for the local
viewer) are two faces of one tool catalog: src/mcp/catalog.mjs owns every
tool name, description, input schema and dispatch, and both transports call it.
A test asserts the two produce byte-identical answers for the same call, so a
page and a model can never be told different things.
cascade mcp # every project in ~/.cascade/registry.json
cascade mcp --project mall --project shop # only those two
cascade mcp --pack .cascade/pack # one pack directly (id from the pack meta)
cascade mcp --root ../some/repo # that repo's .cascade/pack
cascade mcp --memory-budget 256 # MB of pack JSON to keep in memory (default 512)
cascade view takes the same flags plus --port.
Graph loading is lazy: loaded graphs are held in an LRU under the memory
budget. The automatic-refresh coordinator also reads pack metadata and checks
saved local inputs in the background. --no-watch disables automatic analysis
and serves snapshots with unknown freshness. Project queries wait for required
refreshes; failures are returned explicitly. See
code intelligence and freshness.
The structure tools reuse the selected Java, TypeScript and frontend facts.
They have separate coverage and uncalibrated trust, not the impact graph’s
certification. All take the same optional project argument.
| tool | purpose | arguments |
|---|---|---|
session_briefing |
Short session context: structure, dependencies, recorded entry points, freshness and limits | depth, limit, maxBytes |
architecture |
Inferred directory groups and directed recorded dependencies with evidence | depth, limit, offset, maxBytes |
code_search |
Name/path substring search | query required; kind=all\|file\|declaration, limit, offset |
file_outline |
Declarations in source order | file required; limit, offset |
code_context |
Ranked matches and adjacent relationships | query required; maxBytes, limit |
code_trace |
Direct and transitive recorded callers/callees, including intermediate declarations | symbol required; direction=inbound\|outbound\|both, depth, limit, offset, maxBytes |
code_snippet |
Hash-checked source in the repository root | symbol or file; fromLine, toLine, maxBytes, maxLines |
index_status |
Structure generation, coverage and refresh state without forcing a refresh | no additional arguments |
The maxBytes parameter of session_briefing, architecture,
code_context and code_trace bounds UTF-8 bytes of answer JSON, excluding the
contract envelope; it is not a token limit. code_snippet.maxBytes limits source
text bytes. Snippets with unknown declaration extents require explicit file
ranges. Missing structure is reported as unavailable; run cascade analyze
with this version to create it. Source comments and identifiers are untrusted
data, never instructions to an agent.
Architecture and briefing lists page independently. A byte-limited list can
return no rows; truncated.fields[].byteLimited and
retry: { action: "increase-maxBytes", offset } then request a larger budget
at the same first-unseen offset. Do not advance past a row that did not fit.
code_trace defaults to one hop in both directions. Use an exact declaration
ID for overloads or ambiguous names. Edge grades remain the grades originally
recorded; a structural route through callback references is not a causal impact
path. references-callback records a function passed as an argument, without
claiming the receiving code invokes it. Dynamic and unrecorded calls are unknown.
For a bounded impact response, call flow with compact: true and optionally
maxBytes (4,096–262,144; default 32,768 answer JSON bytes). The default response
format stays unchanged. Compact format flow:compact:1 replaces lane-row
path/walkedPath arrays with pathRefs/walkedPathRefs, and link with
linkRef. Resolve these IDs through answer.evidence.edges and .links;
their basisRef values refer to .reasons. This preserves every shown proof
and its grade. Byte cuts omit whole rows, recorded in per-lane truncation and
retry metadata; the walk census remains intact. The response envelope is outside
the byte budget. maxBytes requires compact: true.
project argumentEvery tool takes an optional project string. The rule is deliberately blunt:
| served | call says | result |
|---|---|---|
| one project | nothing | that project answers |
| one project | project: "<its id>" |
the same |
| several | project: "<an id>" |
that project answers |
| several | nothing | ambiguous — the ids are listed, nothing is guessed |
| any | an id nobody serves | unknown-key — the served ids are listed |
Answering the wrong project confidently is the failure this product exists to prevent, so there is no “pick the first one” fallback.
Call projects first when you do not know the ids. It is the one tool that
is not about a pack — it answers from the registry, loads nothing, and reports
the cache (loaded, bytes, budgetBytes, evictions, hits, misses).
Its basis says so: project: "*", scope: "server", no build digest.
// tools/call {"name":"projects","arguments":{}}
{ "answer": {
"projects": [ { "id": "mall", "dotCascadePath": "/…/mall/.cascade",
"stack": ["sql","java"], "lastCertifiedAt": "2026-09-04T…",
"loaded": false, "bytes": null,
"federation": { "index": "present", "serves": 214, "calls": 3 } } ],
"cache": { "loaded": 0, "bytes": 0, "budgetBytes": 536870912,
"evictions": 0, "hits": 0, "misses": 0 } },
"basis": { "project": "*", "scope": "server", "buildDigest": null,
"freshness": { "verdict": "unknown" } },
"trust": { "trustLevel": "UNCERTIFIED", "axes": ["server"],
"knownGaps": ["server-level-answer", …] },
"limits": [], "truncated": { "any": false, "fields": [ … ] } }
trust.trustLevel on every answer is one of four computed names, in
ascending order of what they claim: UNCERTIFIED (nothing was scored),
GOLDEN_FAIL (a relation got something wrong), RUNTIME_PASS (nothing failed,
endpoint->tables passed, and at least one passing relation was labelled by an
execution trace and nothing else) and GOLDEN_PASS (every relation passes on a
corpus a human approved, or on one that covered the whole population). Read RUNTIME_PASS for exactly what a run can show: the answer
covered what actually ran. It never covers precision, because a trace carries no
negatives, and it says nothing about a route nobody exercised.
trust.gatesNotShown names every relation that could not be scored at all, and
a relation that is silently absent from it is one that passed.
basis.project on every answer is the id you addressed — the registry id, not
the name inside the pack (an older analyze stamped every pack "project", so
those names can collide across a multi-project server). When the pack declares a
different name of its own, the basis carries it as packProject and overview
relays it in answer.pack.project.
tools/list is the source of truth — src/mcp/catalog.mjs owns the names,
descriptions and input schemas, and test/docs.test.mjs fails if this table
falls behind it. Every tool also takes the optional project argument described
above.
| tool | the question it answers | arguments (* = required) |
|---|---|---|
overview |
Start here. What is in this pack, how much of it is wired end to end from an HTTP route down to a table, and what the engine could not see. screens.byKind splits the screens into router (a frontend router declared them) and page (a controller renders them). reach.groups is how many API groups the served routes fall into, by the rule map groups them by (the first segment of a route’s path, read below the prefix the application is deployed under where its lane knows one, or the handler’s package cut to moduleAttribution.packageDepth when the profile declares it), reach.routeGrades counts the served routes by the grade of their link to a handler, code.services counts the methods between a handler and its SQL (the ones the walks pass through, and a method that sends SQL from a call in its own body, such as a NestJS service calling Prisma), diagnostics lists the run’s warn and error diagnostics (each is a limits entry too), and every entry of gaps carries a class saying what a reader can do about it: input, unresolved, query, unreached or info. Every entry of gaps and diagnostics also carries remedy, the one thing to do when the engine knows it: {action:"declare", key, example} (a profile key and a short example the profile validator accepts), {action:"flag", flag, example} (a cascade analyze flag), {action:"run", command} or {action:"mode", mode}; null says the engine knows no single fix. A diagnostic that stands for many carries count and its first sentences in examples: the schema reader’s are one per kind, and the gaps they make are catalog-tables-unread, catalog-rules-assumed and catalog-read-in-part. Beside reach, axisRemedies gives the fix for each axis that is not whole (one per cause, and none when two causes hold), routeRemedy the fix for the routes whose own address is a guess, and groupRule what reach.groups counts: {kind:"path"} or {kind:"declared", packageDepth} |
mode depth |
projects |
Which projects does this server serve, and what is in memory? Answers from the registry alone — no pack is loaded | — |
search |
Find a table, column or statement by a name substring. Matches names and business comments, not source full text | query* limit |
browse |
List one kind (table, column, statement, endpoint, symbol, screen) with the numbers to pick by on each row, for when you have no name yet. endpoints per row comes from one per-pack walk in mode (conservative when none is given) with no hop cap, which the answer names in census ({mode, depth}), and every other count on a row (a table’s or a column’s statement counts, its screens, a screen’s routes and tables) is counted in that mode too, so a list asked in the mode of the answer beside it counts what that answer walks; the census is walked once per pack and mode; a walk the node cap stopped is said in limits; kind=symbol needs a query. kind=screen gives {screen, path, label, title, group, component, source, endpoints, tables, observed}, and a page the server renders adds {kind: "page", template, engine, routes} |
kind* mode query table sort limit offset |
column_impact |
If I change this column, which SQL statements read or write it? The statement axis; these edges are EXACT |
column* mode limit offset |
endpoint_impact |
If I change this column, which HTTP endpoints are affected? Walks the code axis up, the same walk flow direction=up draws, so it gives the rows and grades Trace does; each endpoint carries the weakest grade on its path. On a pack with a screen axis every row also carries screens: {count, sample}. A walk the node cap stopped is said in limits |
column* mode limit offset |
screen_impact |
If I change this column, table, statement or method, which screens are affected? The same walk two lanes further out, through the frontend’s own calls and the RENDERS edge, plus one step off RENDERS_PAGE so a server-rendered page whose HANDLER touches the column is listed too. Each row names the routes it goes through, the route that renders a page among them, and observed says whether a recording confirms the call. A walk the node cap stopped is said in limits |
column table statement symbol mode limit offset |
table_usage |
Which statements touch this table, with access, and per-column read/write counts | table* limit offset |
flow |
The chain behind one API call: entry → services → statements → tables, with hops and the walked path. screen=<router path> starts at the other end of the round trip (screen → its component functions → the routes they call → services → statements → tables), and screen=view:<view name> starts at a page the server renders, with the methods that render it; walking up, a pack with a frontend gains webFunctions and screens at the far end, the screens lane names a server-rendered page one step off each reached method that renders it, and walking DOWN from a route the screens lane lists the page that route shows. A Prisma or TypeORM statement row, and a statement’s entry, carry call ({model, operation}), the call it makes. walk.laneNames says which lanes the answer has. From a route, the picture starts at the first handler the mode admits and no row is graded above that link, which entry.link names; when the mode admits none, it stops at the route and says why (see concepts.md). direction=up from an endpoint is “where is this API used”: see Up from a route. Which kinds walk which way is one table, FLOW_DIRECTIONS: down from an endpoint, a screen or a symbol; up from an endpoint, a column, a table, a statement or a symbol; a screen walked up and a column, table or statement walked down are refused by name. With no depth a walk has no hop cap, and a node cap guards it (concepts.md). In chain mode offset cuts every lane from that place in the walk’s order, to fetch the next page of one lane. List mode takes kind=endpoint (default) or kind=screen |
endpoint screen symbol column table statement direction kind mode depth limit query offset |
transactions |
The @Transactional boundaries and each one’s atomic read/write footprint |
method limit offset |
erd |
An ERD recovered from the joins the mapper SQL witnesses — foreign keys are never read. Each table carries its family, by the one rule summary reads table names with (familyRule), so a table is in the same family on both |
table hops limit |
coupling |
Which API group writes what another group reads — the DB sharing no call edge shows | axis mode depth limit offset |
map |
The whole pack as one relation map: groups, endpoints, the tables they reach, and the joins between them | mode depth layers limit maxBytes |
neighborhood |
The graph slice around one focus node, for a visual view | node column table statement endpoint symbol direction hops limit |
summary |
The whole pack in about ten boxes a side: groups of routes, the table families they reach, and the links between them with the tables, routes and weakest grade behind each. Every box lists its members. rule.groups.kind says what a box on the left is: the modules the profile declares (declared), where the handler code sits (code-path), the group each route’s lane wrote (lane), or the route path (path). A family is read from the words of the table names, over every table of the pack, and a table a table.join-table rule names goes with the tables it joins (rule.tables.joinTables); boxes are kept by how many routes they hold, families by how many routes reach them and then by size. Read limits: an undeclared group is a pattern, not a module. depth is 1 to 8, or left out for no hop cap; a walk the node cap stopped is said. Name one endpoint or one table and answer.through carries the paths through it; see The paths through one node |
mode depth limit endpoint table |
pack_diff |
What changed between two packs of ONE project: nodes and edges added, removed or regraded; changed node attributes (for example column type or transaction marker); changed edge evidence (for example access); and location-only moves separately. Each changed field retains its base/head value and whether it was absent, and duplicate edge records remain distinct. Affected endpoints/screens are static candidates from both recorded graphs, including candidate-grade edges, for additions, removals, regrades and content changes; moves alone do not inflate them. They are not runtime or behaviour proof. This is recorded graph change, not every source/body change or a safety verdict. The additive schema remains cascade:pack-diff:1; comparisonVersion: 2 identifies this expanded comparator. The addressed project is the head; the base is an earlier build from its pack history (base_commit, base_history) or another served pack of the same repository (base). Packs of different repositories are refused. Read answer.conditions first |
base_commit base_history base limit |
changed_impact |
The working-tree overlay: I edited these files — what is the blast radius? Re-parses the dirty files on each call, Java, frontend and TypeScript alike (parsedFiles, parsedWebFiles, parsedTsFiles). An edited component file also reports touched.screens, the screens its functions are drawn on |
files mode limit offset |
rules |
Which rules does this engine run, and what did each give in this pack? With no rule: every kind, pack and rule, each rule with gave: {edges, nodes} here, sorted by what it gave. With rule: that rule whole, and the links and nodes it gave, a page at a time. See Rules |
rule query kind lane here limit offset |
Grades, the mode floors, and what limits / truncated mean are in
concepts.md.
What the engine knows about frameworks is in rule packs
(rules.md), and every link a rule gave names it in its evidence
(evidence.rule). A rule can also decide what a node is without drawing a line:
a Prisma operation rule decides what one call site’s statement reads and writes,
and the statement names it in an evidence block of its own
(prismaEvidence.rule). rules counts both, for any lane that marks its nodes
that way.
// tools/call {"name":"rules","arguments":{"here":true}}
{ "answer": {
"rules": [ { "id": "prisma.client", "pack": "prisma", "kind": "ts.type-role", "lane": "ts",
"grade": "EXACT", "gave": { "edges": 152, "nodes": 0 }, "here": true, … },
{ "id": "prisma.operations", "gave": { "edges": 0, "nodes": 152 }, "here": true, … } ],
"totals": { "packs": 10, "rules": 19, "here": 3 }, … } }
query, kind, lane (java, ts, sql) and here: true
narrow it.gave is null for a kind that only classifies (the database a file path
names) where the pack names the rule nowhere: there is nothing to count,
which is not the same as unused. A classifying rule a lane does name, such as
typeorm.receivers on the statement link it typed, is counted. Asked for one, such a rule’s lists
are not-shipped and a limits sentence says why.rule, edges carry both ends ({id, kind, label}), the grade and the
sentence the lane wrote, and nodes the nodes it marked; both are paged by
limit (default 50) and offset, and gave.byType, gave.byGrade and
gave.byKind count them whole (the list carries the same counts).cascade rules test runs them, and the
viewer’s Rules view, under Analysis status, shows each example’s verdict from
the same run.flow direction=up endpoint="DELETE /api/v1/user" asks “where is this API
used?”. The walk starts at the route, not at its handler, and climbs what calls
it over HTTP: the frontend functions that call it, the functions that call
those, and the screens that render them, plus any client in this pack that
calls the route. It is the walk every other target gets: the same mode floor
(walk.cut.byMode, split by grade in byModeGrades), the same depth and depth
cut, the same weakest-link grade on every row, the same lanes paged by
offset. There is no statements lane, because what calls a route is code,
never SQL.
Each caller is graded by its own link to the route. The route’s HANDLES grade
grades the code below it, never its callers. A route whose address its lane
could not settle (a NestJS global prefix with an exclude this engine cannot
read) says so on its HANDLES evidence (address: {grade, why}); the web lane
grades every call matched to it by that and names it on the call
(evidence.address), and walk.note says the address’s grade. A caller below
the mode’s floor is counted in walk.cut.byMode. On a pack with no frontend the webFunctions and screens lanes are still
this answer’s, empty with their reason (not-shipped where the pack has no
screen axis), so “no screen calls this” is never read off a pack that read no
screen. On a server with several projects, another
project that calls the route is crossed into as it is from any walk up. The
viewer’s Trace asks this when a route is read “Where it is used”, and
cascade export --tab impact --endpoint saves it.
summary draws the whole pack as boxes. Name one endpoint ("VERB /path")
or one table and answer.through also carries the paths through that node,
from the same walk as the boxes:
// tools/call {"name":"summary","arguments":{"table":"pms_product"}} (mall)
{ "answer": { …, "through": {
"node": "table:pms_product", "family": "pms",
"links": [ { "group": "controller", "grade": "SOUND_SET",
"endpoints": ["endpoint:GET /flashProductRelation/list", "endpoint:GET /product/list", …] },
{ "group": "portal", … }, { "group": "search", … } ] } } }
{node, group, links: [{family, tables, grade}]}, the families
it reaches, each with the tables it reaches there and the weakest grade on
the way.{node, family, links: [{group, endpoints, grade}]}, the groups
whose routes reach it, each with those routes and the weakest grade.(others) included, so a reader
lights exactly the lines the summary draws. A route the walk did not start
from, or a table no route reaches, has no line: links is empty and its box
is null.unknown-node, because an
empty set of lines would read as “nothing runs through it”. Naming both is
bad-input. The pack does not change.The viewer’s map on Start asks this when a route or a table is picked inside an open box.
One repository per microservice is the normal shape, and each one analyzes into
its own pack. A pack knows that its code sends GET /owners/{ownerId}
somewhere and stops there: the route it calls is not a route it serves, so the
lane put an outbound endpoint node in the graph and an UNRESOLVED CALLS_HTTP
edge onto it, which is below every mode’s floor.
When another project this same server serves answers that route, flow and
endpoint_impact keep walking there. Nothing is added to any pack: the join is
made at query time.
How a route is matched. analyze writes routes.json beside pack.json:
what this project serves, what it calls and does not serve, and the service
names it answers to. The server reads those sidecars, never the packs, so it
can answer “who serves this?” for twenty projects without parsing one of them.
A sibling’s pack is loaded only when the answer really crosses into it, through
the same LRU and the same memory budget as any other project.
Where the names come from. serviceNames is profile.serviceNames, which
cascade init writes from spring.application.name in the project’s own
application.yml. When the profile declares none, analyze uses the names its
OWN run read out of the tree, so a project analyzed before anybody re-ran init
still answers to its real name, and the run says the name is not recorded yet
and how to record it. The profile always wins when it has one, and neither
choice touches the pack: the name rides beside it in the sidecar, so the digest
is the same either way. A project that declares no name anywhere is still
matched by its project id.
The name ON A CALL comes from the evidence the lane recorded: the host a Java
client wrote (a lb://-style service name in the url), the service a declared
or discovered gateway route forwards to, or nothing at all, in which case only
the path and the method are left to match on.
| the call matches | what happens |
|---|---|
| one project | crossed, SOUND_SET (HEURISTIC when either side’s method is ANY) |
| several, and the call’s service name picks exactly one | that one, SOUND_SET |
| several, and nothing picks one | all of them, HEURISTIC, ambiguous: true, one limits sentence naming them |
| none | not crossed. The row still reads “leaves the pack”, and the call is listed in federation.unmatched |
A crossing never rises above SOUND_SET: which deployable answers a service
name is not a fact about anybody’s source. It weakens the path grade of every
row below it exactly as any other edge does.
What an answer carries.
{ "answer": {
"tables": [ { "table": "owners", "project": "customers-service",
"grade": "SOUND_SET", "viaHttp": true, "httpHops": 1,
"federated": true, "hops": 6, … } ],
"federation": {
"crossed": [ { "from": { "project": "api-gateway", "symbol": "…CustomersServiceClient#getOwner" },
"route": { "method": "GET", "path": "/owners/{ownerId}" },
"service": "customers-service",
"to": { "project": "customers-service", "endpoint": "GET /owners/{ownerId}" },
"grade": "SOUND_SET", "ambiguous": false } ],
"unmatched": [ { "from": { … }, "route": { … }, "checked": 3, "noIndex": 0 } ],
"skipped": [ { "project": "vets-service", "reason": "no-index" } ] } },
"basis": { "project": "api-gateway", "buildDigest": "afcbe96e574f", …,
"siblings": [ { "project": "customers-service", "buildDigest": "eaeb163146ce",
"builtAt": "…", "freshness": { "verdict": "unknown" } } ] } }
project, federated and
viaHttp, and its hops continue from the caller. walk and layers on a
flow answer describe this project’s walk only, and walk.note says so.basis.siblings names every other pack the answer walked, each with its own
build digest and freshness verdict. A row from over there is anchored to that
snapshot, not to this one. It is absent when nothing was crossed.federation is
{ "available": false, "reason": "single-project", "unmatched": [ … ] }. The
calls that leave the pack are still listed, because registering the project
that serves them is the remedy and a reader who never sees the list cannot
know to do it.skipped names a project that could not be asked: no-index (no
routes.json beside its pack, so it is not federated at all), stale-index
(its index was built from a different pack than the one this server loads), or
unreadable (its pack could not be loaded). Each gets one limits sentence
with the remedy, which is always cascade analyze in that project.federate: false answers from this pack alone. federationHops (default 3)
bounds how many crossings one answer may chain, so A to B to C is walked and a
ring of services cannot make one question walk forever.Siblings are read from their committed packs, never from their working-tree
overlays. changed_impact and the overlay describe the project you are
editing; a sibling is whatever its last cascade analyze wrote. An edit in
another project is invisible here until that project is analyzed again.
map and erd follow the same rules, with one extra one: only what this
project’s requests reach is added, never a merge of two packs. Two services
share no foreign key, so a relationship line between them would be an
invention, and jeecg alone is 10 601 nodes, so a merged picture would be
unreadable.
map. An endpoint on this map that calls a route another registered
project serves gets a calls link (federated: true) to that project’s own
endpoint node, which is the portal. Under the portal hangs that route’s own
picture, built by the same walk with the same mode, depth and layers.
{ "nodes": [
{ "id": "project:customers-service", "kind": "project", "label": "customers-service",
"project": "customers-service", "endpoints": 1 },
{ "id": "customers-service|endpoint:GET /owners/{ownerId}", "kind": "endpoint",
"project": "customers-service", "portal": true, … },
{ "id": "customers-service|table:owners", "kind": "table", "project": "customers-service", … } ],
"links": [
{ "source": "project:customers-service", "target": "customers-service|endpoint:GET /owners/{ownerId}",
"kind": "member", "grade": "EXACT", "project": "customers-service" },
{ "source": "endpoint:GET /api/gateway/owners/{ownerId}",
"target": "customers-service|endpoint:GET /owners/{ownerId}",
"kind": "calls", "grade": "SOUND_SET", "federated": true, "ambiguous": false,
"project": "customers-service", "route": "GET /owners/{ownerId}", "via": "…#getOwner" },
{ "source": "customers-service|endpoint:GET /owners/{ownerId}",
"target": "customers-service|table:owners", "kind": "touches",
"grade": "SOUND_SET", "project": "customers-service" } ],
"summary": { …, "federated": { "projects": ["customers-service"], "nodes": 2, "links": 3 } } }
<project>|<id> and every such node
and link carries project, so two services that both have an orders table
are two nodes and never one.id: "project:<id>", kind: "project", is the skeleton
the portals hang off. The sibling’s own groups are not drawn: a group is
one pack’s naming convention and does not travel.summary.federated.nodes counts the nodes that BELONG to another pack (the
skeleton is this answer’s own drawing device, so it is not counted there);
summary.federated.links counts every line that touches one.limits names what went and from which project.limits
sentence names the methods and the routes, and overview.federation counts
every call that leaves the pack whether a route reaches it or not.erd. The own answer is untouched. The whole-schema view gains
answer.federated, one entry per registered project a request reaches:
{ "federated": [
{ "project": "customers-service", "buildDigest": "eaeb163146ce",
"tables": [ { "table": "owners", "comment": null, "columnCount": 6 } ],
"relationships": [ { "from": …, "to": …, "columns": […], "statements": 2,
"grade": "EXACT", "cardinality": "1:N" } ],
"via": [ { "route": { "method": "GET", "path": "/owners/{ownerId}" },
"fromEndpoint": "endpoint:GET /api/gateway/owners/{ownerId}",
"grade": "SOUND_SET", "ambiguous": false, "tables": ["owners"] } ] } ] }
tables are only the tables the walk from the crossed route(s) reaches there,
and relationships are that project’s own joins between exactly those
tables. No relationship on this answer ever joins two projects. The only
thing connecting the clusters is the HTTP call, which via names.via[].fromEndpoint is the endpoint the request left from, namespaced
<project>|<id> when a chained crossing means the caller is itself in another
pack.mode=conservative with no hop cap (the default
map and flow walk), because erd takes no mode or depth of its own. limits says
so, so a table a wider walk would reach is unknown rather than absent.erd table=<name> answers one table’s join neighbourhood in this pack and
does not federate. Ask a sibling’s table of that project (erd with its
project argument).Both accept federate and federationHops like flow. With federate: false,
and on a server that serves one project and calls nobody, both answers are
byte for byte what they were before federation existed: the federation
block appears only when there is something to say.
overview.federation is a census over the pack’s outbound routes, not over one
walk, so it says the same thing whichever question the reader arrived with. No
pack is loaded: matching reads the siblings’ sidecars only.
{ "federation": {
"calls": 2, "answered": 2, "unmatched": 0,
"projects": [ "customers-service", "visits-service" ],
"byProject": [ { "project": "customers-service", "sites": 1,
"routes": [ { "method": "GET", "path": "/owners/{ownerId}", "sites": 1 } ] } ],
"unmatchedRoutes": [ ] } }
calls, answered and unmatched count ROUTES that leave this pack. sites
counts CALL SITES: the methods in this pack that make the call, so one route
called from two services is one route and two sites. A route matched by two
projects is listed under both, which is what ambiguous means on a crossing.
| route | what |
|---|---|
GET /api/tools |
the catalog (tools/list) |
GET /api/projects |
the projects tool’s answer |
POST /api/call |
{name, arguments, project?} — one tool call |
GET /api/meta |
the served project’s pack metadata (?project=) |
GET /api/rules |
the rule packs this engine carries, read-only, with how many links and nodes each rule gave in the served pack (?project=) |
GET /api/rules/examples |
whether each rule’s examples hold, one verdict per example, run once per server through the same workers cascade rules test uses |
GET /api/source |
one node’s source, read from the working tree: the snippet, the absolute path, the 1-based line range it cut and the file’s length (?node=, ?project=, ?whole=1 for the whole file instead of the snippet) |
GET /vendor/<file> |
the two vendored MIT browser bundles the graph pictures render with |
GET /i18n/<lang>.json |
one interface-language catalogue (English is compiled into the page) |
/vendor and /i18n are allowlisted shelves, not file servers: one directory,
one set of extensions, and every way out of the directory is a 404.
GET /api/projects is lazy — it reads the registry and loads no pack. Each
listed project carries meta (digest, build time, lanes, axes, freshness) only
once that project has actually answered something; null means “not loaded”,
never “nothing to say”.
project is accepted as a query parameter on the GET routes and as either
body.project or body.arguments.project on /api/call; it is stripped
before the tool sees the arguments.
The viewer page shows one project at a time and carries a selector for it;
the choice rides in the URL hash (#p=<id>&tab=<name>), and ?project=<id> is
still honoured. A single-project server needs neither. See
viewer.md.
One uniform model, kept apart by kind: input mistakes, data that cannot be served, and server defects. Over HTTP they are JSON, never HTML:
| code | HTTP | means |
|---|---|---|
bad-input, bad-request, unknown-tool |
400 | the call is wrong |
unknown-key, unknown-column, unknown-table, … |
404 | no such thing here |
ambiguous |
409 | say which project |
pack-unreadable |
503 | the pack cannot be read, or does not fit the budget |
contract-violation |
500 | the server built an invalid response — a bug |
Over stdio the same failure is a tools/call result with isError: true
and the text error [<code>]: <message> (a tool failure is not a JSON-RPC
error — the model is meant to read it and choose another path).
--memory-budget <MB> bounds the pack JSON held in memory: per project,
the pack file’s size plus its fact index’s text length. That is a proxy, not a
heap measurement — Node cannot price a live object graph, and measuring the
real thing would mean loading the pack the budget is meant to refuse.
Re-measured on two packs, with two independent tools that agree:
| pack | proxy | resident graph | ratio |
|---|---|---|---|
| mall (12 674 nodes / 19 268 edges) | 10.0 MB | 18.1 MB | 1.8x |
| synthetic 400 tables / 3 800 endpoints (61 980 / 105 068) | 47.6 MB | 94.1 MB | 2.0x |
“Resident” is what the server actually HOLDS — the Graph. The file text and the parsed JSON are released once the graph exists; while all three are alive the load transiently costs about 4x the proxy, which is the number to size a machine by, not the one to size the cache by. (The older 2.3x in this document was measured on a 2.8 MB / 3 941-node mall pack that no longer exists.)
So a 512 MB budget is roughly 1 GB of resident graph on this evidence, with a higher transient peak while a pack loads — divide by the ratio you measure if what you must bound is RSS. Re-measure on your own pack with either of:
node --expose-gc scripts/measure-host-memory.mjs --copies 5 --budget 8
node --expose-gc scripts/measure-pack-cost.mjs --pack <pack.json>
A pack that does not fit the budget alone is refused outright
(pack-unreadable, naming both numbers) rather than half-loaded. Eviction is
least-recently-used, logs one line per eviction to stderr, and is counted in
projects’ cache.evictions.