Docs › Reference

For everyone

One asset

The API calls behind one asset.

Part of the HTTP API reference.

Route: /^#\/asset\/<uuid>$/ → drawAsset(uid). The regex is the full 8-4-4-4-12 form, so a short id never reaches the server.

The order of calls

Four in sequence, then four conditional, then everything else lazily.

  1. GET /api/assets/{uid} — the asset, whole.
  2. GET /api/may/{uid} — what this person may do here. .catch( => ({permissions: []})), so a failure means "nothing".
  3. GET /api/workflow/governing/{type_code}?asset=<uid> — does anything govern changes to this.
  4. If governed: GET /api/requests?asset=<uid>&open=1.
  5. If governed: GET /api/change-sets?asset=<uid>, with a .catch( => ({drafts: []})).

Lazily, each on opening its own section:

Section Call
History (and "more") GET /api/assets/{uid}/history?limit=50&offset=<n>
Link adder GET /api/assets/{uid}/relatable
Asset picker GET /api/candidates?…
Responsibility adder GET /api/access and GET /api/people in parallel
Reach (a source role only) GET /api/assets/{uid}/reach
Delete panel GET /api/assets/{uid}/delete-plan
Clear-gone panel GET /api/assets/{uid}/gone-plan
SQL panel POST /api/lineage/parse
Writes, each on its own control:
Control Call
Rename PATCH /api/assets/{uid} {name}
Change the key PATCH /api/assets/{uid} {external_key}
Set a field PUT /api/assets/{uid}/attributes/{code}
Link POST /api/assets/{uid}/relations/bulk
Unlink DELETE /api/assets/{uid}/relations/{id}?propose=1
Assign POST /api/assets/{uid}/responsibilities
Unassign DELETE /api/assets/{uid}/responsibilities/{ruid}
Delete DELETE /api/assets/{uid}?branch=<0|1>
Clear gone POST /api/assets/{uid}/clear-gone
Add to a draft POST /api/change-sets/items
Drop one from a draft DELETE /api/change-sets/{uid}/items/{n}
Submit the draft POST /api/change-sets/{uid}/submit
Discard the draft DELETE /api/change-sets/{uid}
Accept parsed lineage POST /api/lineage/accept

GET /api/assets/{uid} — one asset, whole

Query parameters

Name Type Default Notes
peek bool false true does not record a visit

Docstring: "peek is a screen reading it for somebody who has not opened it — Ask's best-match card — and is not a visit, so it does not go into their recently viewed."

Refusals: 404 "'<uid>' is not an asset id" (malformed); 404 "no asset with that id"; 404 "no such asset".

The proposal rule: if proposed_by is set and the person asking is not the proposer, not somebody who can decide on an open request about it, and does not hold ACCESS_MANAGE (_may_see_proposal), the answer is 404 "no such asset" — "which is true for them."

Response

{
  "uid": "…",
  "name": "CUSTOMERS",
  "external_key": "PROD.SALES.CUSTOMERS",
  "properties": {"row_count": 412000, "source_status": "In Source", …},
  "source_system": "SNOW_PROD",       // which connection catalogued it
  "harvested_at": "…",
  "created_at": "…", "updated_at": "…",
  "proposed_by": null,                // the username, while it is a proposal
  "type_code": "TABLE", "type_name": "Table",
  "origin": "harvested",
  "type_description": "…",

  // present only on a proposal:
  "proposal": {"request": "<uid>", "outcome": null},

  "attributes": [                     // EVERY attribute the metamodel declares, valued or not
    {"code": "STATUS", "name": "Status", "data_type": "choice",
     "allowed_values": ["Draft", "Approved"],
     "is_mandatory": true,
     "value": null,                   // null means unset — "an empty mandatory field is information"
     "origin": "authored"}
  ],

  "relations": [                      // grouped by the WORDS a reader sees, not by code
    {"label": "holds",
     "items": [
       {"relation_id": 912,
        "relation_code": "TABLE_HOLDS_COLUMN",
        "label": "holds",             // forward_label outgoing, inverse_label incoming
        "cardinality": "1:N",
        "method": "declared",         // declared | parsed_ddl | inferred
        "uid": "…", "name": "EMAIL", "external_key": "…",
        "type_code": "COLUMN", "type_name": "Column"}
     ]}
  ],

  "responsibilities": [               // from responsibility_for, inherited included
    {"responsibility_uid": "…",
     "role_code": "OWNER", "role": "Owner",
     "depth": 2,                      // 0 = assigned here; 2+ = inherited
     "is_locked": false,              // inherit_locked above it
     "holder": "A. Khan",
     "kind": "person",                // person | team
     "holder_uid": "…",
     "from_uid": "…", "from_name": "SALES", "from_type": "Schema"}
  ],

  "inheritance": "inherit",           // the asset's own mode, or the repository default
  "inheritance_is_default": true,     // true when the asset has no mode of its own

  "history": [ … ]                    // the first 12 events, same shape as /history
}

