All analyses run as fixed, parameterized Cypher queries — no LLM involved anywhere in this list.
The LLM's only job (see semantic-validation.md) is routing a
natural-language question to one of these when possible, and falling back to validated, read-only
generated Cypher only when it can't.
Declared-architecture analyses over app/analysis/queues.py/blast_radius.py:
| Endpoint | Function | Answers | |
|---|---|---|---|
| A1 | GET /api/analysis/queues/{id}/senders |
senders_of_queue |
Who sends to this queue? |
| A2 | GET /api/analysis/queues/{id}/consumers |
consumers_of_queue |
Who consumes this queue? |
| A3 | GET /api/analysis/queues/without-consumers |
queues_without_consumers |
Queues with a sender but no known consumer |
| A4 | GET /api/analysis/queues/without-senders |
queues_without_senders |
Queues with a consumer but no known sender |
| A5 | GET /api/analysis/services/{id}/blast-radius |
blast_radius |
Mixed-architecture impact analysis — traverses both sync (CALLS/PROVIDES) and async (SENDS/RECEIVES_FROM) edges, default max depth 5 (DEFAULT_MAX_DEPTH, configurable per-request via ?depth=) |
app/analysis/dependencies.py additionally computes the two derived, never-materialized dependency
views mentioned in graph-model.md: sync_depends_on and async_flow_to.
Using this repo's own examples/ fixture (order-service → product-service/payment-service →
invoice-service) after POST /api/import:
curl -s http://localhost:8000/api/analysis/services/service:order-service/blast-radius | jq .[
{
"service_id": "service:payment-service",
"service_name": "PaymentService",
"depth": 1,
"via": "ASYNC"
},
{
"service_id": "service:product-service",
"service_name": "ProductService",
"depth": 1,
"via": "SYNC"
},
{
"service_id": "service:invoice-service",
"service_name": "InvoiceService",
"depth": 2,
"via": "ASYNC"
}
]The endpoint orders entries by service_id, then via, so payment-service sorts ahead of
product-service. order-service reaches payment-service directly over payment-q (ASYNC, a
SENDS/RECEIVES_FROM path) and product-service directly (SYNC, a CALLS/PROVIDES path) —
both one hop away, so both land at depth: 1. invoice-service only appears because payment-service in turn
sends invoice-q onward to it, so it's two hops from order-service and shows up at depth: 2.
Each row's via tells you whether that specific hop was synchronous or asynchronous, independent of
how any other hop in the chain was reached.
?depth= caps how many hops the traversal takes. Capping it at the first hop drops
invoice-service from the result entirely, since it's only reachable at depth 2:
curl -s "http://localhost:8000/api/analysis/services/service:order-service/blast-radius?depth=1" | jq .[
{
"service_id": "service:payment-service",
"service_name": "PaymentService",
"depth": 1,
"via": "ASYNC"
},
{
"service_id": "service:product-service",
"service_name": "ProductService",
"depth": 1,
"via": "SYNC"
}
]returns just the two depth: 1 entries above. The default is DEFAULT_MAX_DEPTH (5, set in
blast_radius.py), so on this small fixture a default call (no ?depth=) already returns everything
reachable — ?depth= mostly matters on larger graphs where you want to bound how far the impact
analysis fans out.
Declared-vs-observed analyses over app/analysis/runtime.py, every one scoped to an environment
and a since/until time window:
| Endpoint | Function | Answers | |
|---|---|---|---|
| O1 | GET /api/runtime/relations |
observed_relations |
Everything actually observed at runtime, with aggregation and optional filters — no declared/observed comparison, just raw observation |
| O2 | GET /api/analysis/runtime/confirmed |
confirmed_relations |
Declared and observed — CONFIRMED |
| O3 | GET /api/analysis/runtime/observed-only |
observed_only_relations |
Observed but never declared — undocumented real dependencies; the spec calls this "probably the most important" runtime analysis |
| O4 | GET /api/analysis/runtime/declared-only |
declared_only_relations |
Declared but not observed in this window — NOT_OBSERVED_IN_WINDOW, qualified by a coverage classification (see opentelemetry.md) |
| O5 | GET /api/analysis/runtime/coverage |
telemetry_coverage |
Per-service telemetry coverage (http_observed/messaging_observed/spans_observed) — used to judge how much weight an O4 finding should carry |
GET /api/runtime/services/{id} (service_runtime_profile) composes O2+O3+O4+O5 into one
per-service view — this is what powers the Service Explorer UI's "Observed" section.
The analysis/REST path above may use an implicit clock-relative default observation window and may
allow an open-ended upper bound — when a caller omits until, the underlying qualification query
places no upper bound on last_seen at all. This is distinct from what a REST response displays:
app/api/runtime.py's RuntimeWindow.to field always reports until or datetime.now(UTC) for
human-readable display, even when the query itself ran with no upper bound — so an omitted until
shows as "now" in the response body while still being genuinely open-ended underneath. Don't read
window.to as the bound the query actually enforced.
The MCP tools (see mcp.md) require the explicit observation context defined by the v0.4
contract instead — there is no implicit default there.
Equivalent effective observation contexts MUST produce equivalent qualification semantics. Different effective observation windows MAY legitimately produce different qualifications.
That line is what distinguishes intended request-contract asymmetry from implementation divergence.
Both surfaces derive their declared-vs-observed evidence matching and coverage classification from
one shared semantic owner, app/qualification/declared_observed.py, proven equivalent by a real
Neo4j differential test (tests/integration/test_qualification_consistency.py) rather than by
inspection alone.
app/analysis/registry.py's INTENT_HANDLERS maps each recognized intent (A1-A5, O1-O5) straight
to the function above — a deterministically-routed question calls the exact same code as its REST
endpoint, so a natural-language answer and its equivalent direct API call are guaranteed to agree.