Docs › Reference

For everyone

The metamodel

The API calls behind the metamodel.

Part of the HTTP API reference.

Route: /^#\/metamodel(?:\/([a-z_]+))?$/ → drawMetamodel(tab). Tabs: asset_types, attribute_types, type_attributes, relation_types, history.

The one rule the whole screen is built around:

The workbook is the only way in. This screen shows what is live, hands you a download of it so an edit always starts from current state rather than somebody's stale copy, and refuses an upload that would orphan data — naming the rows rather than saying "conflict".

There is deliberately no edit-a-row-here path. Two writable surfaces means an upload from a workbook downloaded an hour ago silently reverts every change made on screen since, and nothing in the audit trail says why.

The screen edits rows anyway — and the way it does that is the point of POST /api/metamodel/{kind} below.

The order of calls

  1. GET /api/metamodel/admin — the live model, every tab.
  2. GET /api/metamodel/vocabulary — once, fire-and-forget, to fill the pickers.
  3. On the History tab: GET /api/metamodel/history.
  4. The workbook panel (uploadPanel, MM_UPLOAD): GET /api/metamodel/export as an <a href>, then POST /api/metamodel/preview on choosing a file, then POST /api/metamodel/apply on pressing Apply.
  5. Editing a row: POST /api/metamodel/{kind}/check as you type, then POST /api/metamodel/{kind} or DELETE /api/metamodel/{kind}.
  6. After any write: GET /api/metamodel again (mmRefresh).

The screen also offers the Load and Access workbook panels from the same place (BULK_UPLOAD, AC_UPLOAD) — those endpoints are under Screens 11 and 7.

GET /api/metamodel/admin — the live metamodel

Docstring:

Deliberately no counts of instances or recorded values. This screen is about what may exist, not how much of it does — and counting instances per metamodel row means a correlated subquery over the instance tables on every load. The upload preview still counts what depends on a row, because that is what decides whether removing it is safe.

Response

{
  "asset_types": [            // ORDER BY display_order, code
    {"code": "TABLE", "name": "Table", "plural": "Tables",
     "type_group": "Physical", "origin": "harvested",
     "description": "…", "updated_at": "…"}
  ],
  "attribute_types": [        // ORDER BY code
    {"code": "CONTAINS_PII", "name": "Contains PII", "data_type": "boolean",
     "allowed_values": null,
     "sensitive_values": ["Yes"],   // which values count as a mark worth seeing
     "description": "…", "origin": "authored", "updated_at": "…"}
  ],
  "type_attributes": [        // ORDER BY display_order, asset type code, attribute code
    {"asset_type": "TABLE", "attribute_type": "CONTAINS_PII",
     "is_mandatory": false, "note": null}
  ],
  "relation_types": [         // ORDER BY display_order, code
    {"code": "SCHEMA_HOLDS_TABLE", "source_type": "SCHEMA",
     "forward_label": "holds", "target_type": "TABLE",
     "inverse_label": "sits in",
     "source_to_target": "1:N", "target_to_source": "1:1",
     "cardinality": "1:N", "origin": "harvested",
     "note": null, "updated_at": "…"}
  ],
  "last_changed": "…"         // max(at) in change_log over the four metamodel tables
}

GET /api/metamodel/vocabulary — what the pickers may offer

No parameters. No database access at all.

Docstring: "What the screen may offer: the words the metamodel itself allows."

Response

{"origins": [ … ],          // metamodel_plan.ORIGINS — "harvested", "authored"
 "data_types": [ … ],       // metamodel_plan.DATA_TYPES
 "cardinalities": ["1:1", "1:N"],
 "lineage": ["", "flows to", "flows from", "same thing"]}

GET /api/metamodel/history

Query parameters

Name Type Default Clamped
limit int 60 min(limit, 500)

Response

{"events": [
  {"at": "…", "actor": "r.iyer",
   "kind": "Relation type",       // Asset type | Attribute type | Type attribute | Relation type
   "verb": "changed",             // added | changed | removed
   "action": "update",            // insert | update | delete
   "what": "Schema holds Table",  // the row, in words — see below
   "fields": [                    // only on an update
     {"field": "inverse_label", "was": "in", "now": "sits in"}
   ]}
]}

what is the row in words, not its code (what_of): a type attribute has neither a name nor a code, so it reads "<Attribute> on <Type>"; a relation type reads as the sentence it makes, "<Source> <forward_label> <Target>"; everything else is its name, then its code, then "row <id>".

Noise is excluded in SQL, not after fetching — and that was a bug:

Filtering afterwards meant a bulk reload — which touches display_order on every row and nothing else — filled the whole window with rows that were then all discarded, and the screen showed an empty history over a full log.

NOISE is id, created_at, updated_at, display_order, asset_type_id, attribute_type_id, source_asset_type_id, target_asset_type_id, cardinality.

Field values are truncated to 90 characters with an ellipsis (_short). An update whose only changed fields are all noise produces no row.

This shape is deliberately the same as /api/access/history, "so the two admin screens then render history with one piece of code rather than two that drift."

GET /api/metamodel/export — the live metamodel as a workbook

No parameters. No permission check.

Docstring: "The live metamodel as a workbook, in the shape the upload expects."

Response: xlsx, with a Content-Disposition naming the file krisid-metamodel-YYYY-MM-DD.xlsx for today's date, and a stamp of the current local date and time inside it.

Sheets: Asset types, Attribute types, Type attributes, Relation types (SHEETS).