What is deliberately NOT here — worth quoting, because it is the kind of thing that gets re-added:

NO subtree walk here, deliberately. This used to count what was gone underneath, so the Clear button could carry its own number. On a column that costs nothing; on a schema holding two hundred thousand of them it is a full walk of the tree, in Python, with the whole id list crossing the wire — on EVERY asset page view. It made the application slow for everybody, to put a number on a button.

A count is not worth a page load. The screen offers to clear and the panel says what will happen; nobody needs to be told how many before they have asked for it, and in a real estate the answer is "more than you want to read".

Harvested attributes with no value are hidden: "What a source says, only where it said something: an empty Owner at Source on a thing whose source has no owners is a blank nobody can fill." Authored attributes are returned whether set or not.

GET /api/may/{uid} — what the acting person may do here

Docstring: "The screen asks before it offers: an edit control that appears and then refuses is worse than one that was never there."

Response

{"actor": {"username": "r.iyer", "display_name": "Rohan Iyer"},
 "permissions": ["ASSET_VIEW", "ASSET_EDIT", "RELATION_MANAGE"]}

With no session, or a session whose person is inactive: {"actor": null, "permissions": []} — a 200, not a refusal.

permissions_of(username, asset_id) is the per-asset answer: a global role plus any resource role on this asset or on anything that passes responsibility down to it.

GET /api/assets/{uid}/history

Query parameters

Name Type Default Clamped
limit int 50 min(limit, 500) — no lower clamp
offset int 0 none

Response (_history)

{
  "events": [ … ],
  "has_more": true,
  "next_offset": 50
}

has_more is computed by asking for limit + 1 rows.

An event — kind decides which fields are present:

// kind: "attribute"
{"at": "…", "actor": "r.iyer",
 "actor_name": "Rohan Iyer", "actor_kind": "person",
 "action": "update", "kind": "attribute",
 "verb": "Attribute changed", "what": "Status", "code": "STATUS",
 "gone": false,                  // true when the attribute has since been removed from the metamodel
 "from": "Draft", "to": "Approved"}

// kind: "relation" — read from THIS asset's point of view
{"…": "…", "kind": "relation", "verb": "Relation added",
 "what": "holds",                // forward_label outgoing, inverse_label incoming
 "code": "TABLE_HOLDS_COLUMN", "gone": false,
 "other": "EMAIL",               // or "an asset since deleted"
 "other_uid": "…",               // null when it is gone
 "other_type": "COLUMN",
 "method": "declared"}

// kind: "responsibility"
{"…": "…", "kind": "responsibility", "verb": "Responsibility added",
 "what": "Owner", "code": "OWNER",
 "holder": "A. Khan", "holder_kind": "person",
 "from": null, "to": null}       // set only when an assignment was EDITED rather than replaced

// kind: "asset"
{"…": "…", "kind": "asset", "verb": "Asset changed",
 "what": "Name" | "Key" | "Row count" | …,
 "from": "…", "to": "…"}
// or verb "Asset created" / "Asset deleted", with what = the name and from/to null

actor_kind (_actor_names) is person, job or outside:

actor actor_name actor_kind
a known username their display name person
migration "setup" job
command line "the command line" job
grants seed "setup (source roles)" job
upload "a workbook upload" job
screen "the screen" job
system "the application itself" job
anything else "<name>, in the database" outside

The last is named rather than hidden: "it is the one kind of change nobody in the product can account for."

