Docs › Reference

For everyone

Browsing: overview, roll-up, estate, layers, type lists

The API calls behind browsing: overview, roll-up, estate, layers, type lists.

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 one count(*) 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_up twice 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

  1. GET /api/views?type=<CODE> — always. This is the view catalogue and the column picker's whole vocabulary.
  2. 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
  3. On demand, the export box sets window.location to one of GET /api/bulk/export?type=…, GET /api/hierarchy/export?…, GET /api/table/export?….
  4. On demand, the clear-gone panel: GET /api/gone-plan?… then POST /api/clear-gone .
  5. 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 200 cannot 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, EXPLAIN says 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 limit rows from each type contains the first limit of the whole, because nothing beyond a type's own first limit can be in the first limit overall. Measured: 6.3s to under 10ms, identical rows.

a.id last, 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/export and 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… any is 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. may is 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.