# Project-Scoped Files (v2.6.0) 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:** Give file storage a `project` dimension so files are namespaced like collections, which is the prerequisite for per-project file access policies. **Architecture:** `FileManager::FileInfo` gains a `project` field persisted in the `_files` system collection. Every file RPC carries `project`, and the client fills it from `Config::project` automatically. Lookups by id verify the file's project matches the request, so an id from another namespace is `NOT_FOUND` rather than readable. Blob dedup stays global (disk saving) but the `deduplicated` flag is computed per-project so it cannot act as a cross-project existence oracle. Existing files migrate to `default` on first boot, idempotently. **Tech Stack:** C++20, gRPC/protobuf 3.21 (proto3 field presence available), nlohmann/json, LMDB, spdlog. ## Global Constraints - Reserved/empty project resolves to `default` — same rule as `parseProjectCollection` in `service/src/project_addressing.hpp`. - Project names validated by the existing `isValidProjectName()`; no new validator. - Wire and on-disk JSON stay JSON; no binary format change. - Never use em-dashes or en-dashes in any output, including code comments and commit messages. ASCII hyphens only. - `SOVERSION` stays `2`. Client API changes are source-breaking, so consumers rebuild; note it in `CLAUDE.md`. - Existing `_files` documents have no `project` key. Absent means `default` on read, never "unknown". --- ### Task 1: `project` on FileInfo, persisted and defaulted **Files:** - Modify: `service/src/files/file_manager.hpp:28-41` (add field to `FileInfo`) - Modify: `service/src/files/file_manager.cpp` (serialise/deserialise `project`, default on read) - Test: `tests/test_file_project_scope.cpp` (create) - Modify: `tests/CMakeLists.txt` (register target) **Interfaces:** - Consumes: nothing. - Produces: `FileManager::FileInfo::project` (`std::string`, defaults to `"default"`); `FileManager::listFiles(...)` gains a leading `const std::string& project` parameter; `FileManager::getFileInfo(id)` unchanged, `FileManager::getFileInfoIn(project, id)` added returning `std::nullopt` on project mismatch. - [ ] **Step 1: Write the failing test** ```cpp void test_fileinfo_persists_project() { TmpFm fm; // fixture boots a FileManager over a temp dir FileManager::FileInfo in; in.name = "a.bin"; in.fileType = "document"; in.project = "acme"; auto res = fm->storeFile({1,2,3}, in); auto got = fm->getFileInfo(res.id); check(got.has_value(), "file readable by id"); check(got->project == "acme", "project round-trips through _files"); } void test_absent_project_reads_as_default() { // A pre-2.6.0 record: metadata document with no "project" key at all. TmpFm fm; fm.writeLegacyRecord("legacy-1", R"({"name":"old.bin","fileType":"document"})"); auto got = fm->getFileInfo("legacy-1"); check(got.has_value(), "legacy record still readable"); check(got->project == "default", "absent project means default, not empty"); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cmake --build build -j$(nproc) --target test_file_project_scope && ./build/tests/test_file_project_scope` Expected: FAIL — `FileInfo` has no member `project`. - [ ] **Step 3: Write minimal implementation** Add to `FileInfo`: ```cpp // v2.6.0 — owning project. Files are namespaced like collections so that // per-project access policy has a referent. Records written before 2.6.0 // have no project key; absent means "default", never unknown. std::string project = "default"; ``` In the `_files` serialiser add `j["project"] = info.project;`, and in the deserialiser `info.project = j.value("project", std::string("default"));`. - [ ] **Step 4: Run test to verify it passes** Run: `./build/tests/test_file_project_scope` Expected: PASS - [ ] **Step 5: Commit** ```bash git add service/src/files/file_manager.hpp service/src/files/file_manager.cpp tests/test_file_project_scope.cpp tests/CMakeLists.txt git commit -m "feat(files): persist an owning project on file metadata" ``` --- ### Task 2: project-scoped lookup, listing, and delete **Files:** - Modify: `service/src/files/file_manager.hpp` (signatures) - Modify: `service/src/files/file_manager.cpp` (filter by project) - Test: `tests/test_file_project_scope.cpp` (extend) **Interfaces:** - Consumes: `FileInfo::project` from Task 1. - Produces: `getFileInfoIn(project, id) -> std::optional`; `readFileIn(project, id) -> std::vector` (throws on mismatch); `deleteFileIn(project, id) -> bool`; `listFiles(project, fileType, relatedId, limit, offset, checksum, name)`. - [ ] **Step 1: Write the failing test** ```cpp void test_cross_project_id_is_not_readable() { TmpFm fm; FileInfo a; a.name="a"; a.fileType="document"; a.project="acme"; auto id = fm->storeFile({9,9,9}, a).id; check(fm->getFileInfoIn("acme", id).has_value(), "owner project reads it"); check(!fm->getFileInfoIn("other", id).has_value(), "an id from another project must not resolve"); bool threw = false; try { fm->readFileIn("other", id); } catch (const std::exception&) { threw = true; } check(threw, "reading bytes across projects must throw, not return data"); check(!fm->deleteFileIn("other", id), "delete across projects must not delete"); check(fm->getFileInfoIn("acme", id).has_value(), "and the file survives that attempt"); } void test_list_is_project_filtered() { TmpFm fm; FileInfo a; a.name="a"; a.fileType="document"; a.project="acme"; fm->storeFile({1}, a); FileInfo b; b.name="b"; b.fileType="document"; b.project="other"; fm->storeFile({2}, b); check(fm->listFiles("acme").totalCount == 1, "list shows only the caller's project"); check(fm->listFiles("acme").files[0].name == "a", "and the right file"); check(fm->listFiles("other").totalCount == 1, "peer project sees only its own"); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cmake --build build -j$(nproc) --target test_file_project_scope && ./build/tests/test_file_project_scope` Expected: FAIL — `getFileInfoIn` not declared. - [ ] **Step 3: Write minimal implementation** Add the three `*In` overloads, each resolving via the existing id path then comparing `info.project` to the requested project and refusing on mismatch. `listFiles` gains `project` as its first parameter and filters on it before the other predicates. Keep the old `getFileInfo(id)` as an operator-facing, project-blind accessor and comment it as such. - [ ] **Step 4: Run test to verify it passes** Run: `./build/tests/test_file_project_scope` Expected: PASS - [ ] **Step 5: Commit** ```bash git add service/src/files/file_manager.hpp service/src/files/file_manager.cpp tests/test_file_project_scope.cpp git commit -m "feat(files): scope lookup, list and delete by project" ``` --- ### Task 3: per-project `deduplicated` flag **Files:** - Modify: `service/src/files/file_manager.cpp` (`storeFile`) - Test: `tests/test_file_project_scope.cpp` (extend) **Interfaces:** - Consumes: `listFiles(project, ...)` from Task 2. - Produces: `StoreResult::deduplicated` now means "this project already referenced this checksum". - [ ] **Step 1: Write the failing test** ```cpp void test_dedup_flag_does_not_leak_across_projects() { TmpFm fm; std::vector bytes{7,7,7,7}; FileInfo a; a.name="a"; a.fileType="document"; a.project="acme"; auto r1 = fm->storeFile(bytes, a); check(!r1.deduplicated, "first upload in acme is not a dedup hit"); FileInfo b; b.name="b"; b.fileType="document"; b.project="other"; auto r2 = fm->storeFile(bytes, b); check(!r2.deduplicated, "same bytes from another project must NOT report deduplicated - " "that would be a cross-project existence oracle"); FileInfo c; c.name="c"; c.fileType="document"; c.project="acme"; auto r3 = fm->storeFile(bytes, c); check(r3.deduplicated, "a repeat within the SAME project does report dedup"); // Blob is still shared on disk - the saving is retained. check(fm->getRefCount(r1.checksum) == 3, "one blob, three references"); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `./build/tests/test_file_project_scope` Expected: FAIL — `r2.deduplicated` is true (global dedup reported verbatim). - [ ] **Step 3: Write minimal implementation** Keep the global blob write/refcount exactly as-is. Compute the returned flag from a project-scoped metadata lookup instead of the blob's prior existence: ```cpp // v2.6.0 — dedup stays global so the disk saving is kept, but the flag we // report is per-project. Reporting the global hit would tell project A that // project B already holds these exact bytes. const bool seen_in_project = listFiles(info.project, "", "", 1, 0, checksum).totalCount > 0; result.deduplicated = seen_in_project; ``` - [ ] **Step 4: Run test to verify it passes** Run: `./build/tests/test_file_project_scope` Expected: PASS - [ ] **Step 5: Commit** ```bash git add service/src/files/file_manager.cpp tests/test_file_project_scope.cpp git commit -m "fix(files): compute deduplicated per project, not globally" ``` --- ### Task 4: `project` on the wire **Files:** - Modify: `proto/database.proto:545-606` (`FileMetadata`, `DownloadFileRequest`, `DeleteFileRequest`, `GetFileInfoRequest`, `FileInfo`, `ListFilesRequest`) - Modify: `service/src/database_grpc_impl.cpp` (5 file handlers) **Interfaces:** - Consumes: the `*In` overloads from Task 2. - Produces: `project` field on all six messages; handlers resolve empty to `default`. - [ ] **Step 1: Add the fields** ```protobuf // v2.6.0 — owning project. Empty means "default", matching bare collection // names. Files are namespaced so per-project access policy has a referent. string project = 9; // FileMetadata string project = 2; // DownloadFileRequest, DeleteFileRequest, GetFileInfoRequest string project = 12; // FileInfo string project = 7; // ListFilesRequest ``` - [ ] **Step 2: Route the handlers through the project-scoped calls** `UploadFile` copies `metadata.project()` (or `default`) into `FileInfo::project`. `DownloadFile` / `DeleteFile` / `GetFileInfo` call the `*In` overloads and map a `nullopt`/false result to `NOT_FOUND`. `ListFiles` passes the project through. `FileInfo` responses set `project`. - [ ] **Step 3: Build** Run: `cmake --build build -j$(nproc)` Expected: no errors. - [ ] **Step 4: Commit** ```bash git add proto/database.proto service/src/database_grpc_impl.cpp git commit -m "feat(files): carry project on every file RPC" ``` --- ### Task 5: client fills project automatically **Files:** - Modify: `client/src/client.cpp` (`uploadFile`, `downloadFile`, `deleteFile`, `getFileInfo`, `listFiles`) - Modify: `client/include/smartbotic/database/client.hpp` (`FileUploadMeta`, file info struct gains `project`) - Test: `tests/load_test/test_client_namespacing.cpp` (extend), `tests/load_test/test_client_namespacing.sh` **Interfaces:** - Consumes: proto `project` fields from Task 4. - Produces: file calls default to `Config::project`; a caller may still pass an explicit project to address another namespace. - [ ] **Step 1: Write the failing test** (append to `test_client_namespacing.cpp`) ```cpp // Files must be namespaced like collections. Client a({.address="127.0.0.1:9011", .project="proj1"}); Client b({.address="127.0.0.1:9011", .project="proj2"}); a.connect(); b.connect(); Client::FileUploadMeta m; m.name="secret.bin"; m.fileType="document"; auto up = a.uploadFile({1,2,3}, m); ck(!up.id.empty(), "upload succeeded"); ck(a.getFileInfo(up.id).has_value(), "owner project reads its own file"); ck(!b.getFileInfo(up.id).has_value(), "peer project cannot resolve the id"); ck(b.listFiles("document").files.empty(), "peer project lists nothing"); ck(a.listFiles("document").files.size() == 1, "owner lists its own file"); ck(!b.deleteFile(up.id), "peer project cannot delete it"); ck(a.getFileInfo(up.id).has_value(), "file survives the cross-project delete"); ``` - [ ] **Step 2: Run to verify it fails** Run: `bash tests/load_test/test_client_namespacing.sh` Expected: FAIL — peer project can currently read and delete the file. - [ ] **Step 3: Set `project` on every outgoing file request** Mirror the `qualify()` convention: files use a separate `project` field rather than a `project:name` string, so set `request.set_project(config_.project)` in each of the five methods and read `project` back into the client-side struct. - [ ] **Step 4: Run to verify it passes** Run: `bash tests/load_test/test_client_namespacing.sh` Expected: PASS, assertion count rises from 17 to 24. - [ ] **Step 5: Commit** ```bash git add client proto tests/load_test/test_client_namespacing.cpp git commit -m "feat(files): client fills project on file RPCs" ``` --- ### Task 6: migrate existing files to `default` **Files:** - Modify: `service/src/database_service.cpp` (call after recovery, beside `auditSubdbPlacement()`) - Modify: `service/src/database_service.hpp` (declare `migrateFilesToDefaultProject()`) - Test: `tests/test_file_project_scope.cpp` (extend) **Interfaces:** - Consumes: `FileInfo::project` default from Task 1. - Produces: `void DatabaseService::migrateFilesToDefaultProject()` — idempotent, logs a count, never fails startup. - [ ] **Step 1: Write the failing test** ```cpp void test_migration_stamps_default_and_is_idempotent() { TmpFm fm; fm.writeLegacyRecord("l1", R"({"name":"a","fileType":"document"})"); fm.writeLegacyRecord("l2", R"({"name":"b","fileType":"plugin"})"); check(fm.stampProjects() == 2, "first pass stamps both legacy records"); check(fm.rawRecord("l1").contains("project"), "project key now present on disk"); check(fm.stampProjects() == 0, "second pass is a no-op - idempotent"); check(fm->getFileInfoIn("default", "l1").has_value(), "readable under default"); } ``` - [ ] **Step 2: Run to verify it fails** Run: `./build/tests/test_file_project_scope` Expected: FAIL — no stamping routine exists. - [ ] **Step 3: Implement the migration** Walk `_files`, and for any document with no `project` key write `default` and save. Return the number stamped. Call it from `initialize()` after recovery. Log at INFO with the count, or DEBUG when zero. Advisory: catch and warn, never throw — a file-metadata stamp is not a reason to refuse service. - [ ] **Step 4: Run to verify it passes** Run: `./build/tests/test_file_project_scope` Expected: PASS - [ ] **Step 5: Commit** ```bash git add service/src/database_service.cpp service/src/database_service.hpp tests/test_file_project_scope.cpp git commit -m "feat(files): stamp legacy file records with the default project" ``` --- ### Task 7: release v2.6.0 **Files:** - Modify: `VERSION`, `CLAUDE.md`, `docs/integration-guide.md`, `README.md` - [ ] **Step 1: Full verification** ```bash cmake --build build -j$(nproc) ctest --test-dir build/tests --output-on-failure bash tests/load_test/test_client_namespacing.sh bash tests/load_test/test_views_multiproject.sh bash tests/load_test/test_v24_tls_auth.sh ``` Expected: ctest all pass, namespacing 24/24, views ALL CHECKS PASSED, TLS 4/4. - [ ] **Step 2: Bump and document** `VERSION` to `2.6.0`. Add a `CLAUDE.md` entry covering: the breaking client change, the per-project `deduplicated` semantics and why, the legacy stamping migration, and that cross-project ids now return `NOT_FOUND`. Document the `project` field on files in the integration guide's file-storage section. - [ ] **Step 3: Commit, tag, push** ```bash git add -A git commit -m "release(v2.6.0): project-scoped file storage" git tag -a v2.6.0 -m "v2.6.0: project-scoped file storage" git push origin main && git push origin v2.6.0 ``` - [ ] **Step 4: Build and install locally** ```bash ./packaging/build.sh --local sudo dpkg -i dist/local/libsmartbotic-db-client_2.6.0-1_amd64.deb \ dist/local/libsmartbotic-db-client-dev_2.6.0-1_amd64.deb \ dist/local/smartbotic-database_2.6.0-1_amd64.deb \ dist/local/smartbotic-db-cli_2.6.0-1_amd64.deb ``` Install all four together: downgrading or upgrading the server alone fails its dependency on the client lib and leaves the service dead. - [ ] **Step 5: Verify the upgrade on the live instance** Check `systemctl is-active`, `--version` reports 2.6.0, zero `[error]` lines since restart, the file stamping count appears in the journal, and existing documents still read. Do NOT publish to the apt repo without asking.