Browsing: overview, roll-up, estate, layers, type lists
The API calls behind browsing: overview, roll-up, estate, layers, type lists.
On this page
GET /api/overview— there isn't oneGET /api/rollup/{type_code}— one card per groupingGET /api/rollup/{type_code}/{uid}— one grouping's membersPOST /api/views/generated— the standard view behind a countGET /api/tree— the top of the estateGET /api/tree/{uid}— one level downGET /api/layer-tree— a layer as it nestsGET /api/layer-tree/{uid}— one level down, inside the layer- The type list —
#/type/<CODE>?<query> GET /api/views?type=<CODE>— the view catalogue and the column vocabularyGET /api/assets— a page of assetsGET /api/table— the rows behind a viewGET /api/hierarchy— a view as a treeGET /api/table/export— the view, as a fileGET /api/hierarchy/export— the hierarchy, as an outlineGET /api/gone-plan— what clearing this list would takePOST /api/clear-gone— clear what the list is showing
Part of the HTTP API reference.
Five routes, each with its own small set of calls.
| Route | Draw function | Calls |
|---|---|---|
#/overview |
drawOverview |
GET /api/stats |
#/rollup/<CODE> |
drawRollup |
GET /api/rollup/{type_code} |
#/rollup/<CODE>/<uid> |
drawRollupDetail |
GET /api/rollup/{type_code}/{uid} |
#/estate |
drawTree |
GET /api/tree, then GET /api/tree/{uid} per node opened |
#/layer/<group> |
drawLayerTree |
GET /api/layer-tree?group=…, then GET /api/layer-tree/{uid}?group=… per node |
#/type/<CODE>?<query> |
drawList |
see below |
GET /api/overview — there isn't one
The Overview screen is GET /api/stats, documented under Screen 1.
GET /api/rollup/{type_code} — one card per grouping
{type_code} is upper-cased by the handler and must be one of
ROLLUP_TYPES (from facets.ROLLUP_TYPES, settable as
KRISID_ROLLUP_TYPES=DATA_DOMAIN,BUSINESS_UNIT).
Refusal: 404 "<CODE> is not configured as a grouping type".
Docstring — the performance note is the interesting part:
COUNTED IN THE DATABASE, not in Python. It used to read every (grouping, member) pair out of
rolled_up— then build a dictionary of all of them, then issue onecount(*)per grouping over an array of every member id. On a repository where one domain owns a warehouse that is a million rows over the connection and a million-element array sent back, to draw a card that says a number. The screen never wanted the pairs.ONE statement now, whatever the size — and one walk, which matters more: written as two statements it asked
rolled_uptwice and the screen went from 6.5s to 13.3s on ten million assets, because the walk is the whole cost.
Response
{
"type_code": "DATA_DOMAIN",
"groups": [ // ordered by name; empty list when there are none
{"uid": "…", "name": "Customer",
"summary": "…", // DEFINITION or DESCRIPTION, whichever sorts first
"total": 4120, // everything it owns, directly or by inheritance
"by_type": [ // in metamodel display order
{"code": "TABLE", "plural": "Tables", "n": 212}
],
"co_owned": 44} // how many of its members are owned by another grouping too
]
}
co_owned is a count, not a list: "the detail screen names them per row under
'Also owned by'."
What membership means (_assigned): an asset belongs to a
grouping because someone said so — there is an ownership relation pointing at
it — or because the thing that contains it does. A column has no domain of
its own; it takes its table's. An asset may be owned by several at once, and an
asset with its own assignment keeps it and does not also inherit — "saying so
explicitly is how a steward overrides what containment would imply."
The rule itself lives in the database as rolled_up, read here through
rolled_up_all. It used to be a Python walk, "and that was correct and it
was in the wrong place, because a saved view's conditions are compiled SQL and
could not call it… two implementations of one sentence, and a day when a card
says 213 and the view behind it says something else, with nothing admitting
it." 15.7s became 0.02s on ten million.
GET /api/rollup/{type_code}/{uid} — one grouping's members
{uid} is the grouping asset's uid, matched against the groupings of that type.
Refusals: 404 "<CODE> is not configured as a grouping type"; 404
"no such group".
Response
{
"uid": "…", "name": "Customer", "summary": "…",
"total": 4119, // members, excluding the grouping itself
"groups": [ // one per member type, in display order
{"type_code": "TABLE", "plural": "Tables", "origin": "harvested",
"order": 4, // the type's display_order
"items": [ // sorted by hops, then name
{"uid": "…", "name": "CUSTOMERS",
"external_key": "PROD.SALES.CUSTOMERS",
"hops": 2, // 1 = assigned directly; 2+ = inherited through that many containment steps
"via": "SALES", // the one-hop-back name — "why is this here"
"shared_with": ["Finance"]} // other groupings owning it, by name, sorted
]}
]
}
With no members at all the response is
{"uid","name","summary","groups": [], "total": 0}.
via "answers 'why is this here' without making the reader reconstruct the
whole chain."
POST /api/views/generated — the standard view behind a count
Called by countButton when somebody presses the number on a
roll-up card, which then navigates to
#/type/<CODE>?view=<uid>&c=<the domain condition>.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
type_code |
string | yes | the member type |
rollup_type |
string | yes | must be in ROLLUP_TYPES |
Refusals: 404 for a grouping that is not configured as one, and 404
"There is no asset type called …" naming the code — twice, once for the
grouping and once from _columns_available for a column's type.
The four decisions in the docstring, all of them load-bearing:
Why the domain is not in the view. Because a view is shared. Writing the clicked domain into it would mean the last person to click quietly redefines what everybody else's "Tables by Data Domain" means, and nothing on the screen would say so. And a view per domain per type is six domains times eleven types of near-identical rows filling a dropdown that is meant to help somebody find a view.
Why it matches on a key and not on the name. The name is the convention a person reads — "Tables by Data Domain". The key is what this matches on, so that a steward who renames it still gets the next release's columns rather than a second view appearing beside theirs with the old name.
What a second call does. Refreshes the definition — but only while nobody has edited it. Once a person has, their arrangement is the one that opens… An auto-refresh that overwrote somebody's chosen columns would teach them never to touch it.
The key is rollup:<GROUPING>:<CODE>; the name is
"<member plural> by <grouping name>"; created_by/updated_by are
GENERATED_BY = "krisid" — "a view nobody chose to make should
not carry somebody's name, and the next release's improvements to it should not
need that person to still work here."
Which columns it opens on: whatever the connector feeding this type
suggests (opens_with), plus — for an authored type only — the relation
naming the grouping type. The authored-only rule is the judgement:
Every type in the model may declare an "owned by Data Domain" relation, so the relation existing proves nothing. An authored asset NAMES its domain: a steward set it, and the column reads. A harvested one almost never does — a table takes its schema's domain — so the same column would be blank down the whole page and read as missing data rather than as a question asked of the wrong layer.
If that leaves no columns at all, it falls back to
[{"kind": "field", "code": "external_key", "label": "Key"}], because a view
needs a column and the key always says something.
Response
{"uid": "…", "name": "Tables by Data Domain",
"made": true, // it did not exist and was created
"refreshed": false} // it existed, nobody had touched it, and its definition was updated
When it existed and has been edited by a person, name is the stored name
and both flags are false.
GET /api/tree — the top of the estate
Docstring (_tree_roots): "The top of the estate: the same
for everybody, so built once a change. It takes no actor and reads only the
repository, which is what makes it safe to cache here. Measured at ten million:
0.31s a call, on a screen opened on almost every visit — and 1.82s each when
ten people were doing it at once, for an answer that could not differ between
them." Cached by _derived under tree_roots.
What a root is, and why the two walks differ:
Two different questions, and they were answered with one set for too long. WHERE the tree starts is a question about the estate: a root is a harvested type nothing harvested contains. How far it goes DOWN is not — it is just containment, and stopping at the harvested ones cut the tree off at a semantic model, hiding the metrics inside it for no better reason than that a person may edit them.
So the roots come from _containment(cur, harvested_only=True) and the
has_children test from _containment(cur, harvested_only=False).
Containment is derived, not hardcoded: a containment relation
is one the metamodel already describes as such — origin = 'harvested',
source_to_target = '1:N', target_to_source = '1:1'. "Add a Snowflake
STAGE, a dbt MODEL or a Power BI DATASET to the workbook with a 1:N / 1:1
harvested relation, and it appears in this tree with no code change."
Response
{"technologies": [ // grouped by the connector's own name, sorted case-insensitively
{"name": "Snowflake",
"systems": [ // sorted by lower(name)
{"uid": "…", "name": "ACME Production", "external_key": "acme-prod",
"properties": {"platform": "snowflake", …},
"harvested_at": "…", "source_system": "SNOW_PROD",
"type_code": "SOURCE_SYSTEM", "type_name": "Source System",
"has_children": true,
"summary": "…"}
]}
]}
With no harvested asset types at all:
{"technologies": [], "note": "no harvested asset types in the metamodel"}.
The technology is a harvested property of the system, not a type of its
own, and is shown by the connector's name ("Power BI") rather than the
platform's code ("powerbi"). A system still called by its key gets the
connection's name instead (_system_names).
GET /api/tree/{uid} — one level down
Called lazily by treeNode when a node is opened.
Docstring: "One level down. Lazy, because a warehouse has more columns than a page."
Response — grouped by type "so the reader sees 'Tables 2, Views 1', not one long list":
{"groups": [
{"type_code": "TABLE", "plural": "Tables",
"items": [
{"uid": "…", "name": "CUSTOMERS", "external_key": "PROD.SALES.CUSTOMERS",
"properties": { … },
"type_code": "TABLE", "type_name": "Table", "plural": "Tables",
"label": "holds", // the relation's forward_label
"has_children": true,
"summary": "…"}
]}
]}
Child order (CHILD_ORDER): by the type's display_order,
then by the harvested ordinal property as an integer where it looks like one,
nulls last, then by name — "so a table reads in the order it was defined
rather than alphabetically."
GET /api/layer-tree — a layer as it nests
Query parameters
| Name | Type | Required |
|---|---|---|
group |
string | yes — an asset_type.type_group value |
Refusal: 404 "There is no layer called <group>." when no asset type
carries that type_group.
Docstring: "A layer as it nests — the logical one is Subject Area, Data Entity, Data Attribute. Read from the metamodel, like the estate: the relations inside the layer that are one-to-many one way and one-to-one back. The top is whatever is under nothing, so a data entity in no subject area is not lost — it is at the top, said so."
Response
{
"group": "Logical",
"roots": [ // _grouped, one entry per type
{"type_code": "DATA_ENTITY", "plural": "Data Entities",
"label": "Data Entities not under any Subject Area", // see below
"items": [
{"uid": "…", "name": "Customer", "external_key": "…",
"properties": { … }, "type_code": "DATA_ENTITY",
"type_name": "Data Entity", "plural": "Data Entities",
"display_order": 11, "summary": "…", "has_children": true}
]}
],
"chain": ["Subject Areas", "Data Entities", "Data Attributes"] // top to bottom
}
With edges inside the layer but no relations at all:
{"group": "<group>", "roots": [], "chain": []}.
The label is the plural, unless this type could sit under another type in
the layer, in which case it says so: "<plural> not under any <A> or <B>".
chain is ordered by how many containment steps down from a type that is under
nothing.
GET /api/layer-tree/{uid} — one level down, inside the layer
Query parameters: group (required, same meaning).
Response: {"groups": [ … ]} — the same _grouped shape as roots
above, without the label.
The type list — #/type/<CODE>?<query>
drawList(code, query). The address can arrive with a narrowing
already applied, which is what pressing a count on a roll-up card does: "The
conditions live in the ADDRESS rather than in the saved view, so the link can
be sent to somebody, reloaded, or gone back to, and still mean the same thing;
and so that one shared view can serve every domain."
An unknown CODE is caught in the client and draws
"Not found / There is no asset type called <CODE>." without a request.
The order of calls
GET /api/views?type=<CODE>— always. This is the view catalogue and the column picker's whole vocabulary.- Then exactly one of three, by what the current view holds:
- the view has a hierarchy →
GET /api/hierarchy?…- the view has columns →GET /api/table?…- neither →GET /api/assets?type=<CODE>&limit=2000 - On demand, the export box sets
window.locationto one ofGET /api/bulk/export?type=…,GET /api/hierarchy/export?…,GET /api/table/export?…. - On demand, the clear-gone panel:
GET /api/gone-plan?…thenPOST /api/clear-gone. - Saving the view:
POST /api/views,PUT /api/views/{uid},DELETE /api/views/{uid}(Screen 2).
GET /api/views?type=<CODE> — the view catalogue and the column vocabulary
Query parameters: type — an asset type code, stripped and upper-cased.
Refusal: 404 "There is no asset type called <CODE>."
Response
{
"type": {"id": 4, "code": "TABLE", "name": "Table", "plural": "Tables",
"origin": "harvested"},
"views": [
{"uid": "…", "name": "Tables by Data Domain", "description": "…",
"columns": [ … ], // the saved column list, as JSON
"filters": [ … ],
"hierarchy": {"levels": [ … ]}, // or null
"created_by": "krisid", "updated_at": "…"}
],
"opens_with": [ // what the list opens on when nobody has chosen columns
{"code": "ROW_COUNT", "name": "Row count", "kind": "attribute"}
],
"available": { // the whole column vocabulary, from the metamodel
"attributes": [{"code","name","data_type","is_mandatory","allowed_values"}],
"roles": [{"code","name"}], // every ACTIVE resource-scope role
"relations": [{"code","dir","label","other","other_type_code","cardinality"}],
"properties": [{"code","name"}], // harvested properties this type can show
"fields": [{"code","name"}] // FIELD_COLUMNS: key, created, created by, changed, changed by
}
}
opens_with is "a suggestion from whichever connector feeds this type, not a
setting: choose columns and that choice is what is remembered."
The four kinds of column, and not one named in code:
| Kind | Letter | What it is |
|---|---|---|
attribute |
a |
whatever the metamodel declares for this type |
role |
r |
who holds it here, including inherited, marked as such |
relation |
x |
what this is linked to, by any relation type its type takes part in |
field |
f |
the record's own: key, created, created by, changed, changed by |
property |
p |
a harvested property, straight off the asset's JSON document |
The compact URL form is a:STATUS,r:OWNER,x:TERM_DEFINES_ENTITY:out,f:external_key,p:row_count
(_parse_columns). The letters are not kind[0], because
"role" and "relation" both start with r "and two different columns answering to
one key is the kind of collision that shows up later as a cell in the wrong
column."
For property, lower case is load-bearing: direction and DIRECTION are
different keys in a jsonb document, and the connectors write them in lower
case. A property that has since become an attribute is silently rewritten
to the attribute, "so nothing someone saved goes blank."
FIELD_COLUMNS — two of these are not columns on asset at all:
created_by is the actor on the row's own insert in change_log, and
changed_by is the actor on the most recent change_log row that touched the
asset "including a change to one of its attributes or links, which is what a
reader means by 'last changed', not 'the asset row was updated'."
GET /api/assets — a page of assets
The plain list, and also the global asset list when no type is given.
Query parameters
| Name | Type | Default | Clamped to | Notes |
|---|---|---|---|---|
type |
string | null |
— | an asset type code, matched exactly |
q |
string | null |
— | name ILIKE %q% OR external_key ILIKE %q% |
limit |
int | 200 |
max(1, min(limit, 2000)) |
the client asks for 2000 |
Proposals are always excluded (a.proposed_by IS NULL).
Docstring — the whole reason the query is shaped as it is:
ONE PAGE FROM EACH TYPE, THEN MERGED, rather than the estate sorted.
ORDER BY t.display_order, a.name LIMIT 200cannot be served by any index, because the sort key spans two tables… So Postgres read every asset and top-N sorted it to show two hundred: measured on ten million,EXPLAINsays Parallel Seq Scan on asset, rows=3333717, loops=3 and the request took 6.3 seconds.The same rows come out of a page per type, merged and cut to one page… a merge of at most
limitrows from each type contains the firstlimitof the whole, because nothing beyond a type's own firstlimitcan be in the firstlimitoverall. Measured: 6.3s to under 10ms, identical rows.
a.idlast, so the order is TOTAL.(display_order, name)is not unique — this repository has two columns both called CHANNEL — and a tie broken by whichever plan ran means the list reorders under somebody between identical requests.The description is looked up AFTER the page is cut, not for every candidate row: with twenty-three types and a page of two thousand that would be forty-six thousand probes to show two thousand.
Response — a bare JSON array, not an object:
[
{"uid": "…", "name": "CUSTOMERS", "external_key": "PROD.SALES.CUSTOMERS",
"type_code": "TABLE", "type_name": "Table", "origin": "harvested",
"source_status": "Missing in Source", // properties->>'source_status', or null
"absent_since": "2026-09-17", // properties->>'absent_since', or null
"summary": "…"} // DEFINITION or DESCRIPTION
]
source_status and absent_since ride on every row on purpose: "an asset
nobody opens is exactly the one whose absence goes unnoticed."
Ordered by display_order, name, id.
GET /api/table — the rows behind a view
Query parameters
| Name | Type | Default | Notes |
|---|---|---|---|
type |
string | required | upper-cased |
cols |
string | "" |
the compact spec: a:STATUS,r:OWNER,… |
q |
string | null |
searches name and external_key |
view |
uid | null |
a saved view's id; supplies columns and filters |
filters |
JSON string | null |
conditions; wins over the view's |
Refusals
| Condition | Status | Message |
|---|---|---|
type names nothing |
404 | "There is no asset type called <CODE>." |
view names nothing |
404 | "There is no view with that id." |
view is a saved search (asset_type_id IS NULL) |
400 | "That is a saved search, not a view of one type. Open it on Ask." |
filters unreadable |
400 | "The filters could not be read." |
| a condition the compiler refuses | 400 | facets.Bad's sentence |
The saved-search refusal is named rather than silent: "Its conditions would compile and its rows would come back, so nothing would look broken — the list would simply be answering a narrower question than the one that was saved, with no way to tell."
Response
{
"type": {"id","code","name","plural","origin"},
"columns": [ // only the columns the metamodel STILL has
{"kind": "attribute", "code": "STATUS", "label": "Status", "key": "a:STATUS"}
],
"rows": [
{"uid": "…", "name": "CUSTOMERS",
"source_status": null, "absent_since": null,
"cells": {
"a:STATUS": {"text": "Approved", "raw": "Approved"},
"r:OWNER": {"text": "A. Khan (inherited)",
"holders": [{"name": "A. Khan", "inherited": true, "from": "SALES"}]},
"x:OWNED_BY:out": {"text": "Customer",
"links": [{"name": "Customer", "uid": "…"}]},
"f:created_at": {"text": "31 Jan 2026", "raw": "2026-01-31"},
"p:row_count": {"text": "412000", "raw": "412000"}
}}
],
"filters": [ … ], // as applied
"matched": 412, // _how_many — exact below 10000, a floor above
"capped": false, // true means `matched` is a FLOOR: "more than 10,000"
"of": 1204, // how many of this type there are at all
"page": 2000, // TABLE_ROWS — how many rows one comparison may carry
"cut": false // true when `rows` is the first `page` and not all of them
}
matched, of and rows are three different numbers and the screen now says
all three. It used to print matched of of and use neither capped nor the
row count: on 17,040 columns with a condition matching all but a few, it read
"10000 of 17040 match" — the ten thousand being the count ceiling, so more
than ten thousand matched — above two thousand rows, with nothing saying
either. The only way to discover the other eight thousand was to count the rows
on the screen.
A column the metamodel no longer has is dropped rather than erroring — "a view saved last month should lose a retired column and still open."
Every cell carries text for the screen and, where it differs, raw for
comparison: "'31 Jan 2027' is what a person should read and the wrong thing to
compare a date filter against."
source_status rides on the row rather than waiting to be chosen as a column
because "whether something is still at the source… decides what every other
cell on the line MEANS — and a reader comparing forty tables should not have to
have guessed in advance that one of them is gone."
Row cap: _table_rows takes limit=2000 by default with
ORDER BY a.name. Conditions go into the statement (facets.compile);
they used to be applied in Python to whatever the first two thousand rows
happened to be, "which on a real estate answers a different question from the
one asked."
of is counted, not inferred from the page: "with the conditions now
inside the statement there is no 'before filtering' list to take a length from,
and there never should have been — it was the length of a capped read."
Operators (OPERATORS), applied to the assembled cells:
contains, not contains, is, is not, empty, not empty, before,
after, greater than, less than, inherited, assigned here. A filter
naming a column that is not on the view is ignored — "a filter you cannot
see is a filter you cannot remove."
Dates written as words about today (today, today + 30 days) are resolved at
request time by _today_value, "the same rule the database uses
for the same words, so a workflow's path and a saved view agree about what today
is."
GET /api/hierarchy — a view as a tree
Query parameters
| Name | Type | Default | Notes |
|---|---|---|---|
type |
string | required | upper-cased |
cols |
string | "" |
the compact spec for the top level |
levels |
JSON string | null |
a list of levels, or {"levels": [ … ]} |
view |
uid | null |
a saved view; supplies columns, filters and the hierarchy |
filters |
JSON string | null |
wins over the view's |
q |
string | null |
searches the top level |
A level (_clean_hierarchy) — at most 8 levels:
{"relation": "SUBJECT_AREA_HOLDS_ENTITY", // upper-cased
"dir": "out", // "in" | anything else → "out"
"columns": [ … ], // this level's own columns
"deep": false} // follow a self-relation as far as it goes
Refusals
| Condition | Status | Message |
|---|---|---|
levels unreadable JSON |
400 | "The levels are not readable." |
view names nothing, or names a saved search |
404 | "There is no view of a type with that id." |
| no hierarchy at all | 400 | "This view has no hierarchy. Choose a relation for the level under it first." |
| a level's relation does not exist | 400 | "There is no relation called <CODE>." |
| a level starts somewhere other than where the one above ends | 400 | "Level <n> starts from <A>, but the level above it is <B>. Each level has to start where the one above ends." |
A deep level on a relation from a type to itself is followed up to
MAX_DEEP = 12 rounds. Child rows at each level are fetched with
limit=20000.
Response
{
"type": {"id","code","name","plural","origin"},
"levels": [
{"type_code": "SUBJECT_AREA", "type_name": "Subject Area",
"plural": "Subject Areas", "columns": [ … ], "label": "Subject Area"},
{"type_code": "DATA_ENTITY", "type_name": "Data Entity",
"plural": "Data Entities", "columns": [ … ],
"label": "Data Entity (contains)", // "(…, 3 down)" on a deep level
"word": "contains"} // the relation's label in this direction
],
"tree": [ // _strip: the nested form
{"uid": "…", "name": "Sales", "cells": { … },
"source_status": null,
"children": [ { … same shape … } ]}
],
"rows": [ // _flatten: one row per PATH to the bottom
[{"uid": "…", "cells": ["Sales", "…"]}, // cells are the NAME then each column's text
{"uid": "…", "cells": ["Customer", "…"]}]
],
"paths": 412 // how many such rows
}
"One row per path to the bottom; a thing with nothing under it is a row of its
own with the levels below it empty" (_flatten).
Nothing about which relations make a hierarchy is written in code: "any relation the metamodel has is a level somebody may choose."
GET /api/table/export — the view, as a file
Reached by window.location from the export box.
Query parameters: type (required), cols, q, view, filters, plus
fmt — "xlsx" (default) or "csv".
Refusals: the same as GET /api/table, including the saved-search one with
"Export it from Ask." instead of "Open it on Ask."
Docstring — what makes this one a report rather than a loader input:
Exactly what is on the screen, including the columns a loader could never read back — inherited responsibilities, related assets, who last changed it. That is the point of this one: it is a report.
The other kind of export, the one that goes back in, is
/api/bulk/exportand always has been: the loader needs its own shape, with the key column and the delete column and one sheet per type, and giving it a different set of columns each time would make the round trip a matter of luck.The export is what the screen shows, which means the conditions too. A file that quietly contained the rows you had just filtered out would be the opposite of a report — and one filtered after a capped read would quietly leave rows OUT, which is worse.
Response: a file.
fmt=xlsx→application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,Content-Disposition: attachment; filename="krisid-<type-slug>-<YYYY-MM-DD>.xlsx". Headers are["Name"] + [each column's label].fmt=csv→text/csv; charset=utf-8, filename…-<date>.csv, body prefixed with a UTF-8 BOM — "without it Excel reads a UTF-8 CSV as Latin-1 and turns every name with an accent in it into mojibake."
GET /api/hierarchy/export — the hierarchy, as an outline
Query parameters: type (required), cols, levels, view, filters,
q. Same refusals as GET /api/hierarchy.
Docstring: "an outline, one row per asset under its parent, indented by level and grouped so each branch folds."
Headers are ["Name", "Relation", "Asset Type"] then every level's columns
once each — "a column two levels share (the same attribute on both) is one
column, filled by each row from its own asset."
The Relation cell is said whole — "Subject Area contains Data Entity" —
"so a row read on its own, filtered out of its branch, still makes sense."
The asset type is "as the metamodel names it today, so a rename follows."
Response: xlsx, filename
krisid-<type-slug>-hierarchy-<YYYY-MM-DD>.xlsx. view_io.export is passed
depths so Excel can fold each branch.
GET /api/gone-plan — what clearing this list would take
Called by clearFromList, which swallows any failure and shows
nothing.
Query parameters
| Name | Type | Default | Notes |
|---|---|---|---|
type_code |
string | null |
narrow to one type |
q |
string | null |
name ILIKE %q% OR external_key ILIKE %q% |
source |
string | null |
asset.source_system |
days |
int | null |
overrides the setting; max(0, days) |
Docstring: "A warehouse decommissioned across four databases is not a subtree, and the hierarchy this feature is framed around cannot express it. A Browse list filtered to one type can. It takes the FILTER, not the rows. The screen sends what it was showing and the set is derived here. Sending ids would move the decision about what gets deleted out of the server and into whatever posted the request."
Response (_gone_plan)
{
"name": "every table matching \"orders\" in SNOW_PROD", // the filter, in words
"type_name": "", // empty for the list form
"itself": false, // the subtree form only: whether the node itself is gone
"days": 30, // the cut-off in days
"cutoff": "2026-09-01", // the date it resolves to
"any": true // whether ANYTHING matches — EXISTS(… LIMIT 1), not a count
}
Why it says the rule rather than the number:
It used to return how many would go, of what kinds, with examples. That meant materialising the whole set on every look, and on a real estate the answer is both slow to get and not one anybody reads: nobody checks a list of three hundred thousand columns before pressing a button.
So it says the RULE instead, which is the thing a person actually needs to agree to…
anyis the one fact still read from the estate, and it stops at the first row.
The default cut-off is gone_clear_min_days, 30 if unset (_clear_days). A setting somebody has typed a word into falls back to 30, not
0 — "the failure of a number to parse must never be the most destructive
reading of it."
POST /api/clear-gone — clear what the list is showing
Permission: ASSET_DELETE over the whole repository
(_may(cur, who, "ASSET_DELETE", None, "clear what has gone", "")), plus
ASSET_DELETE and HARVESTED_DELETE asked per asset in SQL.
Body — may be absent entirely (Body(default=None)):
| Field | Notes |
|---|---|
type_code |
as the plan |
q |
as the plan |
source |
as the plan |
days |
as the plan |
Response (_clear)
{
"cleared": 4120, // actually deleted
"refused": 0, // always 0 on this path; see the note below
"held": 18, // lifted into change requests because a workflow governs deletion
"may_not": 212, // this person may not delete them
"requests": [
{"uid": "…", "workflow": "Deletion approval", "n": 18}
],
"detail": "4120 cleared, 18 held for “Deletion approval”, 212 kept because you may not delete them. Only things marked Missing in Source for more than 30 day(s) were touched."
}
Refusal: if the single delete statement is refused by the database, the
whole thing is rolled back and answered 409:
"Nothing was cleared. The database refused the delete, which means something
outside what was being cleared still depends on one of these. It said — <the
database's own sentence>"
The three statements, and why it is not a loop:
The old shape read every id into Python, checked each one's permissions in its own round trip, then deleted them one at a time. That is fine for forty and impossible for four hundred thousand — and four hundred thousand is the number a decommissioned warehouse actually produces.
held — the rows a deletion workflow governs are lifted out first, into one request per workflow. Nothing of theirs is deleted. mine — of the rest, the ones this person may delete.
mayis a database function, so the access model is applied set-wise rather than a round trip per row — the SAME rule, asked once. gone — deleted, deepest first, in one statement.
"Deepest first" is what the ORDER BY does, "so nothing is ever briefly under
a thing that has gone."
Why the set is derived and not sent: the three rules in the section header — the scope is a subtree or a filter, not a list of ids; the node goes too if the node is also missing; and "it goes down the ordinary delete road. Same ASSET_DELETE, same HARVESTED_DELETE, same deletion workflows, same database refusals. A bulk button that quietly held more authority than the person pressing it would be a way round the access model wearing a broom."
Why there are two predicates for one test (DOOMED_LIST):
properties ->> 'source_status' = … cannot use the GIN index, "so this read
every asset in the repository — 705ms on ten million, and the screen asks it
several times." The @> containment form is the same test in the form the
index answers, and the ->> stays as an exact recheck so the two cannot drift.
705ms → 0.55ms.