Three design notes worth keeping:

  • A change to harvested properties is one line per key that changed, in words, not two whole JSON documents — "printing both whole documents to say one key changed is a wall of braces."
  • An update on asset that touched only timestamps produces no row — "not worth a row." ASSET_FIELDS is exactly name, external_key and properties; source_system is excluded because "a repository harvested before it meant that would otherwise open every asset's history on a line about bookkeeping."
  • An attribute or relation type later removed from the metamodel keeps its name, read out of the log's own record of the removal (_gone), and is flagged gone: true — "'attribute #4' is not a history — it is a receipt for one."
  • Role, person and team names are looked up only when the page actually holds one of those events: "three queries per history page for nothing is three queries per history page for nothing."

GET /api/workflow/governing/{type_code}

Query parameters: asset — an asset uid, optional.

Docstring: "Does anything govern changes to this type — and, given an asset, to this one, whose scope may leave it out? The asset page asks before it decides whether an edit writes or proposes."

Refusal: 404 "There is no asset type called <CODE>."

Response: {"governed": false} or

{"governed": true, "uid": "…", "name": "Term approval", "version": 3}

The workflow chosen is _published_for(type_id, "manual", asset_id): those naming the type first, then those on every type, each group ordered by priority then id DESC, and the first whose scope holds for the asset.

PATCH /api/assets/{uid} — the asset's own two fields

Permission: ASSET_EDIT on this asset.

Body — one or both:

Field Notes
name stripped; empty → 400 "A name cannot be empty."
external_key stripped; empty → 400 "A key cannot be empty. It is what a re-load matches on, so clearing it would make the next upload create a duplicate."

Neither → 400 "Nothing to change."

Harvested assets are refused here and only here:

409 "“CUSTOMERS” is a harvested Table. Its name and key come from the source
     system, and the next harvest would put them back — so they cannot be
     edited here. Everything a person adds to it, the attributes,
     responsibilities and links, is yours to change."

"A rename that quietly reverts is worse than one that was never allowed."

The workflow gate: _api_guard(cur, who, asset_id, "edit") reads workflow_api_writes, which is direct, propose or refuse:

Setting Behaviour
direct (default) write as before
propose the write becomes a change request; 202
refuse 409 "“X” is governed by “W”, and direct writes to governed assets are refused here. Propose it instead: add it to a change set (POST /api/change-sets/items) and submit it."

The guard is never asked of the engine applying an approval (_AUTHORITY.on), nor of a write nested inside one it has already allowed (_BEYOND), nor while triggers are quiet (_QUIET.on).

Response on a direct write — the _asset_row shape:

{"id": 4120, "uid": "…", "name": "CUSTOMERS",
 "external_key": "PROD.SALES.CUSTOMERS",
 "type_code": "TABLE", "type_name": "Table", "origin": "harvested",
 "proposed_by": null}

Response when it became a proposal — 202:

{"proposed": true, "request": "<uid>",
 "detail": "Proposed — nothing has changed yet. A workflow decides it."}

PUT /api/assets/{uid}/attributes/{code} — one attribute

Permission: ASSET_EDIT on this asset.

{code} is upper-cased.

Body: {"value": <anything>}. null, missing, or a string that strips to empty means clear it.

Docstring: "Which column the value lands in comes from the metamodel's declared data type, and so does what counts as a valid value. Clearing a mandatory attribute is refused: the form has always shown mandatory fields whether or not they are filled, and letting the screen empty one would make that display a suggestion rather than a rule."

Refusals

Condition Status Message
the attribute is not declared for this type 400 "<CODE> is not declared for <Type>. Add a Type attributes row on the Metamodel screen first."
the attribute's origin is harvested 409 "<Name> comes from the source system and is not edited here. It is written by each harvest; change it in the source and harvest again. Your own words go in the attributes beside it."
clearing a mandatory one 400 "<Name> is required for a <Type> — it can be changed, but not left empty."
data_type is number and it is not 400 "<Name> is a number. '<text>' is not one."
data_type is date and it will not parse 400 "<Name> is a date. Write it as 2026-03-31 or 31 Mar 2026."
data_type is boolean and it is neither 400 "<Name> is Yes or No."
data_type is choice and it is not in the list 400 "<Name> must be one of: <A>, <B>."

