Entity metamodel API
The curation API works with entities: a table, a dashboard, a tag. This API works one level up, with the shapes those entities take — the entity types, and the attributes each type carries.
An entity type is a class; an entity is an instance of it. An attribute definition declares that instances of a type may carry a value of a given kind, under a given name. Nothing here writes values; that is the curation API.
All endpoints require authentication and return the standard DataDios response envelope:
{
"STATUS": "SUCCESS",
"MESSAGE": "",
"DATA": {}
}
A refused request has STATUS: "FAIL" and a machine-readable code in DATA.reason. Branch on
DATA.reason, never on the text in MESSAGE.
Endpoints
| Method and path | Purpose | Success status |
|---|---|---|
POST /entities/types | Create an entity type | 201 when created; 200 when the name already exists |
GET /entities/types | List entity types | 200 |
POST /entities/types/{guid}/attributes | Declare an attribute on an entity type | 201 |
GET /entities/types/{guid}/attributes | List the attributes that apply to a type | 200 |
Create an entity type
{
"name": "Data Product"
}
Creating a type is create-or-get, not create-or-update. Names are compared after trimming
spaces and ignoring letter case. If the name already resolves to a type, the API returns that type
with "created": false and writes nothing.
Two consequences follow from the identity rule, and both are easier to know than to discover:
- Creating "Data Product" twice converges on one type. There is no way to end up with two.
- A type's identity is derived from its name, so a type that is deleted and later recreated under the same name comes back with the same guid. Any curation that outlived the delete points at it again.
The response carries origin, which separates the types DataDios ships from the ones you create:
{
"entity_type": { "guid": "...", "name": "Data Product", "origin": "user_defined" },
"created": true
}
List entity types
GET /entities/types returns both the seeded system types and your own, told apart by origin.
You need that distinction: only user-created types can be renamed or deleted.
The listing is paged with a cursor. Pass cursor and page_size as query parameters; the response
carries next_cursor, which is null on the last page.
{
"entity_types": [{ "guid": "...", "name": "Data Product", "origin": "user_defined" }],
"next_cursor": null
}
Declare an attribute
{
"name": "sla_tier",
"value_type": "string",
"cardinality": "multi",
"allowed_values": ["gold", "silver", "bronze"],
"required": false,
"default_value": "bronze",
"filterable": false
}
| Field | Required | Meaning |
|---|---|---|
name | yes | Attribute name, unique within the owning type |
value_type | yes | One of string, number, boolean, date, json, entity_ref |
cardinality | yes | single for one value, multi for a list |
allowed_values | no | Enumerated values, for scalar types only |
allowed_ref_type | for refs | Guid of the type an entity_ref attribute points at |
required | no | Whether instances must carry a value |
default_value | no | Applied when an instance is created without one; an entity guid for references |
filterable | no | Whether the attribute can be filtered on; entity_ref only |
Every malformed combination is refused with its own reason code rather than being quietly ignored. The rules that are easiest to get wrong:
allowed_valuesis for scalars only. A reference attribute is constrained byallowed_ref_typeinstead. Sending both is refused.- An
entity_refattribute must declareallowed_ref_type. Without it the attribute can never accept a value by name — only by guid — for the rest of its life, so this is refused at declaration rather than discovered later. filterableapplies toentity_refonly. Filtering is built on reference values, so a filterable string attribute would look filter-ready and match nothing, permanently.
Limits
| Limit | Value | Refusal reason |
|---|---|---|
| Attributes declared on one type | 200 | too_many_attribute_defs |
Entries in allowed_values | 1,000 | too_many_allowed_values |
| Request body, before it is parsed | 8 MB | request_too_large (413) |
The first two are not about the size of a single request. A type's attribute definitions are read on every curation write against that type, so an unbounded set is a cost paid by every later write rather than by the call that declared them.
Attribute names cannot be changed
An attribute's declaration is fixed once created. There is no rename and no edit: changing any
field means withdrawing the attribute and declaring a new one. filterable is the field callers
most often want to flip, and flipping it would rewrite the stored filter data of every instance of
the owning type — a background job, not a field edit.
To withdraw one, use DELETE /entities/attributes/{type_guid}/{name} from the curation API.
List a type's attributes
GET /entities/types/{guid}/attributes returns every attribute that applies to the type: the ones
declared on it, plus the four platform attributes every type carries — domain, env,
tags, and classification.
A brand-new type with no attributes of its own still lists those four. They are owned by the
framework rather than by any one type, so they come back with "owner_type": null. That null is
deliberate and is not a missing value: naming an owning type would claim a per-type attribute where
the platform has a schema-wide one.
Withdrawn attributes are absent from this listing.
{
"attributes": [
{
"guid": "...",
"name": "tags",
"owner_type": null,
"value_type": "entity_ref",
"cardinality": "multi",
"allowed_values": null,
"allowed_ref_type": "...",
"required": false,
"default_value": null,
"filterable": true,
"origin": "system"
}
]
}
This listing is not paged. It returns the type's full applicable attribute set, which the 200-per-type limit above bounds.
Refusal reasons
| Reason | Status | When |
|---|---|---|
attribute_def_already_exists | 409 | An attribute of that name is already declared on the type |
attribute_def_withdrawn | 409 | An attribute of that name was withdrawn and its data is still being removed |
too_many_attribute_defs | 400 | The type is at the 200-attribute limit |
base_type_not_owner | 400 | An attribute was declared against the platform's own attribute owner |
unknown_entity_type | 404 | The type guid does not resolve |
unknown_value_type | 400 | value_type is outside the six |
invalid_cardinality | 400 | cardinality is neither single nor multi |
allowed_values_not_supported | 400 | allowed_values was sent on an entity_ref attribute |
allowed_ref_type_not_supported | 400 | allowed_ref_type was sent on a non-reference attribute |
allowed_ref_type_required | 400 | An entity_ref attribute omitted allowed_ref_type |
filterable_not_supported | 400 | filterable was set on a non-reference attribute |
referent_not_found | 404 | A reference default_value does not resolve |
ref_type_mismatch | 400 | An entity_ref default_value names an entity of the wrong type |
empty_value | 400 | name normalizes to nothing once spaces are trimmed |
invalid_page_size | 400 | page_size on the type listing is outside the permitted range |
invalid_cursor | 400 | cursor on the type listing is not one this API issued |
request_too_large | 413 | The body exceeds 8 MB |
Both 409s are temporary in different ways
attribute_def_already_exists means the attribute is live. Declaring it again is refused rather
than treated as a no-op, because a declaration carries a shape: answering SUCCESS would report a
definition you may not have asked for. Withdraw it first if you need a different shape.
attribute_def_withdrawn means the attribute was withdrawn but its stored values have not finished
being removed. That refusal clears on its own — a daily job deletes the withdrawn definition once
its data is gone, after which the name can be declared afresh. It is a window, not a permanent
block.