Sfoglia il codice sorgente

docs(api): document index, facet, ttl, relation and readonly routes

fszontagh 1 mese fa
parent
commit
a26df2b5d1
2 ha cambiato i file con 643 aggiunte e 17 eliminazioni
  1. 32 7
      api/llms.txt
  2. 611 10
      api/openapi.json

+ 32 - 7
api/llms.txt

@@ -65,17 +65,37 @@ Collection names may not start with `_` or the reserved prefix `vectorapi_`.
 
 Arbitrary JSON CRUD under a `kind=json` collection.
 
-- `POST  /api/v1/projects/{project}/collections/{name}/documents` `{data:{…}}` — Insert a document. Returns `{id}`.
-- `GET   /api/v1/projects/{project}/collections/{name}/documents/{id}` — Fetch a document by ID.
-- `PUT   /api/v1/projects/{project}/collections/{name}/documents/{id}` — Replace a document (full upsert).
-- `PATCH /api/v1/projects/{project}/collections/{name}/documents/{id}` — Partially update a document (merge fields).
-- `DELETE /api/v1/projects/{project}/collections/{name}/documents/{id}` — Delete a document.
-- `GET   /api/v1/projects/{project}/collections/{name}/documents` — Find documents.
+- `POST  /api/v1/projects/{project}/collections/{name}/documents` `{data:{…}}` - Insert a document. Returns `{id}`. Optional `?ttl_seconds=N` query param sets an expiry; absent means no expiry.
+- `GET   /api/v1/projects/{project}/collections/{name}/documents/{id}` - Fetch a document by ID.
+- `PUT   /api/v1/projects/{project}/collections/{name}/documents/{id}` - Replace a document (full upsert).
+- `PATCH /api/v1/projects/{project}/collections/{name}/documents/{id}` - Partially update a document (merge fields). Optional `?ttl_seconds=N`: **omitted leaves the existing expiry untouched**; `ttl_seconds=0` **clears** it (document becomes permanent) - do not assume 0 means "no change".
+- `DELETE /api/v1/projects/{project}/collections/{name}/documents/{id}` - Delete a document. Returns **409** `relation_restricted` (with `error.details.impacts`) if a restrict relation has children referencing it; see delete-impact below to preview this first.
+- `GET   /api/v1/projects/{project}/collections/{name}/documents` - Find documents.
+- `GET   /api/v1/projects/{project}/collections/{name}/documents/{id}/delete-impact` - Preview what deleting this document would affect, without deleting it: `{would_be_blocked, impacts:[{relation, child_collection, child_field, on_delete, child_count, sample_child_ids, blocks}]}`. Read-only; follows the collection's read grant (a scoped key with a read rule may call it).
 
 **Filter grammar**: `?filter=field:op:value` (repeatable; ANDed). Supported ops: `eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `contains`. Example: `?filter=age:gte:30&filter=name:eq:Alice`.
 
 Additional query params: `limit` (default 20), `offset` (default 0), `sort` (field name), `desc` (bool).
 
+## Indexes and facets
+
+Project-scoped, under a collection. Index create/drop require a full (non-scoped) key with project access - scoped keys cannot manage indexes.
+
+- `POST   /api/v1/projects/{project}/collections/{name}/indexes` `{field, unique?}` - Declare an index on `field`, backfilling existing rows so it's usable immediately. Idempotent: re-declaring reports `already_existed: true`. With `unique: true`, refuses with **409** `duplicate_values` (`error.details.duplicate_examples`, up to five colliding document ids) if the field already holds duplicate values.
+- `GET    /api/v1/projects/{project}/collections/{name}/indexes` - List indexes: `{indexes:[{field, distinct_values, entries, unique}]}`.
+- `DELETE /api/v1/projects/{project}/collections/{name}/indexes/{field}` - Drop an index.
+- `GET    /api/v1/projects/{project}/collections/{name}/indexes/{field}/values` - Distinct values with counts: `{values:[{value, count}]}`. Index-dependent by design (not an error): returns an empty list for a field with no index and for array-valued fields, whose index keys are elements rather than values. Query params: `limit` (1-1000, default 100), `order` (`asc`|`desc`, default `asc`; use `order=desc&limit=1` for the maximum). Follows the collection's read grant.
+
+## Relations (admin)
+
+Relation names are per-project and **unqualified**: requests and responses always use the bare name; the internal `project:` prefix is never exposed. All relation endpoints require an admin key.
+
+- `POST   /api/v1/projects/{project}/relations` `{name, child, child_field, parent, on_delete?, validate_on_write?}` - Create a relation: deleting a `parent` document affects `child` documents whose `child_field` references it. `on_delete` is one of `restrict` (default), `cascade`, `set_null`, `no_action`; anything else is 422.
+- `GET    /api/v1/projects/{project}/relations` - List relations in the project.
+- `GET    /api/v1/projects/{project}/relations/{name}` - Get a relation by name.
+- `DELETE /api/v1/projects/{project}/relations/{name}` - Drop a relation by name.
+- `PUT    /api/v1/projects/{project}/collections/{name}/relations-enforced` `{enforced}` - Enable/disable relation enforcement on a collection. **`relations_enforced` is read on the collection you are deleting FROM (the PARENT side of the relation), not on the child collection holding the reference.** Disabling it on the child has no effect on deletes issued against the parent; set it on the parent to let its deletes bypass restrict relations.
+
 ## RAG vectors
 
 Requires a `kind=vector` collection. Vectors are stored with cosine-similarity indexing.
@@ -97,7 +117,12 @@ All settings endpoints require an admin key.
 ## Stats
 
 - `GET /api/v1/projects/{project}/stats` — Per-project stats (collections, document counts). Accessible to any key granted the project.
-- `GET /api/v1/stats` — Server-wide stats including `memory_pressure_level` and an `embedding` object with cache telemetry (`cache_size`, `cache_capacity`, `cache_hits`, `cache_misses`, `cache_hit_ratio`, `cache_bytes`). Admin only.
+- `GET /api/v1/stats` — Server-wide stats including `memory_pressure_level`, an `embedding` object with cache telemetry (`cache_size`, `cache_capacity`, `cache_hits`, `cache_misses`, `cache_hit_ratio`, `cache_bytes`), and `db_client_version`/`db_client_commit` (the smartbotic-database client library the dynamic linker actually bound at process startup, not necessarily what the binary was compiled against (2.4.x and 2.11.x share a SONAME, so a mismatched library can load silently and only crash on a later restart)). Admin only.
+
+## Admin
+
+- `GET /api/v1/admin/readonly` - Get the server-wide (not per-project) read-only lock status: `{readonly, scope:"server", reason?}`. Reflects the database's own authoritative state - it can enter read-only by itself after a degraded/failed recovery, not only via the PUT below, so `readonly:true` does not necessarily mean an operator locked it; `reason` (DB-defined free text) is present only when non-empty. While locked, writes are rejected by the database and surfaced by vectorapi as **503**. Admin only.
+- `PUT /api/v1/admin/readonly` `{readonly}` - Set the server-wide read-only lock. Admin only.
 
 ## Ops
 

+ 611 - 10
api/openapi.json

@@ -84,6 +84,65 @@
           "created_at": { "type": "integer" },
           "scope": { "$ref": "#/components/schemas/KeyScope", "description": "Present only when the key carries a capability scope" }
         }
+      },
+      "RelationDefinition": {
+        "type": "object",
+        "description": "Relation names are per-project and unqualified: the API always takes and returns bare names. The internal `project:` prefix used to store the relation is never exposed to clients.",
+        "properties": {
+          "name": { "type": "string" },
+          "child": { "type": "string", "description": "Child collection name - holds the foreign reference" },
+          "child_field": { "type": "string", "description": "Field on the child document that stores the parent's id" },
+          "parent": { "type": "string", "description": "Parent collection name" },
+          "on_delete": { "type": "string", "enum": ["restrict", "cascade", "set_null", "no_action"] },
+          "validate_on_write": { "type": "boolean" },
+          "created_at": { "type": "integer" },
+          "updated_at": { "type": "integer" }
+        },
+        "required": ["name", "child", "child_field", "parent", "on_delete"]
+      }
+    },
+    "responses": {
+      "Conflict": {
+        "description": "Conflict. `error.code` distinguishes the two cases vectorapi returns 409 for: `duplicate_values` (a unique index was refused because the field already holds colliding values) and `relation_restricted` (a document delete was blocked by a restrict relation).",
+        "content": {
+          "application/json": {
+            "schema": {
+              "type": "object",
+              "properties": {
+                "error": {
+                  "type": "object",
+                  "properties": {
+                    "code": { "type": "string", "enum": ["duplicate_values", "relation_restricted"] },
+                    "message": { "type": "string" },
+                    "details": {
+                      "type": "object",
+                      "description": "Machine-readable payload. For `duplicate_values`: `duplicate_examples` (array of up to five document ids, one per colliding value). For `relation_restricted`: `impacts` (array of the same shape returned by GET .../documents/{id}/delete-impact).",
+                      "properties": {
+                        "duplicate_examples": { "type": "array", "items": { "type": "string" } },
+                        "impacts": {
+                          "type": "array",
+                          "items": {
+                            "type": "object",
+                            "properties": {
+                              "relation": { "type": "string" },
+                              "child_collection": { "type": "string" },
+                              "child_field": { "type": "string" },
+                              "on_delete": { "type": "string" },
+                              "child_count": { "type": "integer" },
+                              "sample_child_ids": { "type": "array", "items": { "type": "string" } },
+                              "blocks": { "type": "boolean" }
+                            }
+                          }
+                        }
+                      }
+                    }
+                  },
+                  "required": ["code", "message"]
+                }
+              }
+            }
+          }
+        }
       }
     }
   },
@@ -151,7 +210,7 @@
         "responses": {
           "201": { "description": "Project created" },
           "401": { "description": "Unauthorized" },
-          "403": { "description": "Forbidden — admin required" },
+          "403": { "description": "Forbidden - admin required" },
           "422": { "description": "Validation error" }
         }
       }
@@ -196,7 +255,7 @@
         "responses": {
           "200": { "description": "Project deleted" },
           "401": { "description": "Unauthorized" },
-          "403": { "description": "Forbidden — admin required or cannot drop default" },
+          "403": { "description": "Forbidden - admin required or cannot drop default" },
           "404": { "description": "Project not found" }
         }
       }
@@ -223,7 +282,7 @@
             }
           },
           "401": { "description": "Unauthorized" },
-          "403": { "description": "Forbidden — admin required" }
+          "403": { "description": "Forbidden - admin required" }
         }
       },
       "post": {
@@ -270,7 +329,7 @@
             }
           },
           "401": { "description": "Unauthorized" },
-          "403": { "description": "Forbidden — admin required" },
+          "403": { "description": "Forbidden - admin required" },
           "422": { "description": "Validation error" }
         }
       }
@@ -307,7 +366,7 @@
         "responses": {
           "200": { "description": "Key updated" },
           "401": { "description": "Unauthorized" },
-          "403": { "description": "Forbidden — admin required" },
+          "403": { "description": "Forbidden - admin required" },
           "404": { "description": "Key not found" }
         }
       },
@@ -317,7 +376,7 @@
         "responses": {
           "200": { "description": "Key revoked" },
           "401": { "description": "Unauthorized" },
-          "403": { "description": "Forbidden — admin required" },
+          "403": { "description": "Forbidden - admin required" },
           "404": { "description": "Key not found" }
         }
       }
@@ -535,6 +594,13 @@
             "required": false,
             "schema": { "type": "string" },
             "description": "CAPTCHA response token. Required when the scoped key rule has require_human_token=true; verified server-side via the configured captcha provider."
+          },
+          {
+            "name": "ttl_seconds",
+            "in": "query",
+            "required": false,
+            "schema": { "type": "integer", "minimum": 0 },
+            "description": "Optional expiry, in seconds from now. Absent means the document has no expiry."
           }
         ],
         "requestBody": {
@@ -640,6 +706,15 @@
       "patch": {
         "summary": "Partially update a document (merge fields)",
         "operationId": "patchDocument",
+        "parameters": [
+          {
+            "name": "ttl_seconds",
+            "in": "query",
+            "required": false,
+            "schema": { "type": "integer", "minimum": 0 },
+            "description": "Sets the document's expiry, in seconds from now. This parameter is asymmetric by design: **omitting it leaves the document's current expiry untouched**; sending `ttl_seconds=0` **clears** the expiry, making the document permanent. Do not assume 0 means \"no TTL requested\": it actively wipes an existing one."
+          }
+        ],
         "requestBody": {
           "required": true,
           "content": {
@@ -659,16 +734,346 @@
       },
       "delete": {
         "summary": "Delete a document by ID",
+        "description": "Fails with 409 relation_restricted when a restrict relation has children referencing this document (see GET .../delete-impact to preview this beforehand without deleting).",
         "operationId": "deleteDocument",
         "responses": {
           "200": { "description": "Document deleted" },
           "401": { "description": "Unauthorized" },
           "403": { "description": "Forbidden" },
           "404": { "description": "Document not found" },
+          "409": { "$ref": "#/components/responses/Conflict" },
+          "429": { "description": "Too Many Requests (rate-limited scoped key)" }
+        }
+      }
+    },
+    "/api/v1/projects/{project}/collections/{name}/documents/{id}/delete-impact": {
+      "parameters": [
+        {
+          "name": "project",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Project name"
+        },
+        {
+          "name": "name",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Collection name"
+        },
+        {
+          "name": "id",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Document ID"
+        }
+      ],
+      "get": {
+        "summary": "Preview what deleting this document would affect, without deleting it",
+        "description": "Read-only: changes nothing. Reports whether a delete would be blocked and, for every relation where this collection is the parent, the child collection, the count of referencing child documents, up to a handful of sample child ids, and whether that particular relation blocks the delete. Follows the collection's read grant, so a scoped key with a read rule on this collection may call it (index/relation management routes are admin/full-key only, but this one is not).",
+        "operationId": "describeDelete",
+        "responses": {
+          "200": {
+            "description": "Delete impact preview",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "would_be_blocked": { "type": "boolean" },
+                    "impacts": {
+                      "type": "array",
+                      "items": {
+                        "type": "object",
+                        "properties": {
+                          "relation": { "type": "string" },
+                          "child_collection": { "type": "string" },
+                          "child_field": { "type": "string" },
+                          "on_delete": { "type": "string", "enum": ["restrict", "cascade", "set_null", "no_action"] },
+                          "child_count": { "type": "integer" },
+                          "sample_child_ids": { "type": "array", "items": { "type": "string" } },
+                          "blocks": { "type": "boolean" }
+                        }
+                      }
+                    }
+                  }
+                }
+              }
+            }
+          },
+          "401": { "description": "Unauthorized" },
+          "403": { "description": "Forbidden" },
+          "404": { "description": "Document not found" },
           "429": { "description": "Too Many Requests (rate-limited scoped key)" }
         }
       }
     },
+    "/api/v1/projects/{project}/collections/{name}/indexes": {
+      "parameters": [
+        {
+          "name": "project",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Project name"
+        },
+        {
+          "name": "name",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Collection name"
+        }
+      ],
+      "post": {
+        "summary": "Declare a field index, optionally unique; backfills existing rows",
+        "description": "Backfills existing rows as part of the call, so the index is usable immediately once this returns. Idempotent: re-declaring an index that already exists on `field` reports already_existed:true instead of erroring. Requires a full (non-scoped) key with project access; scoped keys cannot manage indexes.",
+        "operationId": "createIndex",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object",
+                "properties": {
+                  "field": { "type": "string" },
+                  "unique": {
+                    "type": "boolean",
+                    "default": false,
+                    "description": "When true, enforces a uniqueness constraint. If the field already holds duplicate values across existing documents, the call fails with 409 duplicate_values instead of creating the index."
+                  }
+                },
+                "required": ["field"]
+              }
+            }
+          }
+        },
+        "responses": {
+          "201": {
+            "description": "Index declared",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "field": { "type": "string" },
+                    "unique": { "type": "boolean" },
+                    "rows_indexed": { "type": "integer" },
+                    "already_existed": { "type": "boolean" }
+                  }
+                }
+              }
+            }
+          },
+          "401": { "description": "Unauthorized" },
+          "403": { "description": "Forbidden - project access required; scoped keys cannot manage indexes" },
+          "404": { "description": "Collection not found" },
+          "409": { "$ref": "#/components/responses/Conflict" },
+          "422": { "description": "Validation error (missing field)" }
+        }
+      },
+      "get": {
+        "summary": "List indexes declared on a collection",
+        "operationId": "listIndexes",
+        "responses": {
+          "200": {
+            "description": "Index list",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "indexes": {
+                      "type": "array",
+                      "items": {
+                        "type": "object",
+                        "properties": {
+                          "field": { "type": "string" },
+                          "distinct_values": { "type": "integer" },
+                          "entries": { "type": "integer" },
+                          "unique": { "type": "boolean" }
+                        }
+                      }
+                    }
+                  }
+                }
+              }
+            }
+          },
+          "401": { "description": "Unauthorized" },
+          "403": { "description": "Forbidden" },
+          "404": { "description": "Collection not found" }
+        }
+      }
+    },
+    "/api/v1/projects/{project}/collections/{name}/indexes/{field}": {
+      "parameters": [
+        {
+          "name": "project",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Project name"
+        },
+        {
+          "name": "name",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Collection name"
+        },
+        {
+          "name": "field",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Indexed field name"
+        }
+      ],
+      "delete": {
+        "summary": "Drop an index",
+        "operationId": "dropIndex",
+        "description": "Requires a full (non-scoped) key with project access; scoped keys cannot manage indexes.",
+        "responses": {
+          "200": {
+            "description": "Index dropped",
+            "content": {
+              "application/json": {
+                "schema": { "type": "object", "properties": { "dropped": { "type": "string" } } }
+              }
+            }
+          },
+          "401": { "description": "Unauthorized" },
+          "403": { "description": "Forbidden - project access required; scoped keys cannot manage indexes" },
+          "404": { "description": "Index not found" }
+        }
+      }
+    },
+    "/api/v1/projects/{project}/collections/{name}/indexes/{field}/values": {
+      "parameters": [
+        {
+          "name": "project",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Project name"
+        },
+        {
+          "name": "name",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Collection name"
+        },
+        {
+          "name": "field",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Indexed field name"
+        }
+      ],
+      "get": {
+        "summary": "Distinct values of an indexed field, with per-value counts",
+        "description": "Index-dependent by design, not an error: returns an empty values list with HTTP 200 when `field` has no index, and also for array-valued fields, whose index keys are the individual array elements rather than the field's own value. Follows the collection's read grant, so a scoped key with a read rule on this collection may call it. Use order=desc&limit=1 to get the maximum value.",
+        "operationId": "indexFieldValues",
+        "parameters": [
+          {
+            "name": "limit",
+            "in": "query",
+            "schema": { "type": "integer", "minimum": 1, "maximum": 1000, "default": 100 },
+            "description": "Maximum number of distinct values to return, 1-1000."
+          },
+          {
+            "name": "order",
+            "in": "query",
+            "schema": { "type": "string", "enum": ["asc", "desc"], "default": "asc" },
+            "description": "Sort order of the returned values."
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Distinct values with counts (possibly empty; see description)",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "values": {
+                      "type": "array",
+                      "items": {
+                        "type": "object",
+                        "properties": {
+                          "value": {},
+                          "count": { "type": "integer" }
+                        }
+                      }
+                    }
+                  }
+                }
+              }
+            }
+          },
+          "401": { "description": "Unauthorized" },
+          "403": { "description": "Forbidden" },
+          "422": { "description": "Validation error (limit out of 1..1000 range or non-numeric)" },
+          "429": { "description": "Too Many Requests (rate-limited scoped key)" }
+        }
+      }
+    },
+    "/api/v1/projects/{project}/collections/{name}/relations-enforced": {
+      "parameters": [
+        {
+          "name": "project",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Project name"
+        },
+        {
+          "name": "name",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Collection name"
+        }
+      ],
+      "put": {
+        "summary": "Enable or disable relation enforcement on a collection (admin only)",
+        "description": "IMPORTANT: relations_enforced is read on the collection you are deleting FROM, i.e. the PARENT side of the relation, not on the child collection that holds the foreign reference. Disabling it on the child has no effect on deletes issued against the parent. To allow deletes on a parent collection to bypass restrict relations, set this flag on the parent, not the child.",
+        "operationId": "setRelationsEnforced",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object",
+                "properties": { "enforced": { "type": "boolean" } },
+                "required": ["enforced"]
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Enforcement flag updated",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": { "collection": { "type": "string" }, "enforced": { "type": "boolean" } }
+                }
+              }
+            }
+          },
+          "401": { "description": "Unauthorized" },
+          "403": { "description": "Forbidden - admin required" },
+          "422": { "description": "Validation error (enforced missing or not a boolean)" }
+        }
+      }
+    },
     "/api/v1/projects/{project}/collections/{name}/vectors": {
       "parameters": [
         {
@@ -873,6 +1278,128 @@
         }
       }
     },
+    "/api/v1/projects/{project}/relations": {
+      "parameters": [
+        {
+          "name": "project",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Project name"
+        }
+      ],
+      "post": {
+        "summary": "Create a relation between two collections in a project (admin only)",
+        "description": "Relation names are per-project and unqualified: the API takes and returns bare names - the internal project: prefix used to store the relation is never exposed. name may not start with _ or the reserved vectorapi_ prefix.",
+        "operationId": "createRelation",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object",
+                "properties": {
+                  "name": { "type": "string", "description": "Unqualified relation name" },
+                  "child": { "type": "string", "description": "Child collection name - holds the foreign reference" },
+                  "child_field": { "type": "string", "description": "Field on the child document that stores the parent's id" },
+                  "parent": { "type": "string", "description": "Parent collection name" },
+                  "on_delete": {
+                    "type": "string",
+                    "enum": ["restrict", "cascade", "set_null", "no_action"],
+                    "default": "restrict",
+                    "description": "Any value outside this set is rejected with 422."
+                  },
+                  "validate_on_write": {
+                    "type": "boolean",
+                    "default": false,
+                    "description": "When true, writes to the child are validated against an existing parent."
+                  }
+                },
+                "required": ["name", "child", "child_field", "parent"]
+              }
+            }
+          }
+        },
+        "responses": {
+          "201": {
+            "description": "Relation created",
+            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RelationDefinition" } } }
+          },
+          "401": { "description": "Unauthorized" },
+          "403": { "description": "Forbidden - admin required" },
+          "422": { "description": "Validation error (missing field, or on_delete not one of restrict/cascade/set_null/no_action)" }
+        }
+      },
+      "get": {
+        "summary": "List relations defined in a project (admin only)",
+        "operationId": "listRelations",
+        "responses": {
+          "200": {
+            "description": "Relation list",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "relations": { "type": "array", "items": { "$ref": "#/components/schemas/RelationDefinition" } }
+                  }
+                }
+              }
+            }
+          },
+          "401": { "description": "Unauthorized" },
+          "403": { "description": "Forbidden - admin required" }
+        }
+      }
+    },
+    "/api/v1/projects/{project}/relations/{name}": {
+      "parameters": [
+        {
+          "name": "project",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Project name"
+        },
+        {
+          "name": "name",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Unqualified relation name"
+        }
+      ],
+      "get": {
+        "summary": "Get a relation by name (admin only)",
+        "operationId": "getRelation",
+        "responses": {
+          "200": {
+            "description": "Relation",
+            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RelationDefinition" } } }
+          },
+          "401": { "description": "Unauthorized" },
+          "403": { "description": "Forbidden - admin required" },
+          "404": { "description": "Relation not found" }
+        }
+      },
+      "delete": {
+        "summary": "Drop a relation by name (admin only)",
+        "operationId": "dropRelation",
+        "responses": {
+          "200": {
+            "description": "Relation dropped",
+            "content": {
+              "application/json": {
+                "schema": { "type": "object", "properties": { "dropped": { "type": "string" } } }
+              }
+            }
+          },
+          "401": { "description": "Unauthorized" },
+          "403": { "description": "Forbidden - admin required" },
+          "404": { "description": "Relation not found" }
+        }
+      }
+    },
     "/api/v1/settings": {
       "get": {
         "summary": "Get current settings (admin only); openai_api_key is masked to a boolean openai_api_key_set",
@@ -908,7 +1435,7 @@
             }
           },
           "401": { "description": "Unauthorized" },
-          "403": { "description": "Forbidden — admin required" }
+          "403": { "description": "Forbidden - admin required" }
         }
       },
       "put": {
@@ -946,7 +1473,7 @@
         "responses": {
           "200": { "description": "Settings updated", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" } } } } } },
           "401": { "description": "Unauthorized" },
-          "403": { "description": "Forbidden — admin required" }
+          "403": { "description": "Forbidden - admin required" }
         }
       }
     },
@@ -966,14 +1493,88 @@
                     "memory_pressure_level": { "type": "integer" },
                     "collections": { "type": "integer" },
                     "documents": { "type": "integer" },
-                    "embedding": { "type": "object", "properties": { "cache_size": { "type": "integer" }, "cache_capacity": { "type": "integer" }, "cache_hits": { "type": "integer" }, "cache_misses": { "type": "integer" }, "cache_hit_ratio": { "type": "number" }, "cache_bytes": { "type": "integer" } } }
+                    "embedding": { "type": "object", "properties": { "cache_size": { "type": "integer" }, "cache_capacity": { "type": "integer" }, "cache_hits": { "type": "integer" }, "cache_misses": { "type": "integer" }, "cache_hit_ratio": { "type": "number" }, "cache_bytes": { "type": "integer" } } },
+                    "db_client_version": {
+                      "type": "string",
+                      "description": "Version of the smartbotic-database client library actually bound by the dynamic linker at process startup - not necessarily the version this binary was compiled against. 2.4.x and 2.11.x share a SONAME, so a mismatched shared library can load silently at runtime and only surface as a crash on a later restart; this field lets an operator catch that before it does."
+                    },
+                    "db_client_commit": {
+                      "type": "string",
+                      "description": "Commit hash of the loaded smartbotic-database client library, for the same reason as db_client_version."
+                    }
                   }
                 }
               }
             }
           },
           "401": { "description": "Unauthorized" },
-          "403": { "description": "Forbidden — admin required" }
+          "403": { "description": "Forbidden - admin required" }
+        }
+      }
+    },
+    "/api/v1/admin/readonly": {
+      "get": {
+        "summary": "Get the server-wide read-only lock status (admin only)",
+        "description": "Server-global, not per project: a single lock state applies to the entire database backing every project. Reflects the database's own authoritative state, obtained live from it on every call - the database can also enter read-only by itself after a degraded or failed recovery, not only via an operator's PUT to this endpoint, so readonly:true does not necessarily mean an operator locked it. While locked, the database refuses writes (observed error: \"Insert rejected: database is in read-only mode. manually locked via SetReadOnly RPC\") and vectorapi surfaces that as HTTP 503.",
+        "operationId": "getReadOnly",
+        "responses": {
+          "200": {
+            "description": "Read-only status",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "readonly": { "type": "boolean" },
+                    "scope": { "type": "string", "enum": ["server"], "description": "Always \"server\" - the lock is not scoped to a project." },
+                    "reason": { "type": "string", "description": "DB-defined free text explaining why the database is read-only. Present only when non-empty; its absence does not imply the state was operator-set." }
+                  },
+                  "required": ["readonly", "scope"]
+                }
+              }
+            }
+          },
+          "401": { "description": "Unauthorized" },
+          "403": { "description": "Forbidden - admin required" }
+        }
+      },
+      "put": {
+        "summary": "Set the server-wide read-only lock (admin only)",
+        "description": "Server-global, not per project. Locking blocks writes across every project on this server.",
+        "operationId": "setReadOnly",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object",
+                "properties": { "readonly": { "type": "boolean" } },
+                "required": ["readonly"]
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Read-only status after the change",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "readonly": { "type": "boolean" },
+                    "scope": { "type": "string", "enum": ["server"] },
+                    "reason": { "type": "string", "description": "Present only when non-empty" }
+                  },
+                  "required": ["readonly", "scope"]
+                }
+              }
+            }
+          },
+          "401": { "description": "Unauthorized" },
+          "403": { "description": "Forbidden - admin required" },
+          "422": { "description": "Validation error (readonly missing or not a boolean)" },
+          "503": { "description": "Database rejected the request (e.g. failed to change read-only mode)" }
         }
       }
     }