How each type is parsed and where it lands

data_type Column Parsing
anything else value_text the stripped string
number value_num float(text.replace(",", ""))
date value_date %Y-%m-%d, %d %b %Y, %d/%m/%Y, %d-%m-%Y, in that order
boolean value_bool yes/true/y/1 → true; no/false/n/0 → false (case-insensitive)
choice value_text matched case-insensitively against allowed_values, stored as the declared spelling

The other three typed columns are set to NULL on every write, so an attribute never holds two values.

Field locks (_lock_field): every write — including a clear — records that this field was edited on this screen.

It matters only where something else also has an opinion about the value — today that is the semantic layer's YAML — and it is recorded for every attribute regardless, because which fields a generator will one day claim is not knowable at the moment somebody types one.

A lock is not a refusal. It decides who wins a disagreement, and the screen that would lose says so before saving rather than afterwards.

Clearing on purpose is locked too, "and a paste must not quietly put the old value back."

Response: {"code": "STATUS", "value": "Approved"}, or {"code": "STATUS", "value": null} on a clear. Or the 202 proposal shape.

Side effect: fires the changed trigger with {"what": "attribute", "field": code, "label": name, "old": …, "new": …}. The old value is read before the write: "a workflow that starts on a change has to be able to say what it was, and afterwards is too late to ask."

Docstring: "Every relation this asset MAY have, from the metamodel, and what could sit on the other end. The screen offers exactly this and nothing else, so an impossible relation is never something a person can try."

Response

{"relations": [
  {"code": "TERM_DEFINES_ENTITY",
   "forward_label": "defines", "inverse_label": "is defined by",
   "cardinality": "1:N",
   "source_to_target": "1:N", "target_to_source": "1:1",
   "as_source": true,            // whether THIS asset's type sits at the source end
   "other_type_code": "DATA_ENTITY",
   "other_type_name": "Data Entity",
   "other_type_plural": "Data Entities"}
]}

Ordered by display_order, code. A relation whose two ends are the same type appears once with as_source: true.

GET /api/types/{code}/relatable — the same, before there is an asset

Called by newAssetForm.

Docstring: "The same question /api/assets/{uid}/relatable answers, asked before there is an asset to ask it about — which is exactly the moment somebody filling in a creation form needs it. An asset that arrives already knowing which domain owns it is one nobody has to remember to come back to."

Refusal: 404 "There is no asset type called <CODE>."

Response

{"type": {"id","code","name","plural","origin"},
 "relations": [
   {"code": "TERM_OWNED_BY_DOMAIN",
    "dir": "out",                 // "out" when this type is the source end, else "in"
    "label": "owned by",          // forward or inverse, matching dir
    "other": "Data Domain",
    "other_type_code": "DATA_DOMAIN",
    "cardinality": "1:N"}
 ]}

Note the shape differs from /api/assets/{uid}/relatable: this one is _columns_available's relation shape (dir/label/other), the other is the raw metamodel row.

GET /api/candidates — what can go on the other end

Called by assetPicker.

Query parameters

Name Type Default Clamped Notes
type string required — upper-cased; 404 if unknown
of uid null — the asset being linked from
relation string null — the relation code, upper-cased
q string "" first 6 words every word must match the name, the key, or a place it sits in
limit int 3000 max(1, min(limit, 5000))

The search is answered here, and the answer says whether it is all of it. Neither was true before. The picker asked once with no q, filtered what came back as somebody typed, and put that number in the search box — so on a repository with 17,007 tables, a table whose name sorted after the three thousandth could be opened from the estate screen and not found in the picker at all. The box said "Nothing here matches that" about a table that was there.

Each word may be answered by the candidate's own name, its key, or the name of something that contains it — the same rule the browser applies to a list it holds whole, so one box does not mean two things depending on how big the repository is. Words may arrive in any order.

