Bläddra i källkod

docs: add DB 2.11.1 upgrade design and vectorapi 0.2.0 feature plan

Two companion specs plus the implementation plan for the coordinated
upgrade of smartbotic-database 2.4.0 -> 2.11.1 on qdrant001.

The upgrade cannot be DB-only: both versions ship SONAME
libsmartbotic-db-client.so.2, so the linker silently binds the existing
binary to the new library, and a public struct grew a field in v2.7.1 -
inside the range being jumped. The failure is latent, appearing only on
the next restart. Records the rehearsal on zeus that proved 2.11.1 reads
the 2.4.0 encrypted data unchanged (42 docs in 16 collections,
TrivialSuccess recovery, 101/101 tests, doc counts matching live).

Also records what probing settled that the headers do not state: relation
names are project-qualified exactly like collection names, so vectorapi
keeps its single client and existing qualify() helper.

Ignore .playwright-mcp/ session artifacts.
fszontagh 1 månad sedan
förälder
incheckning
75df3193cb

+ 3 - 0
.gitignore

@@ -12,3 +12,6 @@ webui/dist/
 .DS_Store
 *.swp
 compile_commands.json
+
+# Playwright MCP session artifacts (browser snapshots/console logs)
+.playwright-mcp/

+ 1473 - 0
docs/superpowers/plans/2026-08-14-vectorapi-0.2.0-db-features.md

