Lineage and the explorer
The API calls behind lineage and the explorer.
On this page
- The order of calls on the Lineage screen
GET /api/lineage/{uid}— where it came from and what it affectsGET /api/graph/{uid}— what it is tangled up withGET /api/explore/{uid}— the whole connected componentGET /api/lineage/{uid}/export— the picture, as a fileGET /api/lineage-views— the saved readings that apply hereGET /api/lineage-views/{uid}— what a link opensPOST /api/lineage-views— save a readingPUT /api/lineage-views/{uid}DELETE /api/lineage-views/{uid}
Part of the HTTP API reference.
Three routes:
| Route | Draw function |
|---|---|
#/lineage/<uuid> |
drawLineage(uid) |
#/lineage/<uuid>/view/<uuid> |
the same, with LIN_STATE.linkedView set first |
#/explore/<uuid> |
drawExplore(uid) — takes the window over |
The order of calls on the Lineage screen
GET /api/lineage-views?uid=<uid>— every saved reading that applies here, narrowest first.GET /api/lineage-views/{view}?on=<uid>— only when the address carried a view id.- Then one of two, decided by the mode:
- connections mode →
GET /api/graph/{uid}?depth=<hops>- lineage mode →GET /api/lineage/{uid}?direction=…&depth=…&grain=… - On demand:
POST /api/lineage-views,PUT /api/lineage-views/{uid},DELETE /api/lineage-views/{uid}. - On demand, download:
GET /api/lineage/{uid}/export?….
The Explorer makes exactly one call: GET /api/explore/{uid}.
Two questions, two endpoints, on purpose:
/api/lineage— where did this come from, and what does it affect. Directed. Walks only the relations the metamodel declares as carrying data, and treats realisation as a free step sideways rather than a hop, so a Data Entity can reach the tables that realise it without appearing to be upstream of them.
/api/graph— what is this tangled up with. Undirected, every relation of every kind.Both return the same shape — nodes and edges — so one drawing surface can render either. Which relations count as lineage is declared in the workbook, not decided here; there is no list of relation codes anywhere in this file.
GET /api/lineage/{uid} — where it came from and what it affects
Query parameters
| Name | Type | Default | Allowed / clamped |
|---|---|---|---|
direction |
string | "both" |
upstream, downstream, both |
depth |
int | 4 |
max(1, min(depth, 12)) |
grain |
string | "column" |
column, table, business |
Refusals: 400 "direction must be one of upstream, downstream, both";
400 "grain must be one of column, table, business".
The three grains (GRAINS):
Three readings of one graph, coarsening in this order: the edges as recorded, the same edges rolled up to the objects that hold them, and the same edges again lifted onto whoever speaks for those objects in business words.
"Business lineage" is that third one and nothing else. It is not a second graph somebody draws by hand beside the technical one — that graph is right on the day it is drawn and wrong from the next harvest onwards, with nothing to say which of the two to believe. Which assets are business ones is declared, not decided here:
asset_type.origin, in the workbook.
Response
{
"focus": {"uid": "…", "name": "EMAIL", "type_name": "Column"},
"mode": "lineage",
"direction": "both", "depth": 4, "grain": "column",
"nodes": [ // ordered by depth, then type display order, then name
{"depth": 0,
"side": "here", // here | upstream | downstream
"arrived_by": "start", // how the walk reached it
"uid": "…", "name": "EMAIL", "external_key": "…",
"type_code": "COLUMN", "type_name": "Column",
"type_group": "Physical", "origin": "harvested",
"inside": { // what this would become at a coarser grain; null if nothing
"uid": "…", "name": "CUSTOMERS",
"type_code": "TABLE", "type_name": "Table", "type_group": "Physical"
},
"chain": [ // everything it sits inside, NEAREST FIRST
{"uid": "…", "name": "CUSTOMERS", "type_code": "TABLE",
"type_name": "Table", "type_group": "Physical"},
{"uid": "…", "name": "SALES", "type_code": "SCHEMA", …}
]}
],
"edges": [ // ONLY between nodes that came back
{"from": "…", "to": "…",
"kind": "flow", // "flow" | "same thing"
"label": "feeds", // at business grain: "feeds, through 4 technical steps"
"method": "parsed_ddl", // null at business grain
"confidence": 0.9, // null at table and business grain
"weight": 1} // column: 1; table: via_edges; business: via_steps
],
"not_movement": [ // what this picture is deliberately NOT showing
{"type_name": "Data Domain", "n": 3}
],
"no_business_name": [ // business grain only; the busiest 12
{"uid": "…", "name": "STG_ORDERS", "type_name": "Table", "edges": 37}
],
"no_business_name_total": 44,
"focus_has_no_business_name": false,
"truncated": true // something sits AT the depth limit
}
inside is not a lookup of anything called a column:
It is
rolls_up_toand not a lookup of anything called a column, because this file must not know that a column is a column. That function already answers the same question for object grain: a leaf (something that contains nothing) folds into whatever contains it, and anything that contains something stays as itself. So a client whose workbook calls them FIELDs, or who puts partitions inside tables, gets the folding with no code change — and the folded picture agrees with the object-grain one because both are the same function.
chain is one query for every node, not one each: "A picture is a hundred
boxes at its worst and a chain is four deep, so this is four hundred rows and
one round trip" (_add_chains).
not_movement — why it exists:
A data domain owns a table; that is not data moving, so the domain is not on the lineage and never will be. But a diagram that silently omits two thirds of what an asset is attached to reads as an incomplete diagram rather than a focused one. Count what was left out, by type, and let the screen say so.
no_business_name — at business grain the omission is the point:
An asset that moves data and that nothing in the business layer speaks for cannot be drawn here — there is no business name to write in the box. Dropping it silently would make this diagram a lie by omission. Naming it turns the same omission into the most useful thing on the screen: the list of things carrying data through this business that nobody has yet described in the business's own words.
focus_has_no_business_name exists because "the focus itself may be one of
them, and then the empty picture has a cause worth saying out loud rather than
leaving the reader to conclude that nothing flows through it."
The twelve are the twelve that carry the most, ordered by edges — how many
movements in this picture touch them — and no_business_name_total counts all
of them. It used to be ORDER BY t.display_order, a.name, which on a
613,461-asset repository meant the twelve shown were the twelve whose names
begin with A, and the view the reader was looking for was never among them
however much data went through it. A sample chosen by spelling is a sample
chosen at random, on the one list this screen calls the work outstanding. The
Excel export re-asks without the limit and carries every one of them: a screen
may summarise, a file may not.
Two short-circuits, both measured.
An asset with no lineage at all does not run the walk (_has_lineage):
0076's guard already empties every branch inside the function, but a SQL function's body is planned on each call, and planning six references to views over a ten-million-row table costs about six hundred milliseconds whether or not a single row is then read. So the cheap question is asked first and the expensive one is skipped: an asset with no lineage is its own one-row answer, in the same shape, from the same query. On a harvested estate that is 10,001,054 assets out of 10,001,150.
A one-node answer asks for no edges at all:
each of these reads a view over the whole of
relation, and the one-element array does not make the scan smaller —v_lineage_edge_rolledfiltered to a single id still took 0.88 seconds at ten million. An asset cannot be linked to itself… so a single-node answer has nowhere for an edge to go.
Same-thing edges come back separately rather than as flow edges somebody has to filter: they "are drawn differently and carry no direction." They are de-duplicated by the unordered pair.
GET /api/graph/{uid} — what it is tangled up with
Query parameters
| Name | Type | Default | Clamped |
|---|---|---|---|
depth |
int | 2 |
max(1, min(depth, 6)) |
Response
{
"focus": {"uid","name","type_name"},
"mode": "graph", "depth": 2,
"nodes": [ … ], // same node shape as lineage, with `inside` and `chain`
"edges": [
{"from": "…", "to": "…",
"kind": "flows to", // coalesce(rt.lineage, 'structure')
"label": "holds",
"relation_code": "TABLE_HOLDS_COLUMN",
"method": "declared", "confidence": null, "weight": 1}
],
"cut": false, // the picture filled up BEFORE the hop limit
"cap": 1200, // GRAPH_ASSETS
"truncated": true // something sits AT the hop limit
}
cut and truncated are not the same fact: "truncated
means something sits at the hop limit, which the hop picker can undo. cut
means the picture was full before the hop limit was reached, which it
cannot."
Why there is a cap on the answer at all — the longest comment in the file, and the measurements are the argument:
/api/graph depth=1 2 assets 2.0 kB 0.05s
depth=2 50 assets 57.0 kB 0.07s
depth=3 250 assets 230.9 kB 0.23s
depth=4 10,101 assets 11,550.7 kB 7.63s
and depth=6 was allowed
/api/explore on the same column, no depth
the component 10,001,010 assets
the walk alone, counted in SQL 58.9s
the request itself 222.2s, then
HTTP 503 "Cannot reach Postgres … consuming input failed"
kernel: Memory cgroup out of memory: Killed process (postgres)
anon-rss 5,749,560 kB
One GET. The backend holding the result set reached 5.7 GB, the OOM killer took it, and what the person saw was their repository reporting that the database could not be reached. The earlier lesson — an unbounded version of this "hung the server hard enough to need its backends killed" — had been answered with a limit on depth, which is the dimension that was never the problem.
So the cap is on the ANSWER. It is also the number above which a diagram stops being a diagram: a person reads a hundred boxes and skims a thousand, and nobody reads ten thousand. It is reported rather than applied quietly, because both screens carried a sentence a quiet cap would have turned into a lie — "N assets connected", and "All N assets are loaded, nothing has been left out".
GRAPH_ASSETS = 1200.
How the walk behaves at the edge (_walk_out): breadth first,
a hop at a time, the two ends asked separately "because a condition on either
of two columns is a condition on neither index — the shape that took
speaks_for from 6.4 seconds to 87 milliseconds in 0074." Where a level does
not fit, the ids that fit are taken in id order, "so the same request twice
draws the same picture; an arbitrary half of a level would move under somebody's
cursor." Taking part of a level rather than none of it is deliberate: "A table
with three thousand columns would otherwise arrive with none of them, and a
neighbourhood with none of its contents is not a safer answer, only an emptier
one."
A note on cap=None in that function's signature: "a default argument is
bound once when the function is defined: written the obvious way the cap became
two numbers, and changing GRAPH_ASSETS moved one of them. Found by the check
that moves it."
GET /api/explore/{uid} — the whole connected component
Query parameters
| Name | Type | Default | Notes |
|---|---|---|---|
depth |
int | 0 |
0 means the whole thing; no clamp applied |
Docstring:
/api/graphanswers "what is near this", and near is a number you pick. This answers "what is this part of", which has no number in it: the whole connected component, however far it runs. A repository is not one component — an unlinked business term is its own island — so this is bounded by the data rather than by a limit, and the client is told how big it is before it tries to draw it.
depthnarrows that, and defaults to 0, meaning the whole thing — the question this endpoint exists to answer. Narrowing is for reading rather than for cost: it always says how far the component actually runs and how many assets were left outside, so a smaller number can never quietly look like the whole picture.
The walk always goes to EXPLORE_HOP_CAP = 24 hops and is narrowed here,
"so that 'N further out, not shown' keeps the meaning it had: it counts what
this walk actually found and left off the picture… Nothing readable is 24 hops
wide." The answer is still capped at GRAPH_ASSETS = 1200.
Response
{
"focus": {"uid","name","type_name"},
"mode": "explore",
"nodes": [ // sorted by type display order then name, NOT by distance
{"hops": 2, // this screen calls the distance `hops`
"uid": "…", "name": "…", "external_key": "…",
"type_code": "…", "type_name": "…", "type_group": "…", "origin": "…",
"inside": { … } | null, "chain": [ … ]}
],
"edges": [
{"from": "…", "to": "…", "kind": "flows to" | "structure",
"label": "holds", "relation_code": "…", "method": "declared",
"contains": true} // the estate tree's own rule: 1:N out, 1:1 back
],
"depth": null, // the narrowing asked for, or null for "the whole thing"
"reach": 7, // how far the component actually runs, in hops
"component_assets": 1200, // everything the walk found
"beyond": 812, // found but outside the narrowing
"cut": true, // the walk itself stopped early
"cap": 1200,
"repository_assets": 10001150, // count(*) FROM asset
"truncated": false // always false here
}
Nodes are sorted by type and not by distance on purpose: "a diagram whose boxes moved would read as changed data."
contains comes back on the edge rather than being worked out in the
browser from type names: "It is the same cardinality rule the estate tree uses
(one parent, many children), so a new containing type declared in the workbook
collapses on the diagram with no code change here."
When cut is true the screen must change its words, because "'N assets
connected' and 'nothing has been left out' are both false here."
GET /api/lineage/{uid}/export — the picture, as a file
Reached as an <a href> from the canvas. This endpoint calls
lineage itself rather than repeating its query — "so the file and the
screen cannot answer differently; the commonest way an export becomes a lie is
a second query that drifts from the first."
Query parameters
| Name | Type | Default | Allowed |
|---|---|---|---|
direction |
string | "both" |
as /api/lineage |
depth |
int | 4 |
as /api/lineage |
grain |
string | "column" |
as /api/lineage |
fmt |
string | "xlsx" |
xlsx, csv |
part |
string | "assets" |
assets, links, omitted — CSV only picks one sheet |
collapsed |
string | "" |
comma-separated layer:<Band>, type:<CODE>, or a bare name read as a layer |
hidden |
string | "" |
the same keys, for things hidden rather than folded |
collapse |
string | "fold" |
hide means "all of collapsed are hidden" (an older link form) |
inside |
string | "fold" |
show means columns are not folded into their holders by default |
opened |
string | "" |
comma-separated holder uids; a leading - means "folded back" |
group |
string | "" |
comma-separated <Band>=<level>, e.g. Physical=2 |
Refusals: 400 "The file is xlsx or csv."; 400 "part must be one of
assets, links, omitted".
The three rules in the section header, which are what the query string is for:
What the picture leaves out travels with it. A diagram omits things on purpose; a spreadsheet of the same view that dropped them silently would be read as everything there is.
Collapsing a layer is a way of READING the picture, so it marks rows rather than removing them. A file that had quietly lost a third of its rows because somebody folded a band on screen would be a trap: the fold is one click to undo and impossible to see in a file a week later.
A bare name in collapsed is read as a layer "so a link made before
collapsing reached asset types still says the right thing." collapse=hide
is honoured "from a link made when the mode was one switch over the whole
screen."
The three sheets
Assets — every node, with what the picture did with it:
| Column | What it is |
|---|---|
| Asset | the name |
| Type | the type name |
| Layer | type_group, or empty |
| Side | here / upstream / downstream |
| Hops | depth |
| Reached by | arrived_by |
| Source key | external_key |
| On the picture | drawn, folded into <X>, or hidden on the picture |
Links — every edge:
From, From type, To, To type, Kind, How it is written (the label),
Recorded by (the method), Confidence, Underlying links (the weight).
Left out — three columns, Why it is not on the picture, What, In other
words, with a row per reason:
"What is not data movement"/"3 × Data Domain"/ an explanation"Nothing at the business layer speaks for it"/"STG_ORDERS (Table)"/ …"Collapsed by the reader"/ the band or type / hidden-vs-folded"Folded into what holds it"/"CUSTOMERS (+41), ORDERS (+12)"/ …"Beyond the hop limit"/"4 hops"/ …
Response
fmt=xlsx→ one workbook with all three sheets, filenamekrisid-lineage-<focus-slug>-<grain>-<YYYY-MM-DD>.xlsx, and a subtitle naming the counts, direction, hops, what was folded and the export time, plus the words "a report, not an upload".fmt=csv→ the single sheet named bypart, filenamekrisid-lineage-<focus-slug>-<grain>-<YYYY-MM-DD>-<part>.csv, with a UTF-8 BOM.
On folding being written twice: _nest_tiers,
_first_level and _holder_at reimplement what the browser does, "on purpose
and kept honest by a check rather than by hope: verify_phase0.py groups a
picture through the API and through the export and insists the two agree.
Sharing one implementation would mean the browser asking the server every time
somebody moved a dropdown, which is a round trip for a question it can already
answer from what it has."
One bug worth knowing about, recorded in the code: a uid is a
UUID object here and a string in the query string, and said.get(UUID(...))
on a dict keyed by strings "is a lookup that misses every time and says
nothing" — hence the str.
GET /api/lineage-views — the saved readings that apply here
Query parameters: uid — required, the asset being looked at.
Docstring: "Every saved reading that applies to this asset, narrowest first. The order IS the precedence, so the screen does not get to have its own opinion about which one opens: it takes the first."
The order is: views pinned to this asset first, then to this type, then everywhere; within each, yours before other people's, then most recently updated.
What makes these different from saved views:
Scope is which id is set. An asset, a type, or neither — and neither means everywhere. There is no scope word to fall out of step with the ids.
The narrowest applicable one opens the screen. "Every data product opens at business level" and "except this awkward one, which opens at two hops" are both sayable, and the second wins where it applies.
Private until shared. A saved view over a list is a team's agreement about how to read a type; a diagram setting is usually somebody's own way of working, so it starts private and its owner decides.
Visibility is can_see_view(view_id, username, asset_id) in the database.
Response
{
"views": [
{"uid": "…", "name": "Business level, both ways",
"description": "…",
"settings": { … }, // whatever the screen saved; opaque to the server
"created_by": "r.iyer", "updated_at": "…",
"asset_uid": "…", // null unless scoped to an asset
"asset_name": "CUSTOMERS",
"type_code": "TABLE", "type_name": "Table", // null unless scoped to a type
"scope": "asset", // "asset" | "type" | "everywhere"
"mine": true,
"shares": [ // who else can see it
{"kind": "everyone"|"person"|"team"|"role",
"code": "a.khan"|"DATA_GOV"|"OWNER"|null,
"name": "A. Khan"|"Data Governance"|"Owner"|"everyone"}
]}
],
"type": {"code": "TABLE", "name": "Table"},
"me": "r.iyer",
"may": {"create": true, "share": false},
"share_options": { // EMPTY unless may.share
"people": [{"code": "a.khan", "name": "A. Khan"}],
"teams": [{"code": "DATA_GOV", "name": "Data Governance"}],
"roles": [{"code": "OWNER", "name": "Owner", "scope": "resource"}]
},
"opens_with": "<uid>" // the first view's uid, or null
}
may.create/may.share are may(username, 'VIEW_CREATE'|'VIEW_SHARE',
asset_id) — judged against the asset, "which is what makes a steward of
one domain able to save a view about it without holding anything
repository-wide."
share_options is sent only to somebody who may share: "A screen that
lists every colleague to a reader who cannot use the list is telling them
something they did not ask for."
Shares are fetched in one query for all the views: "a person with thirty saved
views should not cost thirty round trips to draw a picker" (_dress_views).
GET /api/lineage-views/{uid} — what a link opens
Query parameters: on — an asset uid, optional.
Docstring:
A link is not the same question as the picker. The picker asks "what applies to this asset"; a link says "this one, please", and it may be a view saved somewhere else entirely that somebody pasted into a ticket. So this fetches it by id and answers the only question that still matters: may this person see it.
onis the asset the reader is standing on, because a role share is judged there — the stewards of THIS table see it, whoever they are.
Refusals
| Condition | Status | Message |
|---|---|---|
| no such view | 404 | "That link points at a saved view that no longer exists. Whoever sent it may have deleted it." |
can_see_view says no |
403 | "That link points at <creator>'s saved view “<name>”, which has not been shared with you. Ask them to share it, and the same link will open it." |
The 403 names whose it is rather than hiding it: "knowing whose it is turns a dead end into a message you can send to one person."
Response: {"view": { … }} — one view in the shape above.
POST /api/lineage-views — save a reading
Permission: VIEW_CREATE, judged against the asset named by uid (or
globally when there is none). VIEW_SHARE, also judged there, if shares is
given.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string | yes | stripped |
settings |
object | yes | a non-empty dict; stored as JSON, never inspected |
scope |
string | yes | asset, type, everywhere |
uid |
uid | for asset and type |
the asset; its type is resolved for type scope |
description |
string | no | stripped, null when empty |
shares |
list | no | [{kind, code}] — saving and sharing in one go |
Refusals
| Condition | Status | Message |
|---|---|---|
| no name | 400 | "A saved view needs a name — it is how you will find it again." |
settings not a non-empty dict |
400 | "There are no settings to save." |
an unknown scope |
400 | "A view is saved on this asset, on its type, or everywhere — nothing else." |
| a share kind that is not one of the four | 400 | "A view is shared with everyone, a person, a team, or whoever holds a role here." |
| a share naming something that does not exist | 400 | "There is no <person/team/role> called <code>." |
| the same name on the same thing | 409 | "You already have a saved view of that name on the same thing. Rename it, or change the one you have." |
SHARE_KINDS = ("everyone", "person", "team", "role"). A
person share is matched on app_user.username, a team on
user_group.code, a role on app_role.code.
Response: {"uid": "<new uid>"}.
PUT /api/lineage-views/{uid}
Permission — and the order of the two checks is load-bearing:
Who it is shared with is judged BEFORE the manager gate, not after. Asked the other way round, a manager asking to share somebody's private view is refused for not holding a permission — which reads as "get the permission and you may", and the answer is that nobody may. Whose view it is comes first; what you hold comes second.
So: shares in the body and not the owner → 403
"“<name>” is <creator>'s, and who else can see it is theirs to decide. You
can rename it or tidy it away, with METAMODEL_MANAGE, but not hand it to
anybody." Otherwise, not the owner → METAMODEL_MANAGE.
Body — every field optional; at least one of name, settings,
description, shares.
| Field | Notes |
|---|---|
name |
stripped; empty → 400 "A saved view needs a name." |
settings |
a non-empty dict; else 400 "There are no settings to save." |
description |
stripped, null when empty |
shares |
replaces the whole list; VIEW_SHARE checked inside, against the view's own asset |
Refusals: 404 "There is no saved view with that id."; 400 "Nothing to
change."
Response: {"uid": "<uid>"}.
DELETE /api/lineage-views/{uid}
Owner always; anybody else needs METAMODEL_MANAGE.
404 "There is no saved view with that id." Response {"removed": "<uid>"}.