A word is only searched as a place when it names few enough of them (PICKER_PLACES, 200), where a place is something with an outgoing containment edge. SCHEMA_3 naming a hundred schemas cost eleven seconds on one keystroke before that, and six vague words together cost forty-six. Words above the cap come back in by_name_only, and the screen says so rather than quietly taking longer or quietly answering less. Docstring: "Picking one asset from a dropdown is fine. Picking ninety tables out of four thousand is not, and an output port realised by a whole schema is an ordinary thing to record. So the screen needs the estate the way a person thinks of it — this system, that database, that schema, these tables — and that shape is already in the repository: containment is the 1:N / 1:1 cardinality pair, the same rule the estate tree is drawn from… Nothing here knows what a SCHEMA is; a type that is contained by nothing simply comes back with an empty path, and a new containing type declared in the workbook starts grouping on its own."

of and relation together mark the ones already linked — "a picker that offers what is already there wastes the answer it is asking for."

Response

{
  "type": {"code": "TABLE", "name": "Table", "plural": "Tables"},
  "assets": [                   // ordered by name
    {"uid": "…", "name": "CUSTOMERS",
     "external_key": "PROD.SALES.CUSTOMERS",
     "proposed": false,
     "path": [                  // outermost first, by climbing depth DESC
       {"uid": "…", "name": "PROD", "type_code": "DATABASE", "type_name": "Database"},
       {"uid": "…", "name": "SALES", "type_code": "SCHEMA", "type_name": "Schema"}
     ],
     "linked": true}            // already linked by THIS relation, from `of`
  ],
  "shown": 412,                 // how many came back
  "total": 1204,                // how many of this type there are (proposals excluded)
  "q": "sales",                 // what was searched for, as read
  "limit": 3000,                // the room there was
  "more": false,                // true when these are the nearest `limit` and not all
  "by_name_only": [],           // words too common to be a place
  "places": 200                 // PICKER_PLACES
}

Proposals are offered narrowly: only to the people who can already see them (_may_see_proposal), capped at the first 500 of that type, and only when of names a governed asset:

It was on this list for everybody, which was the wrong half of the rule: the estate hid proposals and the picker did not… Offered only where the near end is governed, because that is the only place a link can wait: the request holds it until the proposal is approved. From an ungoverned asset there is nothing to hold it, and a picker that offers what the next screen refuses is worse than one that offers less.

The climb is bounded at depth 12, "so a containment cycle — which the metamodel does not forbid — cannot spin here."

POST /api/assets/{uid}/relations/bulk — many links of one kind

The screen's only link-adding call.

Permission: RELATION_MANAGE on this asset.

Body

Field Type Required Notes
relation_code string yes upper-cased
other_uids list of uids yes at most 1000
as_source bool no null means "work it out from the types"
propose bool no true forces the proposal path

Refusals

Condition Status Message
other_uids empty 400 "Nothing was selected to link."
more than 1000 400 "That is more than 1000 links in one go."
no such relation type 400 "There is no relation type called <CODE>."

Docstring — why it is one request and not ninety:

Ninety separate requests would be ninety chances to lose the connection half way through and no way to say what happened. This is one transaction per link inside one request: a link the metamodel refuses is rolled back to its own savepoint and reported by name, and the rest still land. The alternative — all or nothing — would mean one bad row in a selection of ninety throwing away the other eighty-nine.

Response

{"linked": 88,        // written
 "already": 1,        // a UniqueViolation — "already there is not a failure"
 "refused": [         // a trigger or check said no, per link, by NAME
   {"name": "ORDERS", "why": "a TABLE cannot define a DATA_ENTITY"}
 ]}

Linking an asset to itself is refused per link and appears in refused ("An asset cannot be linked to itself.").

When the gate says propose: each far end is judged separately, because each may be governed or not. If all of them are held, the answer is 202:

{"proposed": true, "held": 3, "linked": 0, "already": 0, "refused": [],
 "detail": "3 link(s) proposed — a workflow decides."}

If some are held and some are not, the held ones become requests and the rest go on to be written in the same call, with the ordinary 200 body.

Called by unlink always with ?propose=1.

Permission: RELATION_MANAGE on this asset.

{relation_id} is an integer relation.id.

Query parameters: propose — int, default 0. Non-zero forces the proposal path regardless of the workflow_api_writes setting.

Refusal: 404 "That link is not on this asset." — the DELETE is constrained to source_asset_id = <this> OR target_asset_id = <this>.