POST /api/metamodel/preview — what a workbook would do

multipart/form-data with one field, file.

No permission check. "Writes nothing, ever." The transaction is rolled back whatever happens.

Refusal: 400 with metamodel_io.parse's own ValueError text when the file cannot be read.

Response: {"filename": "<as uploaded>", **p.as_dict} — the plan shape in Conventions.

POST /api/metamodel/apply

multipart/form-data with file. No other parameters.

Permission: METAMODEL_MANAGE, checked before the file is read. The actor recorded against every row it writes is the person signed in.

It used to take an actor query parameter and no permission at all, so anybody with a session could redefine every type in the repository and have the change recorded under a name of their choosing. Editing one row of the metamodel had required the permission since v1; applying a workbook that rewrote the whole model did not.

Docstring: "Apply it, in one transaction, only if nothing is blocked. The plan is recomputed here rather than trusted from the preview: the repository may have changed since, and the check that matters is the one made against the state being written to."

Response: {"filename": …, "applied": <bool>, **p.as_dict}. Committed only when p.safe; otherwise rolled back and applied: false.

POST /api/metamodel/{kind} — add or replace one row

Permission: METAMODEL_MANAGE (_actor_may_model) — "change the metamodel".

{kind} is one of:

{kind} Sheet
type Asset types
attribute Attribute types
attachment Type attributes
relation Relation types

Anything else → 400 "There is nothing called <kind> in the metamodel."

Body

Field Type Required Notes
row list yes the workbook's row, in its column order
was list no the row being replaced, when the code itself is changing

Docstring: "The row is the workbook's row, in its order." And on was: "was names the row being replaced when a code itself is being edited; without it a rename would read as 'one row gone, another arrived', which is exactly the shape the planner refuses when instances depend on the old code."

How a screen edit avoids being a second write path — the reason this endpoint looks the way it does:

The workbook already knows every rule: a type that still has instances cannot be removed, a cardinality cannot be tightened past what the data does, a code cannot be renamed out from under its rows. Writing those rules a second time for the screen would mean two answers to the same question and, sooner or later, two different answers.

So a screen edit is not a different path. It reads the live metamodel in the same shape the workbook parses into, applies ONE change to that structure, and hands the whole thing to the same planner the upload uses. What comes back is either the same refusal a spreadsheet would have got, or the same write.

It costs a full re-plan per edit. The metamodel is a few dozen rows; the cost of the alternative is a rule that holds in one place and not the other.

The row key (SHEET_KEY) is column 0 for three sheets and columns 0 and 1 for Type attributes, upper-cased and stripped. A row whose key is not found is appended; one that is found is replaced.

Refusals

Condition Status Message
unknown {kind} 400 "There is nothing called <kind> in the metamodel."
the planner blocks it 409 " ".join(p.blockers) — "The planner's own words, which were written to be read by whoever has to fix them — not re-worded here into something vaguer."

Response: the plan's as_dict.

DELETE /api/metamodel/{kind} — remove one row

Permission: METAMODEL_MANAGE.

Body: {"row": [ … ]} — the row to remove, identified by its key.

Refusals: 400 unknown kind; 404 "That row is not in the metamodel."; 409 with the planner's blockers.

One convenience, with the reasoning spelt out:

On a screen, removing a type means the type — not "the type, and now go and find the four other rows that mention it". Dropping them here lets the planner answer the question actually being asked, which is whether any INSTANCES depend on it. Nothing is weakened: that refusal is the one that matters, and it still fires.

So removing an asset type also drops its Type attributes rows and any Relation types naming it at either end; removing an attribute type also drops its Type attributes rows.

Response: the plan's as_dict.

POST /api/metamodel/{kind}/check — what that edit would do

Called as somebody types. No permission check.

Body

Field Type Notes
row list the row
was list the row being replaced
remove bool treat it as a removal

Docstring: "The same planner, rolled back — so the screen can say 'this would be refused, and here is why' while somebody is still typing, rather than after they press the button."

Unlike the POST, a remove whose key is not found is not a 404: it simply removes nothing and plans the rest.

Refusal: 400 unknown kind.

Response: the plan's as_dict. Always rolled back.

POST /api/connectors/{kind}/fragment — what a connector wants in the model

Called from the Source screen, which is where somebody realises they need it, but it is a metamodel write and belongs here.

Permission: METAMODEL_MANAGE, for the preview as well — "previewing is free, but this is the screen somebody presses Apply from, and a preview that anybody can run followed by an Apply that they cannot is a worse experience than being told up front."

Query parameters: apply — bool, default false.

Refusals: 400 from _connector when the kind has no connector file; 400 "<Name> asks for nothing from the model." when it declares no FRAGMENT.

Why it goes through the planner (_fragment_book):

A connector declares what it would need — an asset type for an AI agent, a relation saying a share includes a table. It does not get to add them: the whole design rests on a connector being unable to edit a metamodel, because a connector that can add types by mentioning them is a connector that decides what your model is.

So the proposal goes through the same door as everything else… Merged rather than replaced, and that is the whole trick: the metamodel workbook is authoritative, so anything absent from it is DELETED. Handing six rows straight to the planner would propose removing everything else in the model. Reading the live book first and adding to it means the plan can only ever contain additions, which is exactly what a proposal should be.

Response: {"applied": <bool>, **p.as_dict}. With apply=false always applied: false and a rollback; with apply=true, committed only when p.safe.