@@ -0,0 +1,1473 @@
+# vectorapi 0.2.0 — DB 2.11.1 Feature Adoption Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Expose smartbotic-database 2.11.1's secondary indexes, unique constraints, facets, document TTL, relations/referential integrity, read-only lock, and loaded-client-version through the vectorapi REST API and admin UI, so the live DB upgrade can proceed as one coordinated release.
+
+**Architecture:** Purely additive HTTP surface under the existing `/api/v1` prefix. New handlers `indexes.cpp` and `relations.cpp` follow the established `registerXRoutes(ApiServer&)` pattern and call `d->db.client()` directly, exactly as `collections.cpp` does. Relation names are project-qualified through the existing `qualify()` helper — no per-project client instances. Indexes and relations are read live from the DB; the collection registry is not extended, so there is no second source of truth.
+
+**Tech Stack:** C++20, cpp-httplib, nlohmann/json, spdlog, GoogleTest; React 19 + Vite + TS + Tailwind for the UI.
+
+**Spec:** `docs/superpowers/specs/2026-08-14-vectorapi-0.2.0-db-features-design.md`
+
+## Global Constraints
+
+- C++20. Namespace `svapi`. Flat headers, quoted includes. Handlers `throw svapi::ApiError`; the global handler maps to `{error:{code,message}}`.
+- The DB client is reached via `d->db.client()`. Every collection **and relation** name crossing into it goes through `qualify(project, name)`.
+- Reserved prefixes stay rejected: a collection or relation name may not start with `_` or `vectorapi_`.
+- TS: no `any`, no TS `enum`, no constructor parameter-property shorthand (tsconfig `erasableSyntaxOnly`). Iterating parsed JSON in C++: bind to a named var first (range-for over a temporary dangles).
+- `api/openapi.json` and `api/llms.txt` MUST stay in sync with routes — they are shipped and served at `/docs`.
+- Version for both debs: **0.2.0**. This is the version published to the apt repo; nothing is published before Task 13.
+- **Integration tests require a running DB 2.11.1.** `ApiFixture::SetUp` calls `GTEST_SKIP()` when the DB is unreachable, so a green run can mean "nothing ran". Every `ctest` run in this plan must be checked for skips, not just failures.
+- Commits use conventional prefixes (`feat`/`fix`/`build`/`docs`/`test`).
+
+---
+
+### Task 1: Development environment on DB 2.11.1
+
+Without this, every integration test in this plan silently skips and the plan appears to pass while testing nothing.
+
+**Files:**
+- Modify: none (environment only)
+
+**Interfaces:**
+- Produces: a `smartbotic-database` 2.11.1 listening on `localhost:9004` for all later tasks.
+
+- [ ] **Step 1: Check what the dev box currently runs**
+
+```bash
+dpkg-query -W -f='${Package} ${Version}\n' 'smartbotic-*' 'libsmartbotic-*' 2>/dev/null
+ss -ltnp 2>/dev/null | grep 9004 || echo "nothing on 9004"
+```
+
+- [ ] **Step 2: Install DB + client-dev 2.11.1 locally**
+
+```bash
+sudo apt-get update
+sudo apt-get install -y smartbotic-database libsmartbotic-db-client libsmartbotic-db-client-dev
+dpkg-query -W -f='${Package} ${Version}\n' smartbotic-database libsmartbotic-db-client-dev
+```
+
+Expected: both report `2.11.1-1`.
+
+- [ ] **Step 3: Confirm the DB is reachable**
+
+```bash
+sudo systemctl restart smartbotic-database
+sleep 3
+systemctl is-active smartbotic-database
+```
+
+Expected: `active`.
+
+- [ ] **Step 4: Rebuild the project and confirm tests RUN (not skip)**
+
+```bash
+cmake -B build -G Ninja -DBUILD_TESTS=ON -DBUILD_WEBUI=OFF -S . && cmake --build build -j"$(nproc)"
+ctest --test-dir build --output-on-failure 2>&1 | tail -5
+```
+
+Expected: `101 tests passed`, and **zero** "Skipped". If you see skips, the DB is not reachable — stop and fix before continuing.
+
+- [ ] **Step 5: Commit**
+
+Nothing to commit (environment only). Proceed to Task 2.
+
+---
+
+### Task 2: `ErrCode::Conflict` (409) and structured error details
+
+Both unique-index violations and relation-blocked deletes need 409 plus a structured payload. Doing this first means later tasks just use it.
+
+**Files:**
+- Modify: `src/errors.hpp`
+- Modify: `src/errors.cpp`
+- Test: `tests/test_errors.cpp`
+
+**Interfaces:**
+- Produces: `ErrCode::Conflict` -> HTTP 409; `errorBody(code, message, details)` where `details` is an optional `nlohmann::json` object merged into the `error` object; `ApiError` gains a `details` member that the global exception handler emits.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `tests/test_errors.cpp`:
+
+```cpp
+TEST(Errors, ConflictMapsTo409) {
+    EXPECT_EQ(svapi::httpStatus(svapi::ErrCode::Conflict), 409);
+}
+
+TEST(Errors, ErrorBodyCarriesDetails) {
+    nlohmann::json det = {{"duplicate_examples", nlohmann::json::array({"a", "b"})}};
+    auto body = svapi::errorBody("duplicate_values", "field holds duplicates", det);
+    EXPECT_EQ(body["error"]["code"], "duplicate_values");
+    EXPECT_EQ(body["error"]["message"], "field holds duplicates");
+    ASSERT_TRUE(body["error"].contains("details"));
+    EXPECT_EQ(body["error"]["details"]["duplicate_examples"].size(), 2u);
+}
+
+TEST(Errors, ErrorBodyOmitsEmptyDetails) {
+    auto body = svapi::errorBody("validation", "bad");
+    EXPECT_FALSE(body["error"].contains("details"));
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_errors`
+Expected: FAIL — `Conflict` is not a member of `ErrCode`, and `errorBody` takes 2 args.
+
+- [ ] **Step 3: Write minimal implementation**
+
+In `src/errors.hpp`, add `Conflict` to the enum and extend the signatures:
+
+```cpp
+enum class ErrCode { BadRequest, Unauthorized, Forbidden, NotFound, Conflict,
+                     Unprocessable, TooManyRequests, Unavailable, Internal };
+
+int httpStatus(ErrCode code);
+
+/// `details` is merged into the error object as `error.details` when non-empty.
+/// Used for machine-readable payloads (colliding ids, relation impacts) that
+/// would otherwise be flattened into the message string.
+nlohmann::json errorBody(const std::string& code, const std::string& message,
+                         const nlohmann::json& details = nlohmann::json::object());
+
+struct ApiError : std::runtime_error {
+    ErrCode code;
+    std::string slug;
+    nlohmann::json details;   // optional structured payload; empty object = omitted
+    ApiError(ErrCode c, std::string slug_, const std::string& msg,
+             nlohmann::json det = nlohmann::json::object())
+        : std::runtime_error(msg), code(c), slug(std::move(slug_)), details(std::move(det)) {}
+};
+```
+
+In `src/errors.cpp`:
+
+```cpp
+int httpStatus(ErrCode code) {
+    switch (code) {
+        case ErrCode::BadRequest:    return 400;
+        case ErrCode::Unauthorized:  return 401;
+        case ErrCode::Forbidden:     return 403;
+        case ErrCode::NotFound:      return 404;
+        case ErrCode::Conflict:      return 409;
+        case ErrCode::Unprocessable:    return 422;
+        case ErrCode::TooManyRequests:  return 429;
+        case ErrCode::Unavailable:      return 503;
+        case ErrCode::Internal:      return 500;
+    }
+    return 500;
+}
+
+nlohmann::json errorBody(const std::string& code, const std::string& message,
+                         const nlohmann::json& details) {
+    nlohmann::json err{{"code", code}, {"message", message}};
+    if (!details.is_null() && !details.empty()) err["details"] = details;
+    return nlohmann::json{{"error", err}};
+}
+```
+
+- [ ] **Step 4: Wire the exception handler to emit details**
+
+Find the global exception handler in `src/server.cpp` (it calls `errorBody(e.slug, e.what())`) and pass the details through:
+
+```cpp
+sendJson(res, httpStatus(e.code), errorBody(e.slug, e.what(), e.details));
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_errors && ctest --test-dir build --output-on-failure 2>&1 | tail -3`
+Expected: all PASS, no skips.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/errors.hpp src/errors.cpp src/server.cpp tests/test_errors.cpp
+git commit -m "feat(errors): add 409 Conflict and structured error details"
+```
+
+---
+
+### Task 3: Index create / list / drop
+
+**Files:**
+- Create: `src/handlers/indexes.cpp`
+- Modify: `src/CMakeLists.txt` (add `handlers/indexes.cpp` after `handlers/collections.cpp`)
+- Modify: `src/server.hpp` (declare `void registerIndexRoutes(ApiServer&);`)
+- Modify: `src/server.cpp` (call `registerIndexRoutes(*this);` alongside the other register calls)
+- Test: `tests/test_api_integration.cpp`
+
+**Interfaces:**
+- Consumes: `ErrCode::Conflict` (Task 2); `qualify(project, collection)`; `requireProjectManage`, `requireProjectAccess`.
+- Produces: routes `POST|GET /api/v1/projects/{p}/collections/{c}/indexes` and `DELETE .../indexes/{field}`. POST response `{field, unique, rows_indexed, already_existed}`; GET response `{indexes:[{field, distinct_values, entries, unique}]}`.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `tests/test_api_integration.cpp`:
+
+```cpp
+TEST_F(ApiFixture, IndexCreateListDrop) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","idxc"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    std::string idx = base + "/idxc/indexes";
+
+    auto made = c.Post(idx.c_str(), nlohmann::json{{"field","status"}}.dump(), "application/json");
+    ASSERT_TRUE(made); EXPECT_EQ(made->status, 201);
+    auto madeJson = nlohmann::json::parse(made->body);
+    EXPECT_EQ(madeJson["field"], "status");
+    EXPECT_FALSE(madeJson["unique"].get<bool>());
+
+    auto list = c.Get(idx.c_str());
+    ASSERT_EQ(list->status, 200);
+    auto listJson = nlohmann::json::parse(list->body);
+    bool found = false;
+    for (auto& e : listJson["indexes"]) if (e["field"] == "status") found = true;
+    EXPECT_TRUE(found);
+
+    EXPECT_EQ(c.Delete((idx + "/status").c_str())->status, 200);
+    auto after = c.Get(idx.c_str());
+    auto afterJson = nlohmann::json::parse(after->body);
+    for (auto& e : afterJson["indexes"]) EXPECT_NE(e["field"], "status");
+}
+
+TEST_F(ApiFixture, IndexCreateRequiresField) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","idxv"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    auto bad = c.Post((base + "/idxv/indexes").c_str(), nlohmann::json{}.dump(), "application/json");
+    ASSERT_TRUE(bad); EXPECT_EQ(bad->status, 422);
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*Index*'`
+Expected: FAIL with 404 on the index routes (not registered).
+
+- [ ] **Step 3: Write minimal implementation**
+
+Create `src/handlers/indexes.cpp`:
+
+```cpp
+#include "errors.hpp"
+#include "json_http.hpp"
+#include "server.hpp"
+
+namespace svapi {
+
+void registerIndexRoutes(ApiServer& s) {
+    auto& svr = s.raw(); ServerDeps* d = &s.deps();
+
+    // Declare an index. Backfills existing rows, so it is usable immediately.
+    // Idempotent: re-declaring an existing index reports already_existed.
+    svr.Post(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/indexes)",
+             [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req);
+        std::string project = req.matches[1], coll = req.matches[2];
+        requireProjectManage(k, project);
+        auto body = bodyJson(req);
+        std::string field = body.value("field", "");
+        if (field.empty()) throw ApiError(ErrCode::Unprocessable, "validation", "field is required");
+
+        const std::string qc = qualify(project, coll);
+        if (!d->db.client().getCollectionInfo(qc))
+            throw ApiError(ErrCode::NotFound, "not_found", "collection not found");
+
+        uint64_t rows = 0;
+        if (!d->db.client().createIndex(qc, field, rows))
+            throw ApiError(ErrCode::Unavailable, "db_error", "failed to create index");
+        sendJson(res, 201, {{"field", field}, {"unique", false},
+                            {"rows_indexed", rows}, {"already_existed", false}});
+    });
+
+    svr.Get(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/indexes)",
+            [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req);
+        std::string project = req.matches[1], coll = req.matches[2];
+        requireProjectAccess(k, project);
+
+        const std::string qc = qualify(project, coll);
+        if (!d->db.client().getCollectionInfo(qc))
+            throw ApiError(ErrCode::NotFound, "not_found", "collection not found");
+
+        std::vector<std::string> uniqueFields;
+        auto defs = d->db.client().listIndexes(qc, uniqueFields);
+        nlohmann::json out = nlohmann::json::array();
+        for (const auto& def : defs) {
+            bool uniq = false;
+            for (const auto& u : uniqueFields) if (u == def.field) uniq = true;
+            out.push_back({{"field", def.field},
+                           {"distinct_values", def.distinctValues},
+                           {"entries", def.entries},
+                           {"unique", uniq}});
+        }
+        sendJson(res, 200, {{"indexes", out}});
+    });
+
+    svr.Delete(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/indexes/([^/]+))",
+               [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req);
+        std::string project = req.matches[1], coll = req.matches[2], field = req.matches[3];
+        requireProjectManage(k, project);
+        if (!d->db.client().dropIndex(qualify(project, coll), field))
+            throw ApiError(ErrCode::NotFound, "not_found", "index not found");
+        sendJson(res, 200, {{"dropped", field}});
+    });
+}
+
+} // namespace svapi
+```
+
+Note: confirm the second `listIndexes` overload's out-parameter type against `/usr/include/smartbotic/database/client.hpp:730` before building; if it differs, adapt this call and keep the response shape identical.
+
+- [ ] **Step 4: Wire it up**
+
+In `src/server.hpp`, beside the other declarations:
+
+```cpp
+void registerIndexRoutes(ApiServer&);
+```
+
+In `src/server.cpp`, beside the other register calls:
+
+```cpp
+registerIndexRoutes(*this);
+```
+
+In `src/CMakeLists.txt`, after `handlers/collections.cpp`:
+
+```cmake
+    handlers/indexes.cpp
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*Index*'`
+Expected: PASS, no skips.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/handlers/indexes.cpp src/server.hpp src/server.cpp src/CMakeLists.txt tests/test_api_integration.cpp
+git commit -m "feat(indexes): declare, list and drop secondary indexes"
+```
+
+---
+
+### Task 4: Unique indexes with 409 duplicate reporting
+
+**Files:**
+- Modify: `src/handlers/indexes.cpp`
+- Test: `tests/test_api_integration.cpp`
+
+**Interfaces:**
+- Consumes: `ErrCode::Conflict` + `ApiError` details (Task 2); the POST route from Task 3.
+- Produces: `POST .../indexes {field, unique:true}`; on duplicates, HTTP 409 slug `duplicate_values` with `error.details.duplicate_examples` = array of colliding document ids.
+
+- [ ] **Step 1: Write the failing test**
+
+```cpp
+TEST_F(ApiFixture, UniqueIndexRejectsExistingDuplicates) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","uq"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    std::string docs = base + "/uq/documents";
+    ASSERT_EQ(c.Post(docs.c_str(), nlohmann::json{{"sku","A1"}}.dump(), "application/json")->status, 201);
+    ASSERT_EQ(c.Post(docs.c_str(), nlohmann::json{{"sku","A1"}}.dump(), "application/json")->status, 201);
+
+    auto conflict = c.Post((base + "/uq/indexes").c_str(),
+                           nlohmann::json{{"field","sku"},{"unique",true}}.dump(), "application/json");
+    ASSERT_TRUE(conflict); EXPECT_EQ(conflict->status, 409);
+    auto body = nlohmann::json::parse(conflict->body);
+    EXPECT_EQ(body["error"]["code"], "duplicate_values");
+    ASSERT_TRUE(body["error"]["details"].contains("duplicate_examples"));
+    EXPECT_GE(body["error"]["details"]["duplicate_examples"].size(), 1u);
+}
+
+TEST_F(ApiFixture, UniqueIndexOnCleanFieldSucceeds) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","uq2"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    std::string docs = base + "/uq2/documents";
+    ASSERT_EQ(c.Post(docs.c_str(), nlohmann::json{{"sku","B1"}}.dump(), "application/json")->status, 201);
+    ASSERT_EQ(c.Post(docs.c_str(), nlohmann::json{{"sku","B2"}}.dump(), "application/json")->status, 201);
+
+    auto made = c.Post((base + "/uq2/indexes").c_str(),
+                       nlohmann::json{{"field","sku"},{"unique",true}}.dump(), "application/json");
+    ASSERT_TRUE(made); EXPECT_EQ(made->status, 201);
+    EXPECT_TRUE(nlohmann::json::parse(made->body)["unique"].get<bool>());
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*Unique*'`
+Expected: FAIL — `unique` is ignored, so both return 201.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Replace the body of the POST handler in `src/handlers/indexes.cpp` (after the `field` validation and collection check) with:
+
+```cpp
+        const bool unique = body.value("unique", false);
+        if (unique) {
+            auto r = d->db.client().createUniqueIndex(qc, field);
+            if (!r.success) {
+                if (!r.duplicateExamples.empty()) {
+                    nlohmann::json ex = nlohmann::json::array();
+                    for (const auto& id : r.duplicateExamples) ex.push_back(id);
+                    throw ApiError(ErrCode::Conflict, "duplicate_values",
+                                   "field '" + field + "' already holds duplicate values",
+                                   {{"duplicate_examples", ex}});
+                }
+                throw ApiError(ErrCode::Unavailable, "db_error",
+                               r.error.empty() ? "failed to create unique index" : r.error);
+            }
+            sendJson(res, 201, {{"field", field}, {"unique", true},
+                                {"rows_indexed", r.rowsIndexed},
+                                {"already_existed", r.alreadyExisted}});
+            return;
+        }
+
+        uint64_t rows = 0;
+        if (!d->db.client().createIndex(qc, field, rows))
+            throw ApiError(ErrCode::Unavailable, "db_error", "failed to create index");
+        sendJson(res, 201, {{"field", field}, {"unique", false},
+                            {"rows_indexed", rows}, {"already_existed", false}});
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*Unique*:*Index*'`
+Expected: PASS, no skips.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/handlers/indexes.cpp tests/test_api_integration.cpp
+git commit -m "feat(indexes): unique indexes with 409 duplicate reporting"
+```
+
+---
+
+### Task 5: Facet values endpoint
+
+**Files:**
+- Modify: `src/handlers/indexes.cpp`
+- Test: `tests/test_api_integration.cpp`
+
+**Interfaces:**
+- Consumes: `requireCapability(*d, k, req, project, collection, KeyOp::Read)`.
+- Produces: `GET .../indexes/{field}/values?limit=100&order=asc|desc` -> `{values:[{value, count}]}`. Empty array for an unindexed or array-valued field (documented, not an error).
+
+- [ ] **Step 1: Write the failing test**
+
+```cpp
+TEST_F(ApiFixture, IndexValuesReturnsFacetCounts) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","facets"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    std::string docs = base + "/facets/documents";
+    for (const char* st : {"open", "open", "closed"})
+        ASSERT_EQ(c.Post(docs.c_str(), nlohmann::json{{"status", st}}.dump(), "application/json")->status, 201);
+    ASSERT_EQ(c.Post((base + "/facets/indexes").c_str(),
+                     nlohmann::json{{"field","status"}}.dump(), "application/json")->status, 201);
+
+    auto r = c.Get((base + "/facets/indexes/status/values").c_str());
+    ASSERT_TRUE(r); EXPECT_EQ(r->status, 200);
+    auto body = nlohmann::json::parse(r->body);
+    uint64_t open = 0, closed = 0;
+    for (auto& e : body["values"]) {
+        if (e["value"] == "open")   open   = e["count"].get<uint64_t>();
+        if (e["value"] == "closed") closed = e["count"].get<uint64_t>();
+    }
+    EXPECT_EQ(open, 2u);
+    EXPECT_EQ(closed, 1u);
+}
+
+TEST_F(ApiFixture, IndexValuesEmptyForUnindexedField) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","nofacet"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    auto r = c.Get((base + "/nofacet/indexes/whatever/values").c_str());
+    ASSERT_TRUE(r); EXPECT_EQ(r->status, 200);
+    EXPECT_TRUE(nlohmann::json::parse(r->body)["values"].empty());
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*IndexValues*'`
+Expected: FAIL with 404 — the route does not exist.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Add to `registerIndexRoutes` in `src/handlers/indexes.cpp`. Register this BEFORE the `DELETE .../indexes/{field}` route is irrelevant (different verb), but it MUST be declared before any broader GET pattern that could shadow it:
+
+```cpp
+    // Distinct values of an indexed field, with counts. Index-dependent by
+    // design: returns an empty list for an unindexed field and for array-valued
+    // fields, whose index keys are elements rather than values.
+    svr.Get(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/indexes/([^/]+)/values)",
+            [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req);
+        std::string project = req.matches[1], coll = req.matches[2], field = req.matches[3];
+        requireCapability(*d, k, req, project, coll, KeyOp::Read);
+
+        uint32_t limit = 100;
+        if (req.has_param("limit")) {
+            limit = (uint32_t)std::strtoul(req.get_param_value("limit").c_str(), nullptr, 10);
+            if (limit == 0 || limit > 1000)
+                throw ApiError(ErrCode::Unprocessable, "validation", "limit must be 1..1000");
+        }
+        const bool ascending = !(req.has_param("order") && req.get_param_value("order") == "desc");
+
+        auto vals = d->db.client().indexValues(qualify(project, coll), field, limit, ascending);
+        nlohmann::json out = nlohmann::json::array();
+        for (const auto& v : vals) out.push_back({{"value", v.value}, {"count", v.count}});
+        sendJson(res, 200, {{"values", out}});
+    });
+```
+
+Add `#include <cstdlib>` at the top of the file for `strtoul`.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*IndexValues*:*Index*:*Unique*'`
+Expected: PASS, no skips.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/handlers/indexes.cpp tests/test_api_integration.cpp
+git commit -m "feat(indexes): facet values endpoint with counts"
+```
+
+---
+
+### Task 6: Document TTL on insert and patch
+
+**Files:**
+- Modify: `src/handlers/documents.cpp`
+- Test: `tests/test_api_integration.cpp`
+
+**Interfaces:**
+- Produces: `ttl_seconds` query parameter on `POST .../documents` and `PATCH .../documents/{id}`. On PATCH, **absence** leaves the expiry untouched (3-arg `patch`); **presence** sets it (`patchWithTtl`), and `ttl_seconds=0` CLEARS it.
+
+- [ ] **Step 1: Write the failing test**
+
+```cpp
+TEST_F(ApiFixture, DocumentTtlAcceptedOnInsertAndPatch) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","ttlc"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    std::string docs = base + "/ttlc/documents";
+
+    auto made = c.Post((docs + "?ttl_seconds=3600").c_str(),
+                       nlohmann::json{{"v",1}}.dump(), "application/json");
+    ASSERT_TRUE(made); EXPECT_EQ(made->status, 201);
+    std::string id = nlohmann::json::parse(made->body)["id"];
+
+    // Document is readable while the TTL is in the future.
+    EXPECT_EQ(c.Get((docs + "/" + id).c_str())->status, 200);
+
+    // ttl_seconds=0 on PATCH clears the expiry (does not delete the document).
+    auto cleared = c.Patch((docs + "/" + id + "?ttl_seconds=0").c_str(),
+                           nlohmann::json{{"v",2}}.dump(), "application/json");
+    ASSERT_TRUE(cleared); EXPECT_EQ(cleared->status, 200);
+    auto got = c.Get((docs + "/" + id).c_str());
+    ASSERT_EQ(got->status, 200);
+    EXPECT_EQ(nlohmann::json::parse(got->body)["v"], 2);
+}
+
+TEST_F(ApiFixture, DocumentTtlRejectsNonNumeric) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","ttlbad"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    auto bad = c.Post((base + "/ttlbad/documents?ttl_seconds=soon").c_str(),
+                      nlohmann::json{{"v",1}}.dump(), "application/json");
+    ASSERT_TRUE(bad); EXPECT_EQ(bad->status, 422);
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*DocumentTtl*'`
+Expected: FAIL — `ttl_seconds=soon` is ignored, so the bad case returns 201.
+
+- [ ] **Step 3: Write minimal implementation**
+
+At the top of `src/handlers/documents.cpp`, add a shared parser:
+
+```cpp
+// Parses ?ttl_seconds=. Returns nullopt when absent — the caller must
+// distinguish "not supplied" (leave expiry alone) from 0 (clear the expiry).
+static std::optional<uint32_t> ttlParam(const httplib::Request& req) {
+    if (!req.has_param("ttl_seconds")) return std::nullopt;
+    const std::string raw = req.get_param_value("ttl_seconds");
+    if (raw.empty() || raw.find_first_not_of("0123456789") != std::string::npos)
+        throw ApiError(ErrCode::Unprocessable, "validation", "ttl_seconds must be a non-negative integer");
+    return (uint32_t)std::strtoul(raw.c_str(), nullptr, 10);
+}
+```
+
+Add `#include <optional>` and `#include <cstdlib>` if not already present.
+
+In the POST (insert) handler, pass it through to `insert`:
+
+```cpp
+        const uint32_t ttl = ttlParam(req).value_or(0);
+        std::string newId = d->db.client().insert(qualify(project, coll), doc, id, ttl, actor);
+```
+
+In the PATCH handler, branch on presence:
+
+```cpp
+        const auto ttl = ttlParam(req);
+        uint64_t version = ttl ? d->db.client().patchWithTtl(qualify(project, coll), id, doc, *ttl, actor)
+                               : d->db.client().patch(qualify(project, coll), id, doc, actor);
+```
+
+Keep the existing handling of the returned version/failure exactly as it is; only the call selection changes.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*DocumentTtl*'`
+Expected: PASS, no skips.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/handlers/documents.cpp tests/test_api_integration.cpp
+git commit -m "feat(documents): ttl_seconds on insert and patch"
+```
+
+---
+
+### Task 7: Relation create / list / get / drop
+
+**Files:**
+- Create: `src/handlers/relations.cpp`
+- Modify: `src/CMakeLists.txt`, `src/server.hpp`, `src/server.cpp`
+- Test: `tests/test_api_integration.cpp`
+
+**Interfaces:**
+- Consumes: `qualify(project, name)` — applied to the relation NAME as well as child/parent collections (verified: the server rejects a bare name whose collections are in another project).
+- Produces: `POST|GET /api/v1/projects/{p}/relations`, `GET|DELETE /api/v1/projects/{p}/relations/{name}`. Relation objects are returned with **unqualified** names and collections, so the API never leaks the `project:` prefix.
+
+- [ ] **Step 1: Write the failing test**
+
+```cpp
+TEST_F(ApiFixture, RelationCrud) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","orders"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","customers"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+
+    std::string rel = "/api/v1/projects/" + project_ + "/relations";
+    auto made = c.Post(rel.c_str(), nlohmann::json{
+        {"name","order_customer"}, {"child","orders"}, {"child_field","customer_id"},
+        {"parent","customers"}, {"on_delete","restrict"}}.dump(), "application/json");
+    ASSERT_TRUE(made); EXPECT_EQ(made->status, 201);
+
+    auto list = c.Get(rel.c_str());
+    ASSERT_EQ(list->status, 200);
+    auto listJson = nlohmann::json::parse(list->body);
+    bool found = false;
+    for (auto& e : listJson["relations"])
+        if (e["name"] == "order_customer") { found = true; EXPECT_EQ(e["child"], "orders"); }
+    EXPECT_TRUE(found);
+
+    auto one = c.Get((rel + "/order_customer").c_str());
+    ASSERT_EQ(one->status, 200);
+    EXPECT_EQ(nlohmann::json::parse(one->body)["parent"], "customers");
+
+    EXPECT_EQ(c.Delete((rel + "/order_customer").c_str())->status, 200);
+    EXPECT_EQ(c.Get((rel + "/order_customer").c_str())->status, 404);
+}
+
+TEST_F(ApiFixture, RelationRejectsBadOnDelete) {
+    auto c = admin();
+    std::string rel = "/api/v1/projects/" + project_ + "/relations";
+    auto bad = c.Post(rel.c_str(), nlohmann::json{
+        {"name","r"}, {"child","a"}, {"child_field","b"},
+        {"parent","c"}, {"on_delete","explode"}}.dump(), "application/json");
+    ASSERT_TRUE(bad); EXPECT_EQ(bad->status, 422);
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*Relation*'`
+Expected: FAIL with 404 — routes not registered.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Create `src/handlers/relations.cpp`:
+
+```cpp
+#include "errors.hpp"
+#include "json_http.hpp"
+#include "server.hpp"
+
+namespace svapi {
+namespace {
+
+// Strip a leading "project:" so the API never leaks qualified names outward.
+std::string unqualify(const std::string& project, const std::string& name) {
+    const std::string pfx = project + ":";
+    return name.rfind(pfx, 0) == 0 ? name.substr(pfx.size()) : name;
+}
+
+nlohmann::json relJson(const std::string& project,
+                       const smartbotic::database::Client::RelationDefinition& r) {
+    return {{"name",        unqualify(project, r.name)},
+            {"child",       unqualify(project, r.child)},
+            {"child_field", r.childField},
+            {"parent",      unqualify(project, r.parent)},
+            {"on_delete",   r.onDelete},
+            {"validate_on_write", r.validateOnWrite},
+            {"created_at",  r.createdAt},
+            {"updated_at",  r.updatedAt}};
+}
+
+} // namespace
+
+void registerRelationRoutes(ApiServer& s) {
+    auto& svr = s.raw(); ServerDeps* d = &s.deps();
+
+    svr.Post(R"(/api/v1/projects/([^/]+)/relations)",
+             [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req); std::string project = req.matches[1];
+        requireAdmin(k); requireProjectAccess(k, project);
+        auto body = bodyJson(req);
+        std::string name  = body.value("name", ""),  child  = body.value("child", ""),
+                    field = body.value("child_field", ""), parent = body.value("parent", ""),
+                    onDelete = body.value("on_delete", "restrict");
+        if (name.empty() || child.empty() || field.empty() || parent.empty())
+            throw ApiError(ErrCode::Unprocessable, "validation",
+                           "name, child, child_field and parent are required");
+        if (name.rfind('_', 0) == 0 || name.rfind("vectorapi_", 0) == 0)
+            throw ApiError(ErrCode::Unprocessable, "validation",
+                           "name may not start with '_' or the reserved 'vectorapi_' prefix");
+        if (onDelete != "restrict" && onDelete != "cascade" &&
+            onDelete != "set_null" && onDelete != "no_action")
+            throw ApiError(ErrCode::Unprocessable, "validation",
+                           "on_delete must be restrict, cascade, set_null or no_action");
+        const bool validateOnWrite = body.value("validate_on_write", false);
+
+        // The relation NAME is project-qualified exactly like the collections.
+        if (!d->db.client().createRelation(qualify(project, name), qualify(project, child), field,
+                                           qualify(project, parent), onDelete, validateOnWrite))
+            throw ApiError(ErrCode::Unavailable, "db_error", "failed to create relation");
+        sendJson(res, 201, {{"name", name}, {"child", child}, {"child_field", field},
+                            {"parent", parent}, {"on_delete", onDelete},
+                            {"validate_on_write", validateOnWrite}});
+    });
+
+    svr.Get(R"(/api/v1/projects/([^/]+)/relations)",
+            [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req); std::string project = req.matches[1];
+        requireAdmin(k); requireProjectAccess(k, project);
+        auto defs = d->db.client().listRelations(project);
+        nlohmann::json out = nlohmann::json::array();
+        for (const auto& r : defs) out.push_back(relJson(project, r));
+        sendJson(res, 200, {{"relations", out}});
+    });
+
+    svr.Get(R"(/api/v1/projects/([^/]+)/relations/([^/]+))",
+            [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req);
+        std::string project = req.matches[1], name = req.matches[2];
+        requireAdmin(k); requireProjectAccess(k, project);
+        auto info = d->db.client().getRelationInfo(qualify(project, name));
+        if (!info) throw ApiError(ErrCode::NotFound, "not_found", "relation not found");
+        sendJson(res, 200, relJson(project, *info));
+    });
+
+    svr.Delete(R"(/api/v1/projects/([^/]+)/relations/([^/]+))",
+               [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req);
+        std::string project = req.matches[1], name = req.matches[2];
+        requireAdmin(k); requireProjectAccess(k, project);
+        if (!d->db.client().dropRelation(qualify(project, name)))
+            throw ApiError(ErrCode::NotFound, "not_found", "relation not found");
+        sendJson(res, 200, {{"dropped", name}});
+    });
+}
+
+} // namespace svapi
+```
+
+Check the exact `createRelation` overload signature at `client.hpp:835` and `:851` — one takes `validateOnWrite`, one does not. Use the matching overload.
+
+- [ ] **Step 4: Wire it up**
+
+`src/server.hpp`: `void registerRelationRoutes(ApiServer&);`
+`src/server.cpp`: `registerRelationRoutes(*this);`
+`src/CMakeLists.txt`: `    handlers/relations.cpp`
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*Relation*'`
+Expected: PASS, no skips.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/handlers/relations.cpp src/server.hpp src/server.cpp src/CMakeLists.txt tests/test_api_integration.cpp
+git commit -m "feat(relations): create, list, get and drop relations"
+```
+
+---
+
+### Task 8: Delete-impact, enforcement toggle, and 409 on blocked delete
+
+**Files:**
+- Modify: `src/handlers/relations.cpp` (enforcement toggle + delete-impact)
+- Modify: `src/handlers/documents.cpp` (409 on blocked delete)
+- Test: `tests/test_api_integration.cpp`
+
+**Interfaces:**
+- Consumes: `describeDelete` -> `{success, error, wouldBeBlocked, impacts[]}` where each impact has `{relation, childCollection, childField, onDelete, childCount, sampleChildIds, blocks}`.
+- Produces: `GET .../documents/{id}/delete-impact`; `PUT .../collections/{c}/relations-enforced`; document DELETE returns 409 slug `relation_restricted` with `error.details.impacts`.
+
+- [ ] **Step 1: Write the failing test**
+
+```cpp
+TEST_F(ApiFixture, DeleteBlockedByRelationReturns409WithImpacts) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","inv"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","cust"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    std::string rel = "/api/v1/projects/" + project_ + "/relations";
+    ASSERT_EQ(c.Post(rel.c_str(), nlohmann::json{
+        {"name","inv_cust"}, {"child","inv"}, {"child_field","cust_id"},
+        {"parent","cust"}, {"on_delete","restrict"}}.dump(), "application/json")->status, 201);
+
+    auto p = c.Post((base + "/cust/documents").c_str(),
+                    nlohmann::json{{"n","acme"}}.dump(), "application/json");
+    ASSERT_EQ(p->status, 201);
+    std::string pid = nlohmann::json::parse(p->body)["id"];
+    ASSERT_EQ(c.Post((base + "/inv/documents").c_str(),
+                     nlohmann::json{{"cust_id", pid}}.dump(), "application/json")->status, 201);
+
+    auto impact = c.Get((base + "/cust/documents/" + pid + "/delete-impact").c_str());
+    ASSERT_TRUE(impact); EXPECT_EQ(impact->status, 200);
+    auto impactJson = nlohmann::json::parse(impact->body);
+    EXPECT_TRUE(impactJson["would_be_blocked"].get<bool>());
+    EXPECT_GE(impactJson["impacts"].size(), 1u);
+
+    auto del = c.Delete((base + "/cust/documents/" + pid).c_str());
+    ASSERT_TRUE(del); EXPECT_EQ(del->status, 409);
+    auto delJson = nlohmann::json::parse(del->body);
+    EXPECT_EQ(delJson["error"]["code"], "relation_restricted");
+    EXPECT_GE(delJson["error"]["details"]["impacts"].size(), 1u);
+}
+
+TEST_F(ApiFixture, RelationsEnforcedTogglePermitsDelete) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","inv2"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","cust2"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    std::string rel = "/api/v1/projects/" + project_ + "/relations";
+    ASSERT_EQ(c.Post(rel.c_str(), nlohmann::json{
+        {"name","inv2_cust2"}, {"child","inv2"}, {"child_field","cust_id"},
+        {"parent","cust2"}, {"on_delete","restrict"}}.dump(), "application/json")->status, 201);
+
+    auto p = c.Post((base + "/cust2/documents").c_str(),
+                    nlohmann::json{{"n","x"}}.dump(), "application/json");
+    std::string pid = nlohmann::json::parse(p->body)["id"];
+    ASSERT_EQ(c.Post((base + "/inv2/documents").c_str(),
+                     nlohmann::json{{"cust_id", pid}}.dump(), "application/json")->status, 201);
+
+    auto off = c.Put((base + "/inv2/relations-enforced").c_str(),
+                     nlohmann::json{{"enforced", false}}.dump(), "application/json");
+    ASSERT_TRUE(off); EXPECT_EQ(off->status, 200);
+    EXPECT_EQ(c.Delete((base + "/cust2/documents/" + pid).c_str())->status, 200);
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*DeleteBlocked*:*RelationsEnforced*'`
+Expected: FAIL — delete-impact route missing (404) and delete returns 503/500 rather than 409.
+
+- [ ] **Step 3: Implement delete-impact and the enforcement toggle**
+
+Add to `registerRelationRoutes` in `src/handlers/relations.cpp` (and add a shared helper the documents handler will reuse):
+
+```cpp
+// Shared with handlers/documents.cpp: renders describeDelete impacts as JSON.
+nlohmann::json impactsJson(const std::string& project,
+                           const smartbotic::database::Client::DescribeDeleteResult& r) {
+    nlohmann::json arr = nlohmann::json::array();
+    for (const auto& i : r.impacts) {
+        nlohmann::json ids = nlohmann::json::array();
+        for (const auto& s : i.sampleChildIds) ids.push_back(s);
+        arr.push_back({{"relation",         unqualify(project, i.relation)},
+                       {"child_collection", unqualify(project, i.childCollection)},
+                       {"child_field",      i.childField},
+                       {"on_delete",        i.onDelete},
+                       {"child_count",      i.childCount},
+                       {"sample_child_ids", ids},
+                       {"blocks",           i.blocks}});
+    }
+    return arr;
+}
+```
+
+Move `unqualify` and `impactsJson` out of the anonymous namespace into `namespace svapi` and declare both in `src/server.hpp` so `documents.cpp` can call them:
+
+```cpp
+std::string unqualify(const std::string& project, const std::string& name);
+nlohmann::json impactsJson(const std::string& project,
+                           const smartbotic::database::Client::DescribeDeleteResult& r);
+```
+
+Routes:
+
+```cpp
+    svr.Get(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/documents/([^/]+)/delete-impact)",
+            [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req);
+        std::string project = req.matches[1], coll = req.matches[2], id = req.matches[3];
+        // Read-only: changes nothing, but child counts and sample ids are facts
+        // about data, so it follows the collection's read grant.
+        requireCapability(*d, k, req, project, coll, KeyOp::Read);
+        auto r = d->db.client().describeDelete(qualify(project, coll), id);
+        if (!r.success) throw ApiError(ErrCode::NotFound, "not_found",
+                                       r.error.empty() ? "document not found" : r.error);
+        sendJson(res, 200, {{"would_be_blocked", r.wouldBeBlocked},
+                            {"impacts", impactsJson(project, r)}});
+    });
+
+    svr.Put(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/relations-enforced)",
+            [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req);
+        std::string project = req.matches[1], coll = req.matches[2];
+        requireAdmin(k); requireProjectAccess(k, project);
+        auto body = bodyJson(req);
+        if (!body.contains("enforced") || !body["enforced"].is_boolean())
+            throw ApiError(ErrCode::Unprocessable, "validation", "enforced (boolean) is required");
+        const bool enforced = body["enforced"].get<bool>();
+        if (!d->db.client().setRelationsEnforced(qualify(project, coll), enforced))
+            throw ApiError(ErrCode::Unavailable, "db_error", "failed to set relations_enforced");
+        sendJson(res, 200, {{"collection", coll}, {"enforced", enforced}});
+    });
+```
+
+- [ ] **Step 4: Return 409 from the document delete**
+
+In `src/handlers/documents.cpp`, replace the 2-argument `remove` with the 3-argument overload so the refusal message survives, and pre-check impacts to build the payload:
+
+```cpp
+        const std::string qc = qualify(project, coll);
+        std::string err;
+        if (!d->db.client().remove(qc, id, err)) {
+            // A restrict relation is the one refusal a caller can act on, so it
+            // gets 409 plus the impact list rather than a generic failure.
+            auto why = d->db.client().describeDelete(qc, id);
+            if (why.success && why.wouldBeBlocked)
+                throw ApiError(ErrCode::Conflict, "relation_restricted",
+                               err.empty() ? "delete blocked by a relation" : err,
+                               {{"impacts", impactsJson(project, why)}});
+            throw ApiError(ErrCode::NotFound, "not_found", "document not found");
+        }
+```
+
+Keep whatever success response the handler already sends.
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*Delete*:*Relation*'`
+Expected: PASS, no skips.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/handlers/relations.cpp src/handlers/documents.cpp src/server.hpp tests/test_api_integration.cpp
+git commit -m "feat(relations): delete-impact, enforcement toggle and 409 on blocked delete"
+```
+
+---
+
+### Task 9: Admin read-only lock
+
+**Files:**
+- Modify: `src/handlers/settings.cpp`
+- Test: `tests/test_api_integration.cpp`
+
+**Interfaces:**
+- Produces: `GET /api/v1/admin/readonly` -> `{readonly: bool}`; `PUT /api/v1/admin/readonly {readonly: bool}` -> same. Server-global, not per project.
+
+- [ ] **Step 1: Write the failing test**
+
+```cpp
+TEST_F(ApiFixture, AdminReadonlyLockBlocksWritesThenReleases) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","rolock"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    std::string docs = base + "/rolock/documents";
+
+    auto on = c.Put("/api/v1/admin/readonly", nlohmann::json{{"readonly", true}}.dump(),
+                    "application/json");
+    ASSERT_TRUE(on); EXPECT_EQ(on->status, 200);
+    EXPECT_TRUE(nlohmann::json::parse(c.Get("/api/v1/admin/readonly")->body)["readonly"].get<bool>());
+
+    auto blocked = c.Post(docs.c_str(), nlohmann::json{{"v",1}}.dump(), "application/json");
+    ASSERT_TRUE(blocked); EXPECT_NE(blocked->status, 201);
+
+    auto off = c.Put("/api/v1/admin/readonly", nlohmann::json{{"readonly", false}}.dump(),
+                     "application/json");
+    ASSERT_TRUE(off); EXPECT_EQ(off->status, 200);
+    EXPECT_EQ(c.Post(docs.c_str(), nlohmann::json{{"v",2}}.dump(), "application/json")->status, 201);
+}
+
+TEST_F(ApiFixture, ReadonlyRequiresAdmin) {
+    // The fixture's scoped-key helper is used by the existing scoped-key tests;
+    // reuse the same construction to assert a non-admin key is refused.
+    auto c = admin();
+    auto made = c.Post("/api/v1/keys", nlohmann::json{
+        {"label","ro-nonadmin"}, {"projects", nlohmann::json::array({project_})},
+        {"admin", false}}.dump(), "application/json");
+    ASSERT_EQ(made->status, 201);
+    std::string nonAdmin = nlohmann::json::parse(made->body)["key"];
+    auto r = request("PUT", "/api/v1/admin/readonly",
+                     nlohmann::json{{"readonly", true}}.dump(), "application/json", nonAdmin);
+    ASSERT_TRUE(r); EXPECT_EQ(r->status, 403);
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*Readonly*'`
+Expected: FAIL with 404 — routes not registered.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Add to `registerSettingsRoutes` in `src/handlers/settings.cpp`. The lock is **server-global**, so the response says so explicitly rather than implying project scope:
+
+```cpp
+    svr.Get(R"(/api/v1/admin/readonly)", [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req);
+        requireAdmin(k);
+        auto stats = d->db.client().getStats();
+        sendJson(res, 200, {{"readonly", stats.readOnly}, {"scope", "server"}});
+    });
+
+    svr.Put(R"(/api/v1/admin/readonly)", [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req);
+        requireAdmin(k);
+        auto body = bodyJson(req);
+        if (!body.contains("readonly") || !body["readonly"].is_boolean())
+            throw ApiError(ErrCode::Unprocessable, "validation", "readonly (boolean) is required");
+        const bool ro = body["readonly"].get<bool>();
+        if (!d->db.client().setReadOnly(ro))
+            throw ApiError(ErrCode::Unavailable, "db_error", "failed to set read-only mode");
+        sendJson(res, 200, {{"readonly", ro}, {"scope", "server"}});
+    });
+```
+
+Check what `getStats()` actually exposes for the read-only flag (`client.hpp` around the Stats struct). If it carries no such field, track the last set value in `ServerDeps` instead and document that the GET reports vectorapi's view, not the server's.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*Readonly*'`
+Expected: PASS, no skips.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/handlers/settings.cpp tests/test_api_integration.cpp
+git commit -m "feat(admin): server read-only lock endpoint"
+```
+
+---
+
+### Task 10: Report the loaded client library version
+
+The point of this endpoint is that it reports what the dynamic linker **bound**, not what the binary was compiled against — the distinction that makes a stale `.so` diagnosable.
+
+**Files:**
+- Modify: `src/handlers/stats.cpp`
+- Test: `tests/test_api_integration.cpp`
+
+**Interfaces:**
+- Produces: `GET /api/v1/stats` gains `db_client_version` (e.g. `"2.11.1"`) and `db_client_commit` (e.g. `"320dfca"`).
+
+- [ ] **Step 1: Write the failing test**
+
+```cpp
+TEST_F(ApiFixture, StatsReportsLoadedClientLibraryVersion) {
+    auto r = admin().Get("/api/v1/stats");
+    ASSERT_TRUE(r); ASSERT_EQ(r->status, 200);
+    auto body = nlohmann::json::parse(r->body);
+    ASSERT_TRUE(body.contains("db_client_version"));
+    ASSERT_TRUE(body.contains("db_client_commit"));
+    // Reports the LOADED library, so it must be non-empty and 2.x.
+    EXPECT_FALSE(body["db_client_version"].get<std::string>().empty());
+    EXPECT_EQ(body["db_client_version"].get<std::string>().rfind("2.", 0), 0u);
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*StatsReports*'`
+Expected: FAIL — keys absent.
+
+- [ ] **Step 3: Write minimal implementation**
+
+In `src/handlers/stats.cpp`, inside the `/api/v1/stats` handler, add to the response object:
+
+```cpp
+        // What the dynamic linker actually bound, not what we compiled against:
+        // a stale .so under an unchanged soname is otherwise invisible.
+        out["db_client_version"] = smartbotic::database::clientLibraryVersion();
+        out["db_client_commit"]  = smartbotic::database::clientLibraryCommit();
+```
+
+(Adapt `out` to whatever the handler names its response json.)
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cmake --build build -j"$(nproc)" && ./build/tests/test_api_integration --gtest_filter='*StatsReports*'`
+Expected: PASS, no skips.
+
+- [ ] **Step 5: Full suite**
+
+Run: `ctest --test-dir build --output-on-failure 2>&1 | tail -5`
+Expected: all pass, no skips.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/handlers/stats.cpp tests/test_api_integration.cpp
+git commit -m "feat(stats): report loaded db client library version and commit"
+```
+
+---
+
+### Task 11: API documentation (openapi.json + llms.txt)
+
+CLAUDE.md requires these stay in sync; they are shipped in the server deb and served at `/docs` and `/llms.txt`.
+
+**Files:**
+- Modify: `api/openapi.json`
+- Modify: `api/llms.txt`
+
+**Interfaces:**
+- Consumes: every route added in Tasks 3-10.
+
+- [ ] **Step 1: List the routes that must appear**
+
+```
+POST   /api/v1/projects/{project}/collections/{collection}/indexes
+GET    /api/v1/projects/{project}/collections/{collection}/indexes
+DELETE /api/v1/projects/{project}/collections/{collection}/indexes/{field}
+GET    /api/v1/projects/{project}/collections/{collection}/indexes/{field}/values
+GET    /api/v1/projects/{project}/collections/{collection}/documents/{id}/delete-impact
+PUT    /api/v1/projects/{project}/collections/{collection}/relations-enforced
+POST   /api/v1/projects/{project}/relations
+GET    /api/v1/projects/{project}/relations
+GET    /api/v1/projects/{project}/relations/{name}
+DELETE /api/v1/projects/{project}/relations/{name}
+GET    /api/v1/admin/readonly
+PUT    /api/v1/admin/readonly
+```
+
+Plus: the `ttl_seconds` query parameter on document POST and PATCH, and the two new `/api/v1/stats` response fields.
+
+- [ ] **Step 2: Add each path to `api/openapi.json`**
+
+Follow the existing entries' shape exactly (same `tags`, `security`, and error-response refs). Document, in the descriptions:
+- `/indexes/{field}/values` returns an empty list for an unindexed or array-valued field — this is by design, not an error.
+- `ttl_seconds` on PATCH: **absent** leaves the expiry untouched; **0 clears** it.
+- 409 responses: `duplicate_values` (with `details.duplicate_examples`) and `relation_restricted` (with `details.impacts`).
+- `/admin/readonly` is server-global, not per project.
+
+- [ ] **Step 3: Add a 409 response component**
+
+If the spec file has a shared error-response section, add a `Conflict` entry there and reference it from the index-create and document-delete operations rather than repeating the schema.
+
+- [ ] **Step 4: Update `api/llms.txt`**
+
+Add the same routes in the existing llmstxt.org style, one line each with the one-sentence purpose. Keep the existing ordering convention.
+
+- [ ] **Step 5: Validate the spec parses**
+
+```bash
+python3 -c "import json;d=json.load(open('api/openapi.json'));print(len(d['paths']),'paths')"
+```
+Expected: parses, and the count increased by 10 new path entries (the two `/admin/readonly` verbs share one path, as do the paired GET/POST and GET/DELETE paths).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add api/openapi.json api/llms.txt
+git commit -m "docs(api): document index, facet, ttl, relation and readonly routes"
+```
+
+---
+
+### Task 12: Web UI index management
+
+**Files:**
+- Modify: `webui/src/api/client.ts` (new API methods)
+- Modify: `webui/src/types.ts` (new types)
+- Create: `webui/src/components/IndexPanel.tsx`
+- Modify: `webui/src/pages/Collections.tsx` (mount the panel)
+
+**Interfaces:**
+- Consumes: the index routes from Tasks 3-5.
+- Produces: `api.listIndexes/createIndex/dropIndex/indexValues`; `<IndexPanel project collection />`.
+
+- [ ] **Step 1: Add types**
+
+In `webui/src/types.ts` (no TS `enum`, no `any`):
+
+```ts
+export type IndexDef = {
+  field: string
+  distinct_values: number
+  entries: number
+  unique: boolean
+}
+
+export type IndexValue = { value: unknown; count: number }
+
+export type CreateIndexResult = {
+  field: string
+  unique: boolean
+  rows_indexed: number
+  already_existed: boolean
+}
+```
+
+- [ ] **Step 2: Add API methods**
+
+In `webui/src/api/client.ts`, inside the `api` object, following the existing `P(project)` helper style:
+
+```ts
+  listIndexes: (project: string, coll: string) =>
+    request<{ indexes: IndexDef[] }>(`${P(project)}/collections/${enc(coll)}/indexes`),
+  createIndex: (project: string, coll: string, field: string, unique: boolean) =>
+    request<CreateIndexResult>(`${P(project)}/collections/${enc(coll)}/indexes`,
+      { method: 'POST', body: JSON.stringify({ field, unique }) }),
+  dropIndex: (project: string, coll: string, field: string) =>
+    request<{ dropped: string }>(`${P(project)}/collections/${enc(coll)}/indexes/${enc(field)}`,
+      { method: 'DELETE' }),
+  indexValues: (project: string, coll: string, field: string, limit = 20) =>
+    request<{ values: IndexValue[] }>(
+      `${P(project)}/collections/${enc(coll)}/indexes/${enc(field)}/values?limit=${limit}`),
+```
+
+Add the new types to the existing `import type { ... } from '@/types'` list at the top.
+
+- [ ] **Step 3: Build the panel**
+
+Create `webui/src/components/IndexPanel.tsx`. It must render the 409 `duplicate_examples` as real content, not a bare error — that payload is the actionable part:
+
+```tsx
+import { useState } from 'react'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { api } from '@/api/client'
+import { ApiError } from '@/types'
+
+export function IndexPanel({ project, collection }: { project: string; collection: string }) {
+  const qc = useQueryClient()
+  const [field, setField] = useState('')
+  const [unique, setUnique] = useState(false)
+  const [duplicates, setDuplicates] = useState<string[]>([])
+
+  const key = ['indexes', project, collection]
+  const { data, isLoading } = useQuery({
+    queryKey: key,
+    queryFn: () => api.listIndexes(project, collection),
+  })
+
+  const create = useMutation({
+    mutationFn: () => api.createIndex(project, collection, field, unique),
+    onSuccess: () => { setField(''); setDuplicates([]); qc.invalidateQueries({ queryKey: key }) },
+    onError: (e: unknown) => {
+      const det = e instanceof ApiError ? e.details : undefined
+      const ex = det && typeof det === 'object' && 'duplicate_examples' in det
+        ? (det as { duplicate_examples: string[] }).duplicate_examples
+        : []
+      setDuplicates(ex)
+    },
+  })
+
+  const drop = useMutation({
+    mutationFn: (f: string) => api.dropIndex(project, collection, f),
+    onSuccess: () => qc.invalidateQueries({ queryKey: key }),
+  })
+
+  return (
+    <div className="rounded-lg border border-slate-700 p-4">
+      <h3 className="text-sm font-semibold text-slate-200 mb-3">Indexes</h3>
+
+      {isLoading ? <p className="text-sm text-slate-400">Loading…</p> : (
+        <table className="w-full text-sm mb-4">
+          <thead>
+            <tr className="text-left text-slate-400">
+              <th className="py-1">Field</th><th>Unique</th><th>Distinct</th><th>Entries</th><th />
+            </tr>
+          </thead>
+          <tbody>
+            {(data?.indexes ?? []).map((i) => (
+              <tr key={i.field} className="border-t border-slate-800">
+                <td className="py-1 text-slate-200">{i.field}</td>
+                <td>{i.unique ? 'yes' : 'no'}</td>
+                <td>{i.distinct_values}</td>
+                <td>{i.entries}</td>
+                <td className="text-right">
+                  <button onClick={() => drop.mutate(i.field)}
+                          className="text-red-400 hover:text-red-300">Drop</button>
+                </td>
+              </tr>
+            ))}
+            {(data?.indexes ?? []).length === 0 && (
+              <tr><td colSpan={5} className="py-2 text-slate-500">No indexes declared.</td></tr>
+            )}
+          </tbody>
+        </table>
+      )}
+
+      <div className="flex items-center gap-2">
+        <input value={field} onChange={(e) => setField(e.target.value)} placeholder="field name"
+               className="rounded bg-slate-800 px-2 py-1 text-sm text-slate-200" />
+        <label className="flex items-center gap-1 text-sm text-slate-300">
+          <input type="checkbox" checked={unique} onChange={(e) => setUnique(e.target.checked)} />
+          unique
+        </label>
+        <button disabled={!field || create.isPending} onClick={() => create.mutate()}
+                className="rounded bg-indigo-600 px-3 py-1 text-sm text-white disabled:opacity-50">
+          Create
+        </button>
+      </div>
+
+      {duplicates.length > 0 && (
+        <div className="mt-3 rounded border border-red-800 bg-red-950/40 p-2 text-sm">
+          <p className="text-red-300">
+            Cannot make this field unique — these documents hold duplicate values:
+          </p>
+          <ul className="mt-1 list-disc pl-5 font-mono text-xs text-red-200">
+            {duplicates.map((id) => <li key={id}>{id}</li>)}
+          </ul>
+        </div>
+      )}
+    </div>
+  )
+}
+```
+
+- [ ] **Step 4: Expose `details` on the client-side ApiError**
+
+In `webui/src/types.ts`, extend `ApiError` to carry `details`, and in `webui/src/api/client.ts` pass `body?.error?.details` when constructing it. Without this the duplicate list never reaches the panel.
+
+- [ ] **Step 5: Mount it**
+
+In `webui/src/pages/Collections.tsx`, render `<IndexPanel project={currentProject} collection={selected} />` in the per-collection detail area, following how that page already reads `currentProject` from `useAuthStore`.
+
+- [ ] **Step 6: Build and typecheck**
+
+```bash
+cd webui && npm run build
+```
+Expected: build succeeds with no TS errors. Confirm the emitted `dist/index.html` still references `./assets/...` (relative), preserving the subpath fix.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add webui/src/types.ts webui/src/api/client.ts webui/src/components/IndexPanel.tsx webui/src/pages/Collections.tsx
+git commit -m "feat(webui): index management panel with duplicate reporting"
+```
+
+---
+
+### Task 13: Release 0.2.0
+
+**Files:**
+- Modify: `VERSION`
+- Modify: `packaging/Dockerfile.base` (only if it pins a client-dev version explicitly)
+
+**Interfaces:**
+- Consumes: everything above.
+- Produces: `smartbotic-vectorapi_0.2.0-1_amd64.deb` and `smartbotic-vectorapi-webui_0.2.0-1_all.deb`.
+
+- [ ] **Step 1: Full verification before packaging**
+
+```bash
+cmake -B build -G Ninja -DBUILD_TESTS=ON -DBUILD_WEBUI=ON -S . && cmake --build build -j"$(nproc)"
+ctest --test-dir build --output-on-failure 2>&1 | tail -5
+```
+Expected: all pass. **Check explicitly for "Skipped" — a skip here means the integration tests never ran.**
+
+- [ ] **Step 2: Bump the version**
+
+```bash
+echo "0.2.0" > VERSION
+git add VERSION && git commit -m "build(release): bump version to 0.2.0"
+```
+
+- [ ] **Step 3: Rebuild the Docker base so it links the 2.11.1 client**
+
+A stale base image silently produces a 2.4.0-linked binary — the exact ABI hazard this release exists to resolve.
+
+```bash
+./packaging/build.sh --rebuild-base
+```
+
+- [ ] **Step 4: Verify the built binary links the NEW client**
+
+```bash
+dpkg-deb -x dist/debian13/smartbotic-vectorapi_0.2.0-1_amd64.deb /tmp/v020
+nm -D --undefined-only /tmp/v020/usr/bin/smartbotic-vectorapi | c++filt | grep -c 'smartbotic::database::Client'
+dpkg-deb -I dist/debian13/smartbotic-vectorapi_0.2.0-1_amd64.deb | grep Depends
+```
+Expected: symbol count is greater than the 23 of 0.1.7 (new APIs are now used), and `Depends` names `libsmartbotic-db-client`.
+
+- [ ] **Step 5: Confirm the webui dependency still resolves**
+
+```bash
+dpkg-deb -I dist/debian13/smartbotic-vectorapi-webui_0.2.0-1_all.deb | grep Depends
+```
+Expected: `Depends: smartbotic-vectorapi (>= 0.2.0)` — upstream version without the deb revision.
+
+- [ ] **Step 6: STOP — do not publish**
+
+Publishing is outward-facing and needs explicit confirmation. The live upgrade is a separate, gated operation covered by `2026-08-14-database-2.11.1-upgrade-design.md`: fresh backup, `apt-mark unhold`, install DB 2.11.1 + client + vectorapi 0.2.0 + webui 0.2.0 together, verify, **deliberately restart vectorapi and re-verify**, re-apply holds.
+
+Hand back to the user with the built artifacts and the upgrade checklist.
+
+---
+
+## Self-Review
+
+**Spec coverage:** indexes (T3), unique + 409 (T4), facets (T5), TTL (T6), relations CRUD (T7), delete-impact + enforcement + 409 (T8), read-only lock (T9), client version (T10), 409/details plumbing (T2), docs (T11), webui (T12), release + base rebuild (T13), dev-env DB requirement (T1). No spec section is unimplemented.
+
+**Known verification points** (call these out during execution rather than guessing):
+- The second `listIndexes` overload's out-parameter type (T3).
+- Which `createRelation` overload takes `validateOnWrite` (T7).
+- Whether `getStats()` exposes a read-only flag (T9).
+
+These are the three places where the header must be re-read before writing the call; every other signature in this plan was confirmed against the 2.11.1 headers or exercised directly in the zeus rehearsal.

+ 151 - 0
docs/superpowers/specs/2026-08-14-database-2.11.1-upgrade-design.md

@@ -0,0 +1,151 @@
+# smartbotic-database 2.4.0 -> 2.11.1 upgrade (qdrant001)
+
+Status: rehearsal PASSED (all 5 gates) — live upgrade awaiting approval
+Date: 2026-08-14
+
+## Problem
+
+Production droplet `qdrant001` (142.93.100.6) runs `smartbotic-database` 2.4.0-1
+with live data in two projects (`default` = vectorapi keys/settings, `toloajto` =
+furniture-designer). The apt repo offers 2.11.1-1. The upgrade cannot be
+DB-only: `smartbotic-vectorapi` links `libsmartbotic-db-client`, which is
+versioned in lockstep with the server.
+
+## Why a plain apt upgrade would break production
+
+Both 2.4.0 and 2.11.1 ship SONAME `libsmartbotic-db-client.so.2`. The dynamic
+linker therefore binds the existing vectorapi binary (compiled against 2.4.0
+struct layouts) to the new library without any error. The vendor documents the
+consequence in `client.hpp` above `createIndex`:
+
+> adding a member to a public struct changes its size, and with an unchanged
+> soname an already-installed consumer binds to the new library with the old
+> layout and crashes. That is not hypothetical here - it took the local
+> webserver down in a restart loop once already.
+
+That incident is dated v2.7.1, inside the range being jumped. Since v2.7.1 the
+library holds public struct sizes fixed (new capability arrives as overloads and
+free functions), so this hazard is specific to crossing that boundary.
+
+The break is latent: the running process keeps the old `.so` mapped and keeps
+serving, so health checks stay green. It only manifests on the next restart.
+
+## Evidence gathered (static analysis)
+
+- All 23 client symbols vectorapi imports still exist in 2.11.1, with identical
+  mangled signatures - including the `nlohmann::json_abi_v3_11_3` tag, so the
+  JSON ABI is unchanged. Nothing vectorapi calls was removed.
+- API additions 2.4 -> 2.11 are additive and unused by vectorapi: v2.4.5
+  collection version history + partial `updateCollection`; v2.6.0 project-scoped
+  files; v2.7.1 `FindRequest.projection`; v2.8.0 document/file TTL; v2.9.0
+  secondary indexes; v2.10.0 distinct-value counts; v2.11.0 relations and unique
+  indexes; v2.11.1 cross-project `listRelations` + `clientLibraryVersion()`.
+  Relation enforcement requires declaring a relation, so it is inert here.
+- 2.11.1's `preinst` takes its own cold backup to
+  `/var/backups/smartbotic-database/pre-upgrade-<ts>-from-<oldver>/` (keeps 3)
+  after the old `prerm` stops the service, and aborts if free space < 1.2x data.
+
+Unresolved statically: whether the 2.11.1 binary reads 2.4.0's on-disk
+LMDB/WAL/snapshot format. The package ships no migration hook, so the binary
+handles it at startup. This is what the rehearsal exists to answer.
+
+## Backups (complete, pre-upgrade)
+
+Data is encrypted at rest; `storage.key` lives in the data dir and is included -
+without it a restore is useless.
+
+| Artifact | On box | Local |
+|---|---|---|
+| `smartbotic-database-20260814-hot.tar.gz` (392K) | `/root/db-backups/` | `/data/db-backups-qdrant001/` |
+| DB `config.json` | same | same |
+| Rollback debs: DB 2.4.0-1, client 2.4.0-1, vectorapi 0.1.7-2 | `/root/db-backups/rollback-debs/` | `/data/db-backups-qdrant001/rollback-debs/` |
+
+sha256 of the tarball verified identical on both ends. The rollback debs came
+from the box's own `/var/cache/apt/archives` - the apt repo no longer indexes
+2.4.0, so these files are the only route back.
+
+## Release train (two stages, per user direction)
+
+Stage 1 - **compatibility only**, not published:
+- Rebuild `smartbotic-vectorapi` against the 2.11.1 client. No feature adoption.
+- Fix only what the DB version change breaks (compile or runtime).
+- Version 0.1.8. Deployed by direct `dpkg -i`, never pushed to the apt repo.
+
+Stage 2 - **feature adoption**, published:
+- Adopt the new DB capabilities that earn their place (secondary indexes on hot
+  filter fields; relations where referential integrity helps).
+- This is the only version published to `repository.smartbotics.ai`.
+
+`smartbotic-vectorapi-webui` stays at 0.1.7-3 throughout: its dependency now
+tracks `{{UPSTREAM_VERSION}}`, so a server bump to 0.1.8 keeps it satisfied.
+
+## Rehearsal (zeus, Docker) - gate on live
+
+1. Debian 13 container; install `smartbotic-database` 2.11.1 from the apt repo.
+2. Restore the real 392K backup into `/var/lib/smartbotic-database`, ownership
+   fixed, `storage.key` included.
+3. Start the DB. Does it read the 2.4.0 encrypted LMDB, WAL and snapshots?
+4. Build vectorapi against the 2.11.1 client-dev; confirm it compiles unchanged.
+5. Run it against the restored DB and exercise the API: list projects, read
+   `default` (keys/settings) and `toloajto` (live furniture-designer data),
+   similarity search, insert/update/delete round trip.
+
+Live is touched only after all five pass.
+
+### Rehearsal results (2026-08-14, zeus, all PASSED)
+
+Containers `dbtest` / `apibuild` on zeus (`/data/dbrehearsal`, images
+`db-rehearsal:2.11.1` and `db-rehearsal:build`), stopped but retained.
+
+1. **DB reads 2.4.0 data.** `Loaded and verified encryption key ... (fingerprint
+   96965b4a5cbfcdd0)`, `v2.0 migration: v2.3 layout present — skipping v1
+   check`, `opened 2 project env(s): [default, toloajto]`, `Snapshot
+   deserialized: 16 collections`, `Replayed 0 WAL entries from sequence 62`,
+   `Recovery complete (TrivialSuccess): 42 docs in 16 collections`. No migration
+   step, no format conversion, no warnings beyond pre-existing
+   `storage.memory.*` deprecation notices.
+2. **vectorapi compiles unchanged** against client-dev 2.11.1 — 56/56 targets,
+   zero source edits, zero errors.
+3. **101/101 tests pass** (`ctest`), including the ApiFixture integration suite
+   talking to the 2.11.1 DB holding production data.
+4. **Real data intact.** Document counts in `toloajto` match live 2.4.0 exactly
+   across all 8 collections (catalog_doors 1, catalog_hardware 1,
+   catalog_kitchen_modules 0, catalog_materials 9, designs 5, presets 4,
+   quotes 8, settings 1). Design documents retain their full field structure.
+5. **CRUD round-trip works**: create collection 201, insert 201, read back 1,
+   drop 200.
+
+Conclusion: 2.11.1 is a drop-in for this dataset. The only mandatory change is
+rebuilding vectorapi so it is not left bound to the new `.so` with 2.4.0 layouts.
+
+### Deprecation noted (not blocking)
+
+The live `config.json` sets eight `storage.memory.*` keys the server now warns
+are deprecated ("will be removed in v2.1"; RSS is bounded by LMDB mapsize, not
+MemoryStore eviction). Still honoured for back-compat. Worth cleaning up in a
+follow-up, not during the upgrade window.
+
+## Live upgrade sequence (after rehearsal passes, requires explicit approval)
+
+1. Re-take a fresh hot backup (data will have moved on).
+2. `apt-mark unhold` the three held packages.
+3. Install DB 2.11.1 + client 2.11.1 + vectorapi 0.1.8 in one window; the
+   `preinst` cold backup happens automatically.
+4. Verify: `systemctl is-active`, `/rag/healthz`, `/rag/readyz`, both projects
+   readable through the API, furniture-designer's `toloajto` data intact.
+5. **Restart vectorapi deliberately** and re-verify - the ABI class of failure
+   only appears on restart, so a clean restart is the real acceptance test.
+6. Re-apply holds.
+
+## Rollback
+
+1. `systemctl stop smartbotic-vectorapi smartbotic-database`
+2. `dpkg -i` the three 2.4.0/0.1.7-2 debs from `/root/db-backups/rollback-debs/`
+3. Restore data from `/var/backups/smartbotic-database/pre-upgrade-*/` (or our
+   tarball), `chown -R smartbotic-db:smartbotic-db`
+4. Start both services, re-apply holds.
+
+## Out of scope
+
+- Upgrading Qdrant (separate service on `:6334`, untouched).
+- The `/rag` mount layout, settled earlier today.

+ 230 - 0
docs/superpowers/specs/2026-08-14-vectorapi-0.2.0-db-features-design.md

@@ -0,0 +1,230 @@
+# vectorapi 0.2.0 — adopt smartbotic-database 2.11.1 features
+
+Status: design, awaiting review
+Date: 2026-08-14
+Companion: `2026-08-14-database-2.11.1-upgrade-design.md` (the upgrade itself)
+
+## Why
+
+The live DB upgrade 2.4.0 -> 2.11.1 is gated on vectorapi actually using what
+2.11.1 adds. The rehearsal proved 2.11.1 is a drop-in for our data and that
+vectorapi compiles against it unchanged; this spec covers the feature work that
+must land before the single coordinated upgrade.
+
+Everything here is additive. No existing endpoint changes shape.
+
+## Scope
+
+Full adoption: secondary indexes (incl. unique), facets, document TTL, relations
+and referential integrity, the admin read-only lock, and loaded-client-version
+reporting. Web UI gains index management. Both debs ship as 0.2.0 and this is
+the version published to the apt repo.
+
+## New HTTP surface
+
+All under the existing `/api/v1` prefix and project namespacing.
+
+### Indexes (DB v2.9 / v2.11)
+
+```
+POST   /projects/{p}/collections/{c}/indexes           {field, unique?}
+GET    /projects/{p}/collections/{c}/indexes
+DELETE /projects/{p}/collections/{c}/indexes/{field}
+GET    /projects/{p}/collections/{c}/indexes/{field}/values?limit=100&order=asc|desc
+```
+
+- `POST` maps to `createIndex` (or `createUniqueIndex` when `unique:true`), and
+  returns `{field, unique, rows_indexed, already_existed}`. Creation backfills
+  existing rows, so the index is usable immediately; the call is idempotent.
+- A unique declaration refused because the field already holds duplicates
+  returns **409** with `duplicate_examples` (up to five colliding document ids,
+  one per colliding value) — the client surfaces exactly this and it is the
+  actionable part of the error.
+- `GET` maps to `listIndexes(collection, withUnique)` -> `[{field,
+  distinct_values, entries, unique}]`.
+- `/values` maps to `indexValues(collection, field, limit, ascending)` ->
+  `[{value, count}]`. Documented as **index-dependent**: it returns an empty
+  list for an unindexed field and for array-valued fields, by design.
+  `order=desc&limit=1` yields the field maximum.
+
+### Document TTL (DB v2.8)
+
+```
+POST  /projects/{p}/collections/{c}/documents           ?ttl_seconds=N
+PATCH /projects/{p}/collections/{c}/documents/{id}      ?ttl_seconds=N
+```
+
+- On insert, `ttl_seconds` passes through to `insert(..., ttlSeconds, ...)`.
+- On patch, presence of `ttl_seconds` selects `patchWithTtl`; absence keeps the
+  3-arg `patch()` so the expiry is left untouched. `ttl_seconds=0` CLEARS the
+  expiry (document becomes permanent) — that asymmetry must be documented,
+  since 0 elsewhere means "no TTL" rather than "clear existing TTL".
+
+### Relations / referential integrity (DB v2.11)
+
+```
+POST   /projects/{p}/relations        {name, child, child_field, parent,
+                                       on_delete, validate_on_write?}
+GET    /projects/{p}/relations
+GET    /projects/{p}/relations/{name}
+DELETE /projects/{p}/relations/{name}
+PUT    /projects/{p}/collections/{c}/relations-enforced     {enforced: bool}
+GET    /projects/{p}/collections/{c}/documents/{id}/delete-impact
+```
+
+- `on_delete` ∈ `restrict | cascade | set_null | no_action`, all four enforced
+  as of v2.11.0. Unrecognised values are rejected at the API boundary with 422
+  rather than passed through.
+- `delete-impact` maps to `describeDelete` and is **read-only** — it never
+  deletes. Returns whether the delete would be blocked and, per relation, the
+  child collection/field, `on_delete`, child count, and up to five sample child
+  ids.
+- A delete blocked by a `restrict` relation returns **409** carrying the same
+  impact payload, so the client can explain the refusal without a second call.
+- Collection names crossing into the client are project-qualified through the
+  existing `qualify(project, coll)` helper.
+
+### Admin read-only lock
+
+```
+GET /admin/readonly
+PUT /admin/readonly     {readonly: bool}
+```
+
+Maps to `setReadOnly` / `lock` / `unlock`. Server-global, not per project —
+the response documents that explicitly to avoid a false sense of scoping.
+Intended for backup and upgrade windows.
+
+### Diagnostics
+
+`GET /api/v1/stats` gains `db_client_version` and `db_client_commit` from
+`clientLibraryVersion()` / `clientLibraryCommit()`. These report what the
+dynamic linker actually **bound**, not what the binary was compiled against —
+the distinction that made today's ABI investigation slow. Cheap and worth it.
+
+## Cross-cutting changes
+
+### `ErrCode::Conflict` (new)
+
+`src/errors.hpp` currently has no 409. Add `Conflict` to the enum and map it in
+`httpStatus`. Used by exactly two paths: unique-index violation and
+relation-restricted delete. Both carry a structured payload beyond the message,
+so `errorBody` gains an optional `details` object rather than stuffing data into
+the message string.
+
+### Authorization
+
+| Route | Guard | Reasoning |
+|---|---|---|
+| index create/drop | `requireProjectManage` | schema change; must deny publishable scoped keys |
+| index list | `requireProjectAccess` | metadata only |
+| index `/values` | `requireCapability(..., Read)` | returns real stored values, so it follows the collection's read grant — this lets a scoped front-end key drive filter dropdowns |
+| relations (all) | `requireAdmin` + project access | the client documents relation reads as admin-only |
+| relations-enforced | `requireAdmin` | changes write-time enforcement |
+| delete-impact | `requireCapability(..., Read)` | reads document + relation state |
+| admin/readonly | `requireAdmin` | server-global |
+| TTL params | same guard as the underlying insert/patch | no new authority |
+
+### Handlers
+
+New `src/handlers/indexes.cpp` and `src/handlers/relations.cpp`, each with a
+`registerXRoutes(ApiServer&)` following the existing pattern. The read-only lock
+and the stats additions go into the existing `handlers/settings.cpp` and
+`handlers/stats.cpp` rather than earning their own files. Document TTL is a
+parameter change inside `handlers/documents.cpp`.
+
+The collection registry is NOT extended: indexes and relations are read live
+from the DB via `listIndexes` / `listRelations`, so there is no second source of
+truth to drift.
+
+## Web UI (0.2.0)
+
+Collections page gains an **Indexes** panel per collection:
+
+- table of declared indexes: field, unique, distinct values, entries
+- create form: field name + `unique` toggle; on 409, render the returned
+  colliding document ids rather than a bare error
+- drop, with confirmation
+- facet preview: pick an indexed field, show top values with counts
+
+Relations and the read-only lock are API-only for 0.2.0 — the UI work for them
+is not justified until something declares a relation. (Called out so the gap is
+deliberate rather than forgotten.)
+
+## Testing
+
+- Unit: index/relation request validation, `on_delete` rejection, TTL parameter
+  parsing (including `0` = clear), 409 body shape.
+- Integration (`ApiFixture`, needs a live DB): index create/list/drop round
+  trip; unique violation returns 409 + duplicate ids; facet values; TTL insert
+  and patch-clear; relation create + restricted delete returns 409; delete-impact
+  matches; read-only lock rejects writes then releases.
+- The integration suite requires **DB 2.11.1** on the dev machine. It already
+  `GTEST_SKIP()`s when the DB is unreachable, so the fallback is a silent skip —
+  during this work that is a hazard, not a convenience. CI/dev must run 2.11.1
+  and the run must be checked for skips, not just for failures.
+
+## Docs
+
+`api/openapi.json` and `api/llms.txt` must gain every route above — CLAUDE.md
+requires these stay in sync, and `/docs` is served from them.
+
+## Build and release
+
+- `packaging/Dockerfile.base` pins `libsmartbotic-db-client-dev`; it must be
+  rebuilt (`./packaging/build.sh --rebuild-base ...`) to pick up 2.11.1.
+  A stale base silently produces a 2.4.0-linked binary.
+- Version 0.2.0 for both `smartbotic-vectorapi` and
+  `smartbotic-vectorapi-webui`. The webui dependency now tracks
+  `{{UPSTREAM_VERSION}}`, so both move together cleanly.
+- This IS the published version: `./packaging/build.sh --repo --suite trixie
+  --sync` (outward-facing — only on explicit confirmation).
+
+## Relation namespacing — RESOLVED empirically (zeus rehearsal, 2026-08-14)
+
+The header does not state it, so it was probed directly against the 2.11.1
+client library with production data restored:
+
+- Relations are **per-project**: a relation created while the client is pinned
+  to `default` is invisible to a `toloajto` client (`listRelations()` -> 1 vs 0,
+  `getRelationInfo` not visible), and the SAME name can be reused in another
+  project without collision.
+- The relation **name itself is project-qualified**, exactly like collection
+  names. Passing qualified child/parent collections with a bare name is
+  rejected outright, and the server names the fix:
+  `the relation name must be in the same project as its child and parent:
+  name 'default:qual_rel' is in 'default' but child and parent are in
+  'toloajto' (use 'toloajto:qual_rel')`.
+- With `createRelation("toloajto:qual_rel", "toloajto:rp_child", "parent_id",
+  "toloajto:rp_parent", "restrict")` everything works from a client pinned to
+  `default`: listRelations, getRelationInfo, describeDelete
+  (`wouldBeBlocked=true`, 1 impact), and a real delete is refused.
+
+**Consequence: no architectural change.** vectorapi keeps its single Client
+pinned to `default` (`db_gateway.cpp:15`) and routes relation names through the
+existing `qualify(project, name)` helper, the same as collections. Per-project
+Client instances are NOT needed.
+
+### Implementation notes that follow from the probe
+
+- `describeDelete`'s field is `wouldBeBlocked` (not `blocked`), alongside
+  `success`, `error`, and `impacts`. Its own docs confirm it is gated as an
+  ordinary per-collection READ, which matches the authorization table above.
+- Use the **3-argument** `remove(collection, id, errorOut)` overload for
+  document deletes. vectorapi currently links the 2-argument form, which
+  discards the message; the 3-arg version yields the text that makes a 409
+  actionable, e.g. "relation 'toloajto:qual_rel' has 1 child document(s) in
+  'toloajto:rp_child' referencing it (e.g. 019fff...)".
+- `getRelationsEnforced` on a collection with no explicit config returns true,
+  so the API must not treat "true" as evidence that a relation exists.
+
+## Risks
+
+- Relation enforcement is on by default once a relation is declared; declaring
+  one on live data with violations could start rejecting writes. Mitigate by
+  declaring nothing automatically — relations are only created on explicit API
+  calls.
+- Index backfill is O(rows) at creation; our largest collection is 9 documents,
+  so this is theoretical here.
+- The new surface roughly doubles the API. Docs and tests are the mitigations,
+  both listed above as required rather than optional.