Response: {"removed": 912}, or the 202 proposal shape.

The relation is read before it is removed: "a workflow that reviews changes has to be able to say WHICH link went, and afterwards there is nothing to ask."

POST /api/assets/{uid}/responsibilities — assign

Permission: RESPONSIBILITY_ASSIGN on this asset.

Body

Field Type Default Notes
role_code string — upper-cased; must be an active role
holder string — a username (for a person) or a team code
kind string "person" "person" or anything else (treated as a team)

Refusals: 400 "There is no role called <CODE>."; 400 "There is no <kind> called '<holder>'."; and whatever the database says through _refusals — notably 409 "That person already holds that role here." and any trigger refusing a role assigned at a level it is not allowed.

A person is matched on username and is_active; a team on code with no active check on this path.

Response: {"uid": "<the new responsibility uid>"}, or the 202 proposal shape.

DELETE /api/assets/{uid}/responsibilities/{ruid} — unassign

Permission: RESPONSIBILITY_ASSIGN on this asset.

The DELETE is constrained to uid = {ruid} AND asset_id = <this>, so an inherited responsibility cannot be removed from here:

404 "That responsibility is not held on this asset. An inherited one is
     removed where it was assigned, not here."

Response: {"removed": "<ruid>"}, or the 202 proposal shape.

POST /api/assets — a new asset

Called by newAssetForm.

Permission: ASSET_CREATE, over the whole repository — "Creating has no asset to check a permission against, which is exactly what a global role is for."

Body

Field Type Required Notes
type_code string yes upper-cased
name string yes stripped
external_key string no stripped; defaults to the name
attributes object no {CODE: value}, codes upper-cased
links list no [{relation_code, other_uid, as_source}]
propose bool no the screen always sends true
submit bool no with propose, submit at once rather than leaving a draft

Refusals

Condition Status Message
no such type 400 "There is no asset type called <CODE>."
the type's origin is harvested 409 "<Name> is harvested — instances arrive from a connector. Creating one by hand would be replaced at the next harvest."
no name 400 "A name is required."
a mandatory attribute is missing or blank 400 "A <Type> needs <A>, <B>. The metamodel marks them mandatory."
governed, workflow_api_writes = refuse, no propose 409 "<Type> is governed by “<W>”, and direct writes to governed assets are refused here. Propose it on the screen, or send it with \"propose\": true."

"Mandatory attributes are enforced here rather than left to be filled in later: an asset that has never satisfied its own type is what makes a repository untrustworthy."

The order of operations, which is the interesting part:

  1. The asset is inserted as a proposal (proposed_by = <you>), seen by nobody else, "until it is known whether a workflow governs it — which its scope may decide on the very values being given."
  2. The attributes go in through PUT /api/assets/{uid}/attributes/{code} itself, "so a bad one is refused with the same sentence — and the asset it belongs to already exists, which is what makes that refusal recoverable."
  3. Then the governing question is asked, and one of four things happens.

Response — four shapes.

Not governed, or workflow_api_writes = direct: proposed_by is cleared and

{"uid": "…", "linked": 3, "already": 0,
 "refused": ["ORDERS: a TABLE cannot define a DATA_ENTITY"],
 "review": "<request uid>"}     // the `created` trigger's request, or null

Governed, propose: true, no submit — a draft on the new asset's page:

{"uid": "…", "proposed": true, "draft": true, "workflow": "Term approval",
 "linked": 0, "already": 0, "refused": [], "review": null}

Governed, propose: true with submit, or workflow_api_writes = propose — 202:

{"uid": "…", "proposed": true, "request": "<uid>", "workflow": "Term approval",
 "detail": "Proposed — it exists for its approvers until it is approved."}

A refused link does not undo the asset: "The alternative — throwing away a correctly filled-in form because one link was already taken — is worse than an asset that exists and is missing a link somebody can now add on its own page, with the reason in front of them." Links are grouped by relation and sent through the bulk path, "because a form can now stage a schema's worth of tables and ninety separate transactions to record one decision is ninety chances to half-finish."

GET /api/assets/{uid}/reach — everything one role can read

