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.
default — same rule as parseProjectCollection in service/src/project_addressing.hpp.isValidProjectName(); no new validator.SOVERSION stays 2. Client API changes are source-breaking, so consumers rebuild; note it in CLAUDE.md._files documents have no project key. Absent means default on read, never "unknown".project on FileInfo, persisted and defaultedFiles:
service/src/files/file_manager.hpp:28-41 (add field to FileInfo)service/src/files/file_manager.cpp (serialise/deserialise project, default on read)tests/test_file_project_scope.cpp (create)tests/CMakeLists.txt (register target)Interfaces:
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
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.
Add to FileInfo:
// 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"));.
Run: ./build/tests/test_file_project_scope
Expected: PASS
[ ] Step 5: Commit
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"
Files:
service/src/files/file_manager.hpp (signatures)service/src/files/file_manager.cpp (filter by project)tests/test_file_project_scope.cpp (extend)Interfaces:
FileInfo::project from Task 1.Produces: getFileInfoIn(project, id) -> std::optional<FileInfo>; readFileIn(project, id) -> std::vector<uint8_t> (throws on mismatch); deleteFileIn(project, id) -> bool; listFiles(project, fileType, relatedId, limit, offset, checksum, name).
[ ] Step 1: Write the failing test
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.
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.
Run: ./build/tests/test_file_project_scope
Expected: PASS
[ ] Step 5: Commit
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"
deduplicated flagFiles:
service/src/files/file_manager.cpp (storeFile)tests/test_file_project_scope.cpp (extend)Interfaces:
listFiles(project, ...) from Task 2.Produces: StoreResult::deduplicated now means "this project already referenced this checksum".
[ ] Step 1: Write the failing test
void test_dedup_flag_does_not_leak_across_projects() {
TmpFm fm;
std::vector<uint8_t> 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).
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:
// 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;
Run: ./build/tests/test_file_project_scope
Expected: PASS
[ ] Step 5: Commit
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"
project on the wireFiles:
proto/database.proto:545-606 (FileMetadata, DownloadFileRequest, DeleteFileRequest, GetFileInfoRequest, FileInfo, ListFilesRequest)service/src/database_grpc_impl.cpp (5 file handlers)Interfaces:
*In overloads from Task 2.Produces: project field on all six messages; handlers resolve empty to default.
[ ] Step 1: Add the fields
// 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.
Run: cmake --build build -j$(nproc)
Expected: no errors.
[ ] Step 4: Commit
git add proto/database.proto service/src/database_grpc_impl.cpp
git commit -m "feat(files): carry project on every file RPC"
Files:
client/src/client.cpp (uploadFile, downloadFile, deleteFile, getFileInfo, listFiles)client/include/smartbotic/database/client.hpp (FileUploadMeta, file info struct gains project)tests/load_test/test_client_namespacing.cpp (extend), tests/load_test/test_client_namespacing.shInterfaces:
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)
// 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.
project on every outgoing file requestMirror 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.
Run: bash tests/load_test/test_client_namespacing.sh
Expected: PASS, assertion count rises from 17 to 24.
[ ] Step 5: Commit
git add client proto tests/load_test/test_client_namespacing.cpp
git commit -m "feat(files): client fills project on file RPCs"
defaultFiles:
service/src/database_service.cpp (call after recovery, beside auditSubdbPlacement())service/src/database_service.hpp (declare migrateFilesToDefaultProject())tests/test_file_project_scope.cpp (extend)Interfaces:
FileInfo::project default from Task 1.Produces: void DatabaseService::migrateFilesToDefaultProject() — idempotent, logs a count, never fails startup.
[ ] Step 1: Write the failing test
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.
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.
Run: ./build/tests/test_file_project_scope
Expected: PASS
[ ] Step 5: Commit
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"
Files:
Modify: VERSION, CLAUDE.md, docs/integration-guide.md, README.md
[ ] Step 1: Full verification
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.
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
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
./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.
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.