Pārlūkot izejas kodu

docs(spec): RLS/CLS design + project-scoped files plan

Records the five approved decisions so they survive across sessions: principal
= named API key, enforcement covers reads/writes/history/files, no-principal
callers get a grantable 'anonymous', security is per-project and off by default
with deny-by-default once enabled, and files become project-scoped first.

Also records decisions the implementer makes rather than asking: masked fields
cannot be filtered on (otherwise the mask is an inference channel), a write
touching a masked field is denied rather than silently dropped, and
SimilaritySearch applies the row predicate before scoring (filtering after
scoring leaks membership through score distribution).

Lockout is prevented structurally: enabling security is refused unless at least
one policy in that project has admin. Plus an audit mode that logs what it
would deny without denying, so a live project can be switched on safely.

Staging set by the operator: project-scoped files ship and install first, then
the policy system.
fszontagh 1 mēnesi atpakaļ
vecāks
revīzija
a7116fedd2

+ 424 - 0
docs/superpowers/plans/2026-08-08-project-scoped-files-v2.6.0.md

@@ -0,0 +1,424 @@
+# 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<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**
+
+```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<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).
+
+- [ ] **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.

+ 152 - 0
docs/superpowers/specs/2026-08-08-rls-cls-design.md

@@ -0,0 +1,152 @@
+# Row- and Column-Level Security — Design
+
+**Status:** approved, not yet implemented
+**Date:** 2026-08-08
+**Supersedes:** the claim in `docs/integration-guide.md` that view filters are a
+security boundary (corrected 2026-08-08, commit `d8e6a2f`-adjacent).
+
+## Why
+
+The database currently has **no access control beyond authentication**. Auth
+(2.4+) compares a bearer token against a flat list of shared keys per listener
+and attaches **no identity** to the call, so the server cannot know who is
+asking. Consequences established by inspection and live experiment:
+
+- Every key holder has identical, complete access.
+- Views are not a boundary: they only reject *writes* on a view name, and the
+  underlying collection stays readable.
+- Projects are not a boundary: `Config::project` is client-chosen and a
+  fully-qualified `other_project:collection` name is accepted from any client.
+  Demonstrated in practice — a `default`-project CLI reading
+  `smartbotic-automation:image_hashes`.
+- Field-level encryption is encryption *at rest*; `decryptSensitiveFields()`
+  runs for every authenticated reader.
+
+## Approved decisions
+
+These were decided by the operator on 2026-08-08. Do not re-litigate them
+without checking back.
+
+| # | Decision | Rationale |
+|---|----------|-----------|
+| 1 | **Principal = named API key.** `auth.keys[]` entries may be `{"name": "...", "key": "..."}`. Bare strings remain valid and map to the reserved principal `unnamed`. | Smallest change that yields an identity; no PKI or token issuer to operate. mTLS/JWT remain possible later since the principal is resolved behind one interface. |
+| 2 | **Enforcement covers reads, writes, version history, and files.** | Read-only enforcement is a half-boundary: a principal that cannot read a row but can overwrite it is still an exposure. Version history returns old document bodies, so omitting it would bypass column masks trivially. |
+| 3 | **Callers with no principal get the reserved principal `anonymous`,** grantable like any other. | Keeps `smartbotic-db-cli` and local operator tooling working by *explicit grant* rather than by accident, and makes local access visible in the policy itself. |
+| 4 | **Security is per-project and OFF by default. Once enabled for a project, access within it is deny-by-default.** | Not all consumers want this. Deny-by-default is what makes an enabled project an actual boundary. Mitigated by audit mode (below). |
+| 5 | **Files become project-scoped first, then policied per project + fileType.** | Files have no project dimension today, so "per-project file policy" has no referent. This is the only option that satisfies decision 4 for files. |
+
+## Model
+
+### Principal resolution
+
+Resolved once per RPC, before the handler runs:
+
+1. Listener has auth and the token matches a **named** key → that name.
+2. Listener has auth and the token matches a **bare** key → `unnamed`.
+3. Listener has no auth → `anonymous`.
+
+Reserved names, rejected as key names in config: `anonymous`, `unnamed`.
+
+Propagated via `grpc::AuthContext::AddProperty("sbdb_principal", name)` plus
+`SetPeerIdentityPropertyName("sbdb_principal")`. The `AuthContext*` argument to
+`BearerAuthProcessor::Process` is currently ignored — this is the hook.
+
+For listeners with no auth processor there is no `Process` call at all, so the
+handler-side resolver must treat "no `sbdb_principal` property" as `anonymous`.
+
+### Policy record
+
+One document per (principal, project) in the project's `_policies` system
+collection, keyed `<principal>`. Mirrors how `_views` works, so it is WAL'd and
+snapshotted with no new persistence machinery.
+
+```json
+{
+  "_id": "shadowman",
+  "admin": false,
+  "collections": {
+    "users":    { "read": true, "write": false, "mask": ["ssn", "profile.dob"], "row": [ { "field": "tenant", "op": "EQ", "value": "acme" } ] },
+    "sessions": { "read": true, "write": true }
+  },
+  "files": {
+    "plugin":    { "read": true, "write": false },
+    "generated": { "read": true, "write": true }
+  }
+}
+```
+
+- `collections` / `files` keys support the exact name or `"*"` as a fallback.
+  Exact match wins over `"*"`.
+- `mask` uses the same dot-notation paths as view `exclude`, and reuses
+  `applyProjection()`.
+- `row` uses the same `Filter` shape as view `where`, AND-merged with the
+  caller's filters exactly as views already do.
+- Absent collection entry, with no `"*"` fallback → denied (decision 4).
+
+### Per-project enable + audit mode
+
+Reserved document `__security__` in the same `_policies` collection:
+
+```json
+{ "_id": "__security__", "enabled": true, "mode": "enforce" }
+```
+
+`mode` is `"enforce"` or `"audit"`. **Audit mode evaluates every policy and logs
+what it *would* deny, then allows the request.** This exists so a live project
+can be switched on safely: enable in `audit`, watch the log until it is quiet,
+then flip to `enforce`. Without it, decision 4 would lock out consumers the
+moment security is enabled.
+
+### Lockout prevention
+
+`enabled: true` is **refused** unless at least one policy in that project has
+`admin: true`. This makes lockout structurally impossible rather than a matter
+of operator care. `admin: true` grants read/write on everything in that project
+plus the right to modify `_policies`.
+
+Writes to `_policies` are themselves policy-checked: only an `admin` principal
+in that project, or any principal when the project has security disabled.
+
+### Semantics decided by the implementer (not open questions)
+
+- **Masked fields cannot be filtered on.** A filter naming a masked path is
+  rejected with `INVALID_ARGUMENT`. Allowing it turns the mask into an inference
+  channel: `salary > 100000` leaks a value the caller may not read.
+- **A write touching a masked field is denied,** not silently dropped. Silent
+  field loss is worse than an error.
+- **`Count` respects the row predicate,** because it is served from the same
+  `scan()` path as `Find` (2.4.4+).
+- **`SimilaritySearch` applies the row predicate before scoring,** not after.
+  Filtering after scoring leaks membership through score distribution.
+- **System collections** (`_`-prefixed) are never reachable by a non-`admin`
+  principal in a security-enabled project.
+
+## Staging
+
+Three independently shippable releases. Each is testable on its own; the third
+depends on the first two.
+
+Three independently shippable releases, in this order (set by the operator
+2026-08-08: ship and install project-scoped files first, then the policy
+system).
+
+| Release | Content | Plan |
+|---------|---------|------|
+| **v2.6.0** | Project-scoped files. BREAKING (proto + client API). Includes migration of existing files to the `default` project. Ships and installs before any policy work. | `docs/superpowers/plans/2026-08-08-project-scoped-files-v2.6.0.md` |
+| **v2.7.0** | Named API keys, principal resolution, principal in `AuthContext`, principal in the audit log. Additive and back-compatible — no enforcement yet. | to be written |
+| **v2.8.0** | Policy store, enforcement across reads/writes/history/files, audit mode, CLI. | to be written |
+
+Files go first because the breaking proto/client change is independent of the
+security semantics and is a prerequisite for file policies; landing it alone
+keeps the blast radius of each release small.
+
+### Blob deduplication and cross-project leakage
+
+Blob storage dedups by checksum **globally**, and `StoreResult.deduplicated` is
+returned to the caller. Left as-is, that flag tells project A that project B
+already holds a given byte sequence — a cross-project existence oracle.
+
+Resolution (implemented in v2.6.0): keep global blob dedup so the disk saving is
+retained, but compute `deduplicated` from whether **the requesting project**
+already references that checksum. No leak, no extra disk. `getRefCount()` stays
+global and becomes operator-only.