Called by reachPanel, shown only on an asset whose type is SOURCE_ROLE.

Docstring: "Only meaningful on a SOURCE_ROLE, and refused on anything else rather than returning an empty list — 'nothing' and 'wrong question' are different answers and a screen cannot tell them apart. Reading, so it is open like the rest of the estate. What it says is derived entirely from what a harvest found; there is no claim in it."

Refusals: 404 "There is no asset with that id."; 400 "'<name>' is a <Type>, not a role. What a role can read is a question about a role."

Response: {"role": "<name>", **semantic_compile.reach_of(cur, asset_id)}. The inner shape lives in semantic_compile.py and is [not documented] here.

GET /api/assets/{uid}/delete-plan — what deleting would take

Called by deletePanel.

Docstring: "What deleting this would take with it, asked before the button is pressed: whether this person may, what sits under it, and how much of that they may delete too."

Response (_delete_plan, minus branch_ids)

{
  "name": "SALES", "type_name": "Schema",
  "harvested": true,
  "may": false,                   // whether this person may delete the asset itself
  "needs": "HARVESTED_DELETE",    // the permission that is missing, or null
  "under": [                      // per type, in display order
    {"plural": "Tables", "n": 212, "kept": 4, "order": 4}
  ],
  "under_total": 1204,            // everything below it, all the way down
  "kept": ["CUSTOMERS", "ORDERS"],// the first 10 they may NOT delete, by name
  "kept_total": 4,
  "links": 18,                    // relations on the asset itself
  "governed": true                // a workflow starts on deletion here
}

_may_remove is asked per asset, so "a role held over one domain deletes that domain's part of a branch and not the rest." A harvested asset needs ASSET_DELETE and HARVESTED_DELETE.

DELETE /api/assets/{uid} — delete, or ask to

Permission: ASSET_DELETE on this asset, plus HARVESTED_DELETE when a harvest brought it.

Query parameters: branch — int, default 0. 1 takes what sits under it too.

Docstring:

branch=1 takes what sits under it too: every asset below it through a relation that puts one thing under another, each one only if this person may delete it. What they may not — a harvested table, to anyone but a sysadmin — stays, and the answer names it. branch=0 removes the asset alone; what was under it stays, no longer under anything.

A harvested asset is removed only by whoever holds HARVESTED_DELETE (the sysadmin, by default): the next sync brings it back if the source still has it, so deleting one is overruling the source.

Where a workflow starts on deletion, this does not delete: it raises a request and says so. That is the one place an approval has to come BEFORE the act rather than after it, because there is nothing to revert to.

Refusals

Condition Status Message
harvested, no HARVESTED_DELETE 403 "<Name> may not delete “<asset>”: a harvest brought it in, and removing harvested assets needs HARVESTED_DELETE, which only the sysadmin holds unless your access model says otherwise. The next sync would bring it back anyway if the source still has it."
no ASSET_DELETE 403 the standard _may sentence
the database still has something pointing at it 409 whatever _refusals says

Response — deleted:

{"deleted": true,
 "links_removed": 18,          // relations the asset itself had
 "also_removed": 212,          // how many under it went, with branch=1
 "kept": ["CUSTOMERS"],        // first 10 they may not delete; [] without branch
 "kept_total": 4,
 "left_without_parent": 0}     // with branch=0: everything that was under it

Response — held by a workflow:

{"deleted": false, "held": true, "request": "<uid>",
 "detail": "“SALES” has not been deleted. A workflow governs deletions here, so this is now a request for somebody to decide."}

Children are deleted deepest first, "so nothing is ever briefly under a thing that has gone."

GET /api/assets/{uid}/gone-plan

Query parameters: days — int, optional; max(0, days) overrides the setting.

Same _gone_plan shape as GET /api/gone-plan (Screen 3), but with name and type_name from the asset, and itself saying whether this asset is also marked gone — "A schema that is gone and whose tables are gone is not worth leaving behind as an empty shell."

POST /api/assets/{uid}/clear-gone

Permission: ASSET_DELETE on this asset, plus the per-asset ASSET_DELETE and HARVESTED_DELETE applied set-wise in SQL.

Body (may be absent): {"days": <int>}.

Docstring: "What goes is worked out here and never taken from the caller. A screen that posted a list of ids would be a screen somebody could post a different list to, and the warning a person read would be decoration."

The subtree walk is SQL (DOOMED), and the containment rule is read from the metamodel rather than from a list of type codes, "so a client who adds a container of their own is walked correctly without this file knowing the word for it."

Response: the same shape as POST /api/clear-gone (Screen 3).

GET /api/access and GET /api/people — the responsibility adder

Called together, in parallel, by responsibilityAdder. Both are documented under Screen 7.

POST /api/lineage/parse — what this SQL appears to say

Called by sqlPanel.

Permission: a session. Writes nothing.

Body

Field Type Notes
sql string the statement; falls back to the asset's ddl or definition property
asset_uid uid the asset the statement loads
dialect string passed through to sqlglot

Refusals

Condition Status Message
sqlglot not installed 503 "Reading SQL needs the sqlglot package. Install it with .venv/bin/pip install sqlglot and try again — nothing else in the app depends on it."
no SQL anywhere 400 "“<name>” does not carry its SQL. A harvest records it as ddl; until then, paste the statement."
sqlglot cannot read it 400 sql_lineage.CannotRead's sentence
a CREATE whose target is not in the repository 400 "This creates “<X>”, and <why>. Harvest or create it first — parsing will not invent the thing being written to."
a bare SELECT with no asset_uid 400 "This statement does not say what it writes into. Open it from the asset it loads, or use a CREATE statement."

The honest part: "A name that cannot be matched to an asset is reported as unresolved — never guessed at, never created. A parser that quietly invented the tables it could not find would produce a lineage diagram that is complete and wrong, which is worse than one that is short and true."

How names are resolved (_find_asset) — two rules, both about not guessing:

A qualified name (db.schema.table) is matched on the end of the source path, because that is what the key is. An unqualified one is looked for inside the thing that contains the statement's target first — a view's FROM clause means the view's own schema — and only then anywhere, and if "anywhere" turns up more than one it is reported as ambiguous rather than resolved to whichever came first.

Response

{
  "target": {"uid": "…", "name": "V_CUSTOMER_360", "type": "View"},
  "within": "PROD.SALES",            // the container the statement sits in
  "sql": "CREATE VIEW …",            // what was actually parsed
  "edges": [
    {"relation_code": "VIEW_FLOWS_FROM_TABLE", "level": "table",
     "from_uid": "…", "from_name": "V_CUSTOMER_360",
     "to_uid": "…", "to_name": "CUSTOMERS",
     "note": "read from the statement",
     "already": "declared"}          // the method of an existing relation, or null
  ],
  "unresolved": [
    {"what": "STAGE.RAW_ORDERS", "why": "nothing in the repository is called that",
     "level": "table"}
  ],
  "star": false,                     // the statement used SELECT *
  "note": "…",                       // sql_lineage's own note
  "proposed": 4                      // edges with `already` null
}

Column edges carry "transform" as well, and their note reads "<transform> of <column>" — "the transform is the useful half of a column edge: the data moved, and this says what happened to it on the way."

Why an edge can be unresolved even when both ends exist: "the metamodel has no 'flows from' relation between <A> and <B>". Which relation type says "this derives from that" is looked up, not named (_lineage_relation), "so a new pair declared in the workbook becomes parseable without touching this file."

POST /api/lineage/accept — write the edges somebody ticked

Called by sqlProposal.

Permission: RELATION_MANAGE on the target asset.

Body

Field Type Notes
asset_uid uid the target; the permission is checked against it
edges list each {relation_code, from_uid, to_uid, confidence?, note?}

confidence defaults to 0.9; note is truncated to 500 characters.

Refusal: 400 "Nothing was ticked." when edges is empty.

Docstring: "Write the edges somebody ticked, as parsed rather than declared." Every relation is inserted with method = 'parsed_ddl', "so a guess and a statement never become indistinguishable."

Response

{"written": 4, "already": 1,
 "refused": ["a VIEW cannot flow from a BUSINESS_TERM"]}

Each edge runs in its own savepoint: a UniqueViolation counts as already, a trigger or check refusal is collected by its message, and the rest still land.