From b028c80cabdf3f64b96e5d494c24adf057ddd29f Mon Sep 17 00:00:00 2001 From: Hamza Khyari Date: Fri, 6 Mar 2026 00:49:33 +0000 Subject: [PATCH] feat(acms): add scoped backend view filtering Add project-resource isolation to the ACMS context tier system via ScopedBackendView, ResourceScope, alias resolution, DAG expansion, and enforcement hooks. Core types (scoped_view.py): ResourceScope, ScopeViolationError, ScopedBackendView, ScopedBackendSet, create_scoped_backend_set. Scope resolution (scope_resolution.py): ResourceAliasResolver, validate_project_scope, validate_resource_scope, resolve_resource_scope with registry_lookup callback for DAG expansion. Tier integration (scoped_tiers.py): ScopedTierMixin with get_scoped, get_scoped_by_resource, validate_fragment_scope, store_with_scope_check, get_scoped_metrics mixed into ContextTierService. Backward compat: tiers.py re-exports ScopedBackendView. Tests: 74 Behave scenarios (235 steps), 8 Robot Framework cases, 7 ASV benchmark suites. Lint and typecheck clean. ISSUES CLOSED: #193 --- CHANGELOG.md | 15 + benchmarks/scoped_view_bench.py | 270 +++++ docs/reference/scoped_backend_view.md | 271 ++++++ features/scoped_view.feature | 465 +++++++++ features/steps/scoped_view_steps.py | 920 ++++++++++++++++++ features/steps/scoped_view_tier_steps.py | 282 ++++++ robot/helper_scoped_view.py | 273 ++++++ robot/scoped_view.robot | 73 ++ .../application/services/context_tiers.py | 94 +- .../application/services/scoped_tiers.py | 219 +++++ .../domain/models/acms/__init__.py | 30 +- .../domain/models/acms/scope_resolution.py | 325 +++++++ .../domain/models/acms/scoped_view.py | 473 +++++++++ src/cleveragents/domain/models/acms/tiers.py | 47 +- 14 files changed, 3681 insertions(+), 76 deletions(-) create mode 100644 benchmarks/scoped_view_bench.py create mode 100644 docs/reference/scoped_backend_view.md create mode 100644 features/scoped_view.feature create mode 100644 features/steps/scoped_view_steps.py create mode 100644 features/steps/scoped_view_tier_steps.py create mode 100644 robot/helper_scoped_view.py create mode 100644 robot/scoped_view.robot create mode 100644 src/cleveragents/application/services/scoped_tiers.py create mode 100644 src/cleveragents/domain/models/acms/scope_resolution.py create mode 100644 src/cleveragents/domain/models/acms/scoped_view.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 321baea9a..cf516e961 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ ``plan correct``), database-backed persistence, context snapshots, and invariant enforcement during strategize. Added acceptance criteria tags and milestone documentation to the robot suite. (#494) +- Added scoped backend view filtering for project-resource isolation in ACMS. + `ResourceScope` holds the resolved set of resource ULIDs and project names visible + to a plan (immutable, with `include_paths`/`exclude_paths` glob filtering via + `PurePath.full_match()`). `ScopedBackendView` wraps text/vector/graph backends to + auto-inject the `scope` parameter into every query, and filters `TieredFragment` + visibility by project name and resource ID. `ScopedBackendSet` bundles scoped + views for all three backend types. `ResourceAliasResolver` translates user-facing + aliases to canonical resource ULIDs with uniqueness validation. + `resolve_resource_scope()` builds a `ResourceScope` from projects with + allowlist/denylist filtering. `validate_project_scope()` and + `validate_resource_scope()` guard against out-of-scope access. Three enforcement + hooks added to `ContextTierService`: `get_scoped_by_resource`, + `validate_fragment_scope`, `store_with_scope_check`. Includes 74 Behave BDD + scenarios (with full coverage-gap tests), 8 Robot Framework integration tests, + ASV benchmarks, and reference documentation. (#193) - Added `builtin/plan-subplan` tool for strategy actors to emit `SUBPLAN_SPAWN` or `SUBPLAN_PARALLEL_SPAWN` decisions. Validates payload via `SubplanPayload` (Pydantic), applies defaults (merge strategy, max_parallel, dependencies), generates rationale text, diff --git a/benchmarks/scoped_view_bench.py b/benchmarks/scoped_view_bench.py new file mode 100644 index 000000000..22ba0c166 --- /dev/null +++ b/benchmarks/scoped_view_bench.py @@ -0,0 +1,270 @@ +"""ASV benchmarks for ACMS Scoped Backend View filtering. + +Measures the performance of: +- ResourceScope creation and containment checks +- ResourceScope path matching with include/exclude globs +- ScopedBackendView fragment visibility filtering +- ScopedBackendView backend search proxying overhead +- ScopedBackendSet creation and multi-backend search +- ResourceAliasResolver alias resolution +- resolve_resource_scope allowlist/denylist resolution +- validate_project_scope and validate_resource_scope guards +""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path + +# Ensure the local *source* tree is importable even when ASV has an +# older build of the package installed. +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +# Force-reload so ASV picks up the source tree version. +import cleveragents # noqa: E402 + +importlib.reload(cleveragents) + +from cleveragents.domain.models.acms.scope_resolution import ( # noqa: E402 + resolve_resource_scope, + validate_project_scope, + validate_resource_scope, +) +from cleveragents.domain.models.acms.scoped_view import ( # noqa: E402 + ResourceScope, + ScopedBackendView, + create_scoped_backend_set, +) +from cleveragents.domain.models.acms.stubs import ( # noqa: E402 + InMemoryGraphBackend, + InMemoryTextBackend, + InMemoryVectorBackend, +) +from cleveragents.domain.models.acms.tiers import ( # noqa: E402 + ContextTier, + TieredFragment, +) +from cleveragents.domain.models.core.project import ( # noqa: E402 + LinkedResource, + NamespacedProject, +) + +# --------------------------------------------------------------------------- +# ResourceScope benchmarks +# --------------------------------------------------------------------------- + + +class ResourceScopeCreationSuite: + """Benchmark ResourceScope instantiation overhead.""" + + timeout = 60 + + def time_create_small_scope(self) -> None: + ResourceScope( + resource_ids=frozenset({"R1", "R2"}), + project_names=frozenset({"local/api"}), + ) + + def time_create_large_scope(self) -> None: + ResourceScope( + resource_ids=frozenset(f"R{i:04d}" for i in range(1000)), + project_names=frozenset(f"ns/proj{i}" for i in range(10)), + ) + + def time_contains_resource(self) -> None: + scope = ResourceScope( + resource_ids=frozenset(f"R{i:04d}" for i in range(1000)), + project_names=frozenset({"p1"}), + ) + scope.contains_resource("R0500") + + def time_contains_project(self) -> None: + scope = ResourceScope( + resource_ids=frozenset({"R1"}), + project_names=frozenset(f"ns/proj{i}" for i in range(100)), + ) + scope.contains_project("ns/proj50") + + +# --------------------------------------------------------------------------- +# Path matching benchmarks +# --------------------------------------------------------------------------- + + +class PathMatchingSuite: + """Benchmark ResourceScope.matches_path() with various patterns.""" + + timeout = 60 + + def setup(self) -> None: + self.scope_include = ResourceScope( + resource_ids=frozenset({"R1"}), + project_names=frozenset({"p1"}), + include_paths=("src/**/*.py", "tests/**/*.py"), + ) + self.scope_exclude = ResourceScope( + resource_ids=frozenset({"R1"}), + project_names=frozenset({"p1"}), + exclude_paths=("**/node_modules/**", "**/__pycache__/**"), + ) + self.scope_both = ResourceScope( + resource_ids=frozenset({"R1"}), + project_names=frozenset({"p1"}), + include_paths=("src/**/*.py",), + exclude_paths=("**/test_*",), + ) + + def time_match_include_hit(self) -> None: + self.scope_include.matches_path("src/models/user.py") + + def time_match_include_miss(self) -> None: + self.scope_include.matches_path("docs/readme.md") + + def time_match_exclude_pass(self) -> None: + self.scope_exclude.matches_path("src/main.py") + + def time_match_exclude_reject(self) -> None: + self.scope_exclude.matches_path("node_modules/foo/bar.js") + + def time_match_combined(self) -> None: + self.scope_both.matches_path("src/main.py") + + +# --------------------------------------------------------------------------- +# ScopedBackendView benchmarks +# --------------------------------------------------------------------------- + + +class FragmentFilteringSuite: + """Benchmark ScopedBackendView.is_visible() fragment filtering.""" + + timeout = 60 + + def setup(self) -> None: + self.view = ScopedBackendView( + allowed_projects=frozenset({"proj-a"}), + resource_ids=frozenset({"R1", "R2"}), + ) + self.frag_visible = TieredFragment( + fragment_id="f1", + content="c", + tier=ContextTier("hot"), + project_name="proj-a", + resource_id="R1", + ) + self.frag_hidden = TieredFragment( + fragment_id="f2", + content="c", + tier=ContextTier("hot"), + project_name="proj-b", + resource_id="R1", + ) + + def time_visible_fragment(self) -> None: + self.view.is_visible(self.frag_visible) + + def time_hidden_fragment(self) -> None: + self.view.is_visible(self.frag_hidden) + + def time_effective_scope(self) -> None: + self.view.effective_scope() + + +class BackendProxyingSuite: + """Benchmark ScopedBackendView search proxying overhead.""" + + timeout = 60 + + def setup(self) -> None: + scope = ResourceScope( + resource_ids=frozenset({"R1", "R2"}), + project_names=frozenset({"local/api"}), + ) + self.view = ScopedBackendView.from_resource_scope(scope) + self.text_backend = InMemoryTextBackend() + self.vector_backend = InMemoryVectorBackend() + self.graph_backend = InMemoryGraphBackend() + self.embedding: list[float] = [0.1] * 768 + + def time_search_text(self) -> None: + self.view.search_text(self.text_backend, "auth flow") + + def time_search_vector(self) -> None: + self.view.search_vector(self.vector_backend, self.embedding) + + def time_search_graph(self) -> None: + self.view.search_graph( + self.graph_backend, "SELECT ?s WHERE { ?s a uko:Container }" + ) + + +# --------------------------------------------------------------------------- +# ScopedBackendSet benchmarks +# --------------------------------------------------------------------------- + + +class BackendSetSuite: + """Benchmark ScopedBackendSet creation and multi-backend search.""" + + timeout = 60 + + def setup(self) -> None: + self.scope = ResourceScope( + resource_ids=frozenset({"R1"}), + project_names=frozenset({"local/api"}), + ) + + def time_create_backend_set(self) -> None: + create_scoped_backend_set( + self.scope, + text_backend=InMemoryTextBackend(), + vector_backend=InMemoryVectorBackend(), + graph_backend=InMemoryGraphBackend(), + ) + + def time_backend_set_text_search(self) -> None: + bset = create_scoped_backend_set(self.scope, text_backend=InMemoryTextBackend()) + bset.search_text("query") + + +# --------------------------------------------------------------------------- +# Scope resolution benchmarks +# --------------------------------------------------------------------------- + + +class ScopeResolutionSuite: + """Benchmark resolve_resource_scope and validation guards.""" + + timeout = 60 + + def setup(self) -> None: + self.project = NamespacedProject( + name="api", + namespace="local", + linked_resources=[ + LinkedResource(resource_id="01HQ8ZDRX50000000000000001", alias="repo"), + LinkedResource(resource_id="01HQ8ZDRX50000000000000002", alias="docs"), + ], + ) + self.scope = ResourceScope( + resource_ids=frozenset({"R1", "R2", "R3"}), + project_names=frozenset({"p1", "p2"}), + ) + + def time_resolve_scope_no_filter(self) -> None: + resolve_resource_scope([self.project]) + + def time_resolve_scope_with_include(self) -> None: + resolve_resource_scope([self.project], include_resources=("repo",)) + + def time_resolve_scope_with_exclude(self) -> None: + resolve_resource_scope([self.project], exclude_resources=("docs",)) + + def time_validate_project_scope(self) -> None: + validate_project_scope(frozenset({"p1"}), frozenset({"p1", "p2"})) + + def time_validate_resource_scope(self) -> None: + validate_resource_scope(frozenset({"R1"}), self.scope) diff --git a/docs/reference/scoped_backend_view.md b/docs/reference/scoped_backend_view.md new file mode 100644 index 000000000..1700c80e6 --- /dev/null +++ b/docs/reference/scoped_backend_view.md @@ -0,0 +1,271 @@ +# Scoped Backend View + +The Scoped Backend View layer provides project-resource isolation for the +Advanced Context Management System (ACMS). It ensures that actors and +strategies never see resources outside their plan's scope, enforcing +cross-project isolation at both the backend query and fragment visibility +levels. + +## Architecture + +``` +Plan ──► ResourceScope ──► ScopedBackendView ──► Backend Protocol ──► Physical Store + │ │ + │ ├── search_text(backend, query) + │ ├── search_vector(backend, embedding) + │ ├── search_graph(backend, query) + │ └── is_visible(fragment) → bool + │ + ├── resource_ids: frozenset[str] + ├── project_names: frozenset[str] + ├── include_paths / exclude_paths + └── include_resources / exclude_resources +``` + +Each plan is assigned a `ResourceScope` at creation time. The scope is +immutable (frozen) — once created, a plan's visible resources never change. +`ScopedBackendView` wraps backend queries to automatically inject the +`scope` parameter, and `ScopedBackendSet` bundles scoped views for all +three backend types (text, vector, graph). + +## ResourceScope + +Resolved set of resources and projects visible to a plan. Created by +`resolve_resource_scope()` during plan creation. + +Based on `docs/specification.md` lines 42510--42525. + +| Field | Type | Description | +|-------|------|-------------| +| `resource_ids` | `frozenset[str]` | Resource ULIDs in scope (empty = no resources after filtering) | +| `project_names` | `frozenset[str]` | Namespaced project names in scope (min 1) | +| `temporal_scope` | `str` | One of `"current"`, `"recent"`, `"all"` (default: `"current"`) | +| `include_resources` | `tuple[str, ...]` | Resource allowlist (names/aliases). Empty = all | +| `exclude_resources` | `tuple[str, ...]` | Resource denylist. Applied after allowlist | +| `include_paths` | `tuple[str, ...]` | Path glob allowlist. Empty = all | +| `exclude_paths` | `tuple[str, ...]` | Path glob denylist. Applied after allowlist | + +### Methods + +| Method | Signature | Returns | +|--------|-----------|---------| +| `contains_resource` | `(resource_id: str)` | `bool` | +| `contains_project` | `(project_name: str)` | `bool` | +| `matches_path` | `(path: str)` | `bool` — applies include/exclude glob filters | + +Path matching uses `pathlib.PurePath.full_match()` which supports recursive +`**` glob patterns (e.g., `src/**/*.py`). + +## ScopedBackendView + +Wraps a backend to restrict queries to a resource scope. Operates at two +levels: backend-level (injects `scope` into queries) and fragment-level +(filters `TieredFragment` by project name and resource ID). + +Based on `docs/specification.md` lines 42548--42566. + +| Field | Type | Description | +|-------|------|-------------| +| `allowed_projects` | `frozenset[str]` | Project names the actor can access (min 1) | +| `resource_ids` | `frozenset[str]` | Resource ULIDs in scope (empty = project-only filtering) | +| `denied_resource_ids` | `frozenset[str]` | Resource ULIDs explicitly denied (denylist) | + +### Methods + +| Method | Signature | Returns | +|--------|-----------|---------| +| `is_resource_in_scope` | `(resource_id: str)` | `bool` | +| `effective_scope` | `()` | `frozenset[str]` — `resource_ids` minus `denied_resource_ids` | +| `is_visible` | `(fragment: object)` | `bool` — fragment-level project+resource check | +| `search_text` | `(backend: TextBackend, query: str, *, max_results: int = 20)` | `list[TextResult]` | +| `search_vector` | `(backend: VectorBackend, embedding: list[float], *, top_k: int = 20)` | `list[VectorResult]` | +| `search_graph` | `(backend: GraphBackend, query: str)` | `GraphResult` | +| `from_resource_scope` | `(scope: ResourceScope)` (classmethod) | `ScopedBackendView` | + +**Preconditions:** + +- `allowed_projects` must be non-empty. +- When `resource_ids` is populated, search methods raise `ScopeViolationError` if + `effective_scope()` is empty (all resources denied). + +**Postconditions:** + +- Search results are restricted to in-scope resources via the `scope` parameter. +- Fragments with empty `project_name` are always excluded. + +## ScopedBackendSet + +Bundle of scoped backend views for text, vector, and graph backends. +Created by `create_scoped_backend_set()` during initial context assembly. + +| Field | Type | Description | +|-------|------|-------------| +| `view` | `ScopedBackendView` | The scoped view enforcing isolation | +| `text_backend` | `TextBackend \| None` | Text search backend (None if unavailable) | +| `vector_backend` | `VectorBackend \| None` | Vector search backend (None if unavailable) | +| `graph_backend` | `GraphBackend \| None` | Graph query backend (None if unavailable) | + +### Methods + +| Method | Signature | Returns | +|--------|-----------|---------| +| `search_text` | `(query: str, *, max_results: int = 20)` | `list[TextResult]` — empty if no backend | +| `search_vector` | `(embedding: list[float], *, top_k: int = 20)` | `list[VectorResult]` — empty if no backend | +| `search_graph` | `(query: str)` | `GraphResult` — empty if no backend | + +## ScopeViolationError + +Exception raised when a context request references resources outside scope. + +| Attribute | Type | Description | +|-----------|------|-------------| +| `resource_ids` | `tuple[str, ...]` | Out-of-scope resource identifiers | +| `scope_project_names` | `tuple[str, ...]` | Project names that define the scope | + +## ResourceAliasResolver + +Stateless helper that resolves resource aliases to canonical resource ULIDs. +Used during scope resolution to translate user-facing resource names. + +| Method | Signature | Returns | +|--------|-----------|---------| +| `resolve` | `(project: NamespacedProject, alias_or_id: str)` | `str \| None` | +| `resolve_many` | `(project: NamespacedProject, names: tuple[str, ...])` | `frozenset[str]` | +| `validate_uniqueness` | `(project: NamespacedProject)` | `list[str]` — error messages for duplicates | + +## Factory and Validation Functions + +### `create_scoped_backend_set()` + +```python +def create_scoped_backend_set( + scope: ResourceScope, + *, + text_backend: TextBackend | None = None, + vector_backend: VectorBackend | None = None, + graph_backend: GraphBackend | None = None, +) -> ScopedBackendSet: ... +``` + +### `resolve_resource_scope()` + +```python +def resolve_resource_scope( + projects: list[NamespacedProject], + *, + include_resources: tuple[str, ...] = (), + exclude_resources: tuple[str, ...] = (), + include_paths: tuple[str, ...] = (), + exclude_paths: tuple[str, ...] = (), + temporal_scope: str = "current", + registry_lookup: Callable[[str], frozenset[str]] | None = None, +) -> ResourceScope: ... +``` + +Collects all resource IDs from linked resources across projects, then applies +allowlist/denylist filtering using alias resolution. When `registry_lookup` is +provided, each resource ID is expanded to include its DAG descendants (per +spec §42517--42521). + +### `validate_project_scope()` + +```python +def validate_project_scope( + requested_projects: frozenset[str], + available_projects: frozenset[str], +) -> None: ... # raises ScopeViolationError +``` + +### `validate_resource_scope()` + +```python +def validate_resource_scope( + requested_resource_ids: frozenset[str], + scope: ResourceScope, +) -> None: ... # raises ScopeViolationError +``` + +## Usage Examples + +### Basic Scope Resolution + +```python +from cleveragents.domain.models.acms.scope_resolution import resolve_resource_scope + +# Resolve scope from a project — includes all linked resources +scope = resolve_resource_scope([project]) + +# Allowlist: only include the "repo" resource (by alias) +scope = resolve_resource_scope([project], include_resources=("repo",)) + +# Denylist: exclude "docs" resource, keep everything else +scope = resolve_resource_scope([project], exclude_resources=("docs",)) + +# Combined: include only Python files, exclude test files +scope = resolve_resource_scope( + [project], + include_paths=("src/**/*.py",), + exclude_paths=("**/test_*", "**/*_test.py"), +) +``` + +### Scoped Backend Queries + +```python +from cleveragents.domain.models.acms.scoped_view import ( + ScopedBackendView, + create_scoped_backend_set, +) + +# Create a scoped view from a resolved scope +view = ScopedBackendView.from_resource_scope(scope) + +# Search with automatic scope injection +results = view.search_text(text_backend, "authentication flow") +vectors = view.search_vector(vector_backend, embedding) +graph = view.search_graph(graph_backend, "SELECT ?s WHERE { ?s a uko:Class }") + +# Or use ScopedBackendSet for convenience +backend_set = create_scoped_backend_set( + scope, + text_backend=text_backend, + vector_backend=vector_backend, + graph_backend=graph_backend, +) +results = backend_set.search_text("authentication flow") +``` + +### Scope Validation Guards + +```python +from cleveragents.domain.models.acms.scope_resolution import ( + validate_project_scope, + validate_resource_scope, +) +from cleveragents.domain.models.acms.scoped_view import ScopeViolationError + +try: + validate_project_scope( + requested_projects=frozenset({"local/api", "local/frontend"}), + available_projects=frozenset({"local/api"}), + ) +except ScopeViolationError as e: + # "Projects not in scope: local/frontend. Available: local/api" + print(e) +``` + +## Module Locations + +| Module | Purpose | +|--------|---------| +| `cleveragents.domain.models.acms.scoped_view` | Core types: `ResourceScope`, `ScopedBackendView`, `ScopedBackendSet` | +| `cleveragents.domain.models.acms.scope_resolution` | Resolution: `ResourceAliasResolver`, `resolve_resource_scope`, validation guards | +| `cleveragents.domain.models.acms.tiers` | Re-exports `ScopedBackendView` for backward compat | +| `cleveragents.domain.models.acms` | Package-level re-exports of all public symbols | +| `cleveragents.application.services.context_tiers` | Enforcement hooks (`get_scoped`, `get_scoped_by_resource`, etc.) | + +## Related + +- [ACMS Backends Reference](acms_backends.md) — Backend protocol definitions +- [Specification: ACMS > Scoped Views](../specification.md) — Authoritative design (lines 42501--42566) +- [Forgejo Issue #193](https://git.cleverthis.com/cleveragents/cleveragents-core/issues/193) — Feature request diff --git a/features/scoped_view.feature b/features/scoped_view.feature new file mode 100644 index 000000000..8d3f1f3d7 --- /dev/null +++ b/features/scoped_view.feature @@ -0,0 +1,465 @@ +Feature: Scoped Backend View Filtering + As a developer + I want scoped backend views that filter text/vector/graph queries to project resources + So that plans never see resources outside their scope (cross-project isolation) + + # ---- ResourceScope ---- + + Scenario: Create a valid ResourceScope + Given sv a ResourceScope with resources "RES01", "RES02" and projects "local/api" + Then sv the scope should contain resource "RES01" + And sv the scope should contain resource "RES02" + And sv the scope should not contain resource "RES99" + And sv the scope should contain project "local/api" + And sv the scope should not contain project "local/frontend" + + Scenario: ResourceScope is frozen + Given sv a ResourceScope with resources "RES01" and projects "local/api" + Then sv modifying the scope should raise an error + + Scenario: ResourceScope temporal_scope defaults to current + Given sv a ResourceScope with resources "RES01" and projects "local/api" + Then sv temporal_scope should be "current" + + Scenario: ResourceScope rejects invalid temporal_scope + Then sv creating a scope with temporal_scope "invalid" should raise ValidationError + + Scenario: ResourceScope allows empty resource_ids after filtering + Then sv creating a scope with no resources should succeed + + Scenario: ResourceScope rejects empty project_names + Then sv creating a scope with no projects should raise ValidationError + + # ---- ResourceScope path filtering ---- + + Scenario: ResourceScope matches_path with include patterns + Given sv a ResourceScope with include_paths "src/**/*.py", "tests/**/*.py" + Then sv path "src/main.py" should match + And sv path "tests/test_foo.py" should match + And sv path "docs/readme.md" should not match + + Scenario: ResourceScope matches_path with exclude patterns + Given sv a ResourceScope with exclude_paths "**/node_modules/**", "**/__pycache__/**" + Then sv path "src/main.py" should match + And sv path "node_modules/foo/bar.js" should not match + And sv path "src/__pycache__/foo.pyc" should not match + + Scenario: ResourceScope matches_path with both include and exclude + Given sv a ResourceScope with include_paths "src/**/*.py" and exclude_paths "**/test_*" + Then sv path "src/main.py" should match + And sv path "src/test_main.py" should not match + And sv path "docs/readme.md" should not match + + # ---- ScopedBackendView: fragment-level filtering ---- + + Scenario: ScopedBackendView filters fragments by project name + Given sv a project-only ScopedBackendView for "proj-a" + And sv a fragment "frag-a" in project "proj-a" with resource "RES01" + And sv a fragment "frag-b" in project "proj-b" with resource "RES02" + Then sv fragment "frag-a" should be visible + And sv fragment "frag-b" should not be visible + + Scenario: ScopedBackendView excludes fragments with empty project_name + Given sv a project-only ScopedBackendView for "proj-a" + And sv a fragment "frag-x" with no project + Then sv fragment "frag-x" should not be visible + + Scenario: ScopedBackendView filters by resource_ids when set + Given sv a project-and-resource ScopedBackendView for "proj-a" resources "RES01" + And sv a fragment "frag-a" in project "proj-a" with resource "RES01" + And sv a fragment "frag-c" in project "proj-a" with resource "RES99" + Then sv fragment "frag-a" should be visible + And sv fragment "frag-c" should not be visible + + Scenario: ScopedBackendView denied_resource_ids blocks specific resources + Given sv a deny-resource ScopedBackendView for "proj-a" allow "RES01", "RES02" deny "RES02" + And sv a fragment "frag-a" in project "proj-a" with resource "RES01" + And sv a fragment "frag-d" in project "proj-a" with resource "RES02" + Then sv fragment "frag-a" should be visible + And sv fragment "frag-d" should not be visible + + # ---- ScopedBackendView: backend-level search proxying ---- + + Scenario: ScopedBackendView proxies text search with scope injected + Given sv a ResourceScope with resources "RES01", "RES02" and projects "local/api" + And sv a ScopedBackendView from that scope + And sv an InMemoryTextBackend + When sv I search text for "auth flow" + Then sv the text search should return an empty list + And sv the text search should not raise an error + + Scenario: ScopedBackendView proxies vector search with scope injected + Given sv a ResourceScope with resources "RES01" and projects "local/api" + And sv a ScopedBackendView from that scope + And sv an InMemoryVectorBackend + When sv I search vector with embedding + Then sv the vector search should return an empty list + + Scenario: ScopedBackendView proxies graph search with scope injected + Given sv a ResourceScope with resources "RES01" and projects "local/api" + And sv a ScopedBackendView from that scope + And sv an InMemoryGraphBackend + When sv I search graph for "SELECT ?s WHERE { ?s a uko:Container }" + Then sv the graph search should return an empty GraphResult + + # ---- ScopedBackendView: effective_scope ---- + + Scenario: effective_scope returns resource_ids minus denied + Given sv a triple-resource ScopedBackendView for "p" with "R1", "R2", "R3" denying "R2" + Then sv effective_scope should contain "R1" and "R3" but not "R2" + + Scenario: effective_scope is empty when all resources denied + Given sv a single-deny ScopedBackendView for "p" allow "R1" deny "R1" + Then sv effective_scope should be empty + + # ---- ScopedBackendView: from_resource_scope ---- + + Scenario: from_resource_scope creates a view with scope's IDs and projects + Given sv a ResourceScope with two resources "RES01", "RES02" and two projects "local/api", "local/web" + When sv I create a ScopedBackendView from that ResourceScope + Then sv the view should allow project "local/api" + And sv the view should allow project "local/web" + And sv the view resource_ids should contain "RES01" and "RES02" + + # ---- ScopedBackendSet ---- + + Scenario: ScopedBackendSet wraps multiple backends + Given sv a ResourceScope with resources "RES01" and projects "local/api" + And sv a ScopedBackendSet with text, vector, and graph backends + When sv I search text via backend set for "test query" + Then sv the backend set text search should succeed + + Scenario: ScopedBackendSet with no text backend returns empty list + Given sv a ResourceScope with resources "RES01" and projects "local/api" + And sv a ScopedBackendSet with no text backend + When sv I search text via backend set for "test query" + Then sv the backend set text search should return empty list + + # ---- ScopeViolationError ---- + + Scenario: ScopeViolationError carries resource and project info + When sv I raise a ScopeViolationError with resource "RES01" and projects "proj-a" + Then sv the error resource_ids should contain "RES01" + And sv the error scope_project_names should contain "proj-a" + + # ---- validate_project_scope ---- + + Scenario: validate_project_scope passes for valid projects + Then sv validating projects "proj-a" against available "proj-a", "proj-b" should pass + + Scenario: validate_project_scope rejects out-of-scope projects + Then sv validating projects "proj-c" against available "proj-a", "proj-b" should raise ScopeViolationError + + Scenario: validate_project_scope rejects empty requested projects + Then sv validating empty requested projects should raise ValueError + + # ---- validate_resource_scope ---- + + Scenario: validate_resource_scope passes for in-scope resources + Given sv a ResourceScope with resources "RES01", "RES02" and projects "local/api" + Then sv validating resources "RES01" against that scope should pass + + Scenario: validate_resource_scope rejects out-of-scope resources + Given sv a ResourceScope with resources "RES01" and projects "local/api" + Then sv validating resources "RES99" against that scope should raise ScopeViolationError + + # ---- ResourceAliasResolver ---- + + Scenario: Resolve alias to resource_id + Given sv a project "local/myproj" with resource "01HQ8ZDRX50000000000000001" aliased "repo" + And sv a ResourceAliasResolver + When sv I resolve alias "repo" + Then sv the resolved resource_id should be "01HQ8ZDRX50000000000000001" + + Scenario: Resolve by resource_id directly + Given sv a project "local/myproj" with resource "01HQ8ZDRX50000000000000001" aliased "repo" + And sv a ResourceAliasResolver + When sv I resolve alias "01HQ8ZDRX50000000000000001" + Then sv the resolved resource_id should be "01HQ8ZDRX50000000000000001" + + Scenario: Resolve unknown alias returns None + Given sv a project "local/myproj" with resource "01HQ8ZDRX50000000000000001" aliased "repo" + And sv a ResourceAliasResolver + When sv I resolve alias "nonexistent" + Then sv the resolved resource_id should be None + + Scenario: Resolve rejects empty input + Given sv a project "local/myproj" with resource "01HQ8ZDRX50000000000000001" aliased "repo" + And sv a ResourceAliasResolver + Then sv resolving empty alias should raise ValueError + + Scenario: resolve_many resolves multiple aliases + Given sv a project "local/myproj" with two resources "01HQ8ZDRX50000000000000001" aliased "repo" and "01HQ8ZDRX50000000000000002" aliased "docs" + And sv a ResourceAliasResolver + When sv I resolve many aliases "repo", "docs" + Then sv the resolved set should contain "01HQ8ZDRX50000000000000001" and "01HQ8ZDRX50000000000000002" + + Scenario: validate_uniqueness detects duplicate aliases + Given sv a project "local/myproj" with duplicate alias "repo" on "01HQ8ZDRX50000000000000001" and "01HQ8ZDRX50000000000000002" + And sv a ResourceAliasResolver + When sv I validate alias uniqueness + Then sv there should be 1 uniqueness error mentioning "repo" + + Scenario: validate_uniqueness passes for unique aliases + Given sv a project "local/myproj" with two resources "01HQ8ZDRX50000000000000001" aliased "repo" and "01HQ8ZDRX50000000000000002" aliased "docs" + And sv a ResourceAliasResolver + When sv I validate alias uniqueness + Then sv there should be 0 uniqueness errors + + # ---- resolve_resource_scope ---- + + Scenario: resolve_resource_scope collects all resources from projects + Given sv a project "local/api" with linked resources "01HQ8ZDRX50000000000000001", "01HQ8ZDRX50000000000000002" + When sv I resolve the resource scope + Then sv the scope should have 2 resource_ids + And sv the scope should contain resource "01HQ8ZDRX50000000000000001" + And sv the scope should contain resource "01HQ8ZDRX50000000000000002" + + Scenario: resolve_resource_scope applies include allowlist + Given sv a project "local/api" with two resources "01HQ8ZDRX50000000000000001" aliased "repo" and "01HQ8ZDRX50000000000000002" aliased "docs" + When sv I resolve the resource scope with include "repo" + Then sv the scope should have 1 resource_ids + And sv the scope should contain resource "01HQ8ZDRX50000000000000001" + + Scenario: resolve_resource_scope applies exclude denylist + Given sv a project "local/api" with two resources "01HQ8ZDRX50000000000000001" aliased "repo" and "01HQ8ZDRX50000000000000002" aliased "docs" + When sv I resolve the resource scope with exclude "docs" + Then sv the scope should have 1 resource_ids + And sv the scope should contain resource "01HQ8ZDRX50000000000000001" + + Scenario: resolve_resource_scope rejects empty projects + Then sv resolving scope with empty projects should raise ValueError + + # ---- ContextTierService: scope enforcement ---- + + Scenario: get_scoped_by_resource filters by ResourceScope + Given sv a ContextTierService + And sv a ResourceScope with resources "RES01" and projects "proj-a" + When sv I store fragment "f1" project "proj-a" resource "RES01" tier "hot" + And sv I store fragment "f2" project "proj-b" resource "RES02" tier "hot" + Then sv get_scoped_by_resource should return 1 fragment + + Scenario: store_with_scope_check rejects out-of-scope resource + Given sv a ContextTierService + And sv a ResourceScope with resources "RES01" and projects "proj-a" + Then sv storing fragment with resource "RES99" should raise ScopeViolationError + + Scenario: store_with_scope_check rejects out-of-scope project + Given sv a ContextTierService + And sv a ResourceScope with resources "RES01" and projects "proj-a" + Then sv storing fragment with project "evil-proj" and resource "RES01" should raise ScopeViolationError + + Scenario: validate_fragment_scope passes for in-scope fragment + Given sv a ContextTierService + And sv a ResourceScope with resources "RES01" and projects "proj-a" + When sv I store fragment "f3" project "proj-a" resource "RES01" tier "hot" + Then sv validating fragment "f3" against the scope should pass + + Scenario: validate_fragment_scope rejects out-of-scope fragment + Given sv a ContextTierService + And sv a ResourceScope with resources "RES01" and projects "proj-a" + When sv I store fragment "f4" project "proj-b" resource "RES02" tier "hot" + Then sv validating fragment "f4" against the scope should raise ScopeViolationError + + # ---- ScopedBackendSet: vector and graph ---- + + Scenario: ScopedBackendSet proxies vector search + Given sv a ResourceScope with resources "RES01" and projects "local/api" + And sv a ScopedBackendSet with text, vector, and graph backends + When sv I search vector via backend set + Then sv the backend set vector search should return empty list + + Scenario: ScopedBackendSet proxies graph search + Given sv a ResourceScope with resources "RES01" and projects "local/api" + And sv a ScopedBackendSet with text, vector, and graph backends + When sv I search graph via backend set for "SELECT ?s WHERE { ?s a uko:X }" + Then sv the backend set graph search should return empty GraphResult + + # ---- Mixed project scope guard ---- + + Scenario: Mixed project scope guard catches unlinked projects + Then sv validating mixed projects "proj-a", "proj-c" against available "proj-a", "proj-b" should raise ScopeViolationError + + # ---- Coverage: field validator coercion (scoped_view L226, L237) ---- + + Scenario: ScopedBackendView coerces list resource_ids to frozenset + Then sv constructing view with list resource_ids should succeed + + Scenario: ScopedBackendView coerces list denied_resource_ids to frozenset + Then sv constructing view with list denied_resource_ids should succeed + + # ---- Coverage: effective_scope project-only (scoped_view L273) ---- + + Scenario: effective_scope returns empty for project-only view + Given sv a project-only ScopedBackendView for "proj-a" + Then sv effective_scope should be empty + + # ---- Coverage: _require_effective_scope raises (scoped_view L326-330) ---- + + Scenario: search_text raises when all resources are denied + Given sv a single-deny ScopedBackendView for "p" allow "R1" deny "R1" + And sv an InMemoryTextBackend + Then sv searching text via denied view should raise ScopeViolationError + + # ---- Coverage: ScopedBackendSet None backends (scoped_view L489, L505) ---- + + Scenario: ScopedBackendSet search_vector with no vector backend + Given sv a ResourceScope with resources "RES01" and projects "local/api" + And sv a ScopedBackendSet with no vector backend + Then sv searching vector via no-vector set should return empty list + + Scenario: ScopedBackendSet search_graph with no graph backend + Given sv a ResourceScope with resources "RES01" and projects "local/api" + And sv a ScopedBackendSet with no graph backend + Then sv searching graph via no-graph set should return empty GraphResult + + # ---- Coverage: resolve_many unresolvable alias (scoped_view L617) ---- + + Scenario: resolve_many skips unresolvable aliases + Given sv a project "local/myproj" with resource "01HQ8ZDRX50000000000000001" aliased "repo" + And sv a ResourceAliasResolver + When sv I resolve many aliases "repo", "nonexistent" + Then sv the resolved set size should be 1 + And sv the resolved set should contain "01HQ8ZDRX50000000000000001" + + # ---- Coverage: validate_uniqueness no-alias branch (scoped_view L638) ---- + + Scenario: validate_uniqueness ignores resources without aliases + Given sv a project with aliased and unaliased resources + And sv a ResourceAliasResolver + When sv I validate alias uniqueness + Then sv there should be 0 uniqueness errors + + # ---- Coverage: validate_project_scope empty available (scoped_view L676) ---- + + Scenario: validate_project_scope rejects empty available projects + Then sv validating projects against empty available should raise ValueError + + # ---- Coverage: validate_resource_scope empty request (scoped_view L710) ---- + + Scenario: validate_resource_scope passes for empty requested resources + Given sv a ResourceScope with resources "RES01" and projects "local/api" + Then sv validating empty resources against scope should pass + + # ---- Coverage: resolve_resource_scope empty after filter (scoped_view L790) ---- + + Scenario: resolve_resource_scope produces empty after include filtering + Given sv a project "local/api" with two resources "01HQ8ZDRX50000000000000001" aliased "repo" and "01HQ8ZDRX50000000000000002" aliased "docs" + When sv I resolve the resource scope with include "nonexistent" + Then sv the scope should have 0 resource_ids + + # ---- Coverage: context_tiers store empty id (L97) ---- + + Scenario: store rejects empty fragment_id + Given sv a ContextTierService + Then sv storing fragment with empty id should raise ValueError + + # ---- Coverage: context_tiers get_scoped_view empty (L191) ---- + + Scenario: get_scoped_view with empty project_names returns empty + Given sv a ContextTierService + Then sv get_scoped_view with empty projects should return empty list + + # ---- Coverage: context_tiers evict_lru empty store (L269) ---- + + Scenario: evict_lru on empty tier returns empty list + Given sv a ContextTierService + Then sv evict_lru on empty cold tier should return empty list + + # ---- Coverage: context_tiers get_scoped_metrics (L311-316) ---- + + Scenario: get_scoped_metrics counts fragments by project + Given sv a ContextTierService + When sv I store fragment "m1" project "proj-a" resource "RES01" tier "hot" + And sv I store fragment "m2" project "proj-b" resource "RES02" tier "warm" + Then sv get_scoped_metrics for "proj-a" should show 1 hot and 0 warm + + # ---- Coverage: context_tiers get_scoped_by_resource 0 rejected (L355) ---- + + Scenario: get_scoped_by_resource returns all when none rejected + Given sv a ContextTierService + And sv a ResourceScope with resources "RES01" and projects "proj-a" + When sv I store fragment "g1" project "proj-a" resource "RES01" tier "hot" + Then sv get_scoped_by_resource should return 1 fragment + + # ---- Coverage: context_tiers validate_fragment_scope not found (L382) ---- + + Scenario: validate_fragment_scope rejects non-existent fragment + Given sv a ContextTierService + And sv a ResourceScope with resources "RES01" and projects "proj-a" + Then sv validating non-existent fragment should raise ValueError + + # ---- Coverage: context_tiers store_with_scope_check happy path (L434) ---- + + Scenario: store_with_scope_check stores in-scope fragment successfully + Given sv a ContextTierService + And sv a ResourceScope with resources "RES01" and projects "proj-a" + Then sv store_with_scope_check should accept in-scope fragment + + # ---- Coverage: context_tiers budget property (L443) ---- + + Scenario: tier budget property returns TierBudget + Given sv a ContextTierService + Then sv tier service budget should be a TierBudget + + # ---- Coverage: context_tiers get_for_actor no project filter (L161) ---- + + Scenario: get_for_actor without project filter returns fragments + Given sv a ContextTierService + When sv I store fragment "a1" project "proj-a" resource "RES01" tier "hot" + Then sv get_for_actor without projects should return a list + + # ---- Coverage: context_tiers _summarize_for_cold false branch (L481) ---- + + Scenario: demote warm fragment with short content to cold + Given sv a ContextTierService + When sv I store a short warm fragment "sf1" + Then sv demoting "sf1" to cold should keep content intact + + # ---- P0-S1: is_visible denies empty resource_id when resource_ids set ---- + + Scenario: is_visible denies fragment with empty resource_id when resource filtering active + Given sv a ScopedBackendView for projects "proj-a" with resource_ids "RES01" + When sv I check visibility of a fragment with project "proj-a" and empty resource_id + Then sv the fragment should NOT be visible + + Scenario: is_visible allows fragment with empty resource_id when no resource filtering + Given sv a ScopedBackendView for project "proj-a" + When sv I check visibility of a fragment with project "proj-a" and empty resource_id + Then sv the fragment should be visible + + # ---- P0-S2: get_scoped enforces scope ---- + + Scenario: get_scoped returns fragment when in scope + Given sv a ContextTierService + And sv a ResourceScope with resources "RES01" and projects "proj-a" + When sv I store fragment "f1" project "proj-a" resource "RES01" tier "hot" + Then sv get_scoped for "f1" should return the fragment + + Scenario: get_scoped returns None for out-of-scope fragment + Given sv a ContextTierService + And sv a ResourceScope with resources "RES01" and projects "proj-a" + When sv I store fragment "f2" project "proj-b" resource "RES02" tier "hot" + Then sv get_scoped for "f2" should return None + + # ---- P1-SPEC1: DAG expansion via registry_lookup ---- + + Scenario: resolve_resource_scope expands child resources via registry_lookup + Given sv a project "local/api" with two resources "01HQ8ZDRX50000000000000001" aliased "repo" and "01HQ8ZDRX50000000000000002" aliased "docs" + When sv I resolve scope with registry_lookup expanding "01HQ8ZDRX50000000000000001" to "01HQ8ZDRX50000000000000099" + Then sv the scope should have 3 resource_ids + And sv the scope should contain resource "01HQ8ZDRX50000000000000099" + + # ---- P2-B2: tuple coercion in field validators ---- + + Scenario: ScopedBackendView accepts tuple for allowed_projects + When sv I create a ScopedBackendView with tuple allowed_projects + Then sv the view should have 1 allowed project + + # ---- P0-S2: get_scoped with empty resource_id denied ---- + + Scenario: get_scoped denies fragment with empty resource_id when scope has resources + Given sv a ContextTierService + And sv a ResourceScope with resources "RES01" and projects "proj-a" + When sv I store fragment "f3" with project "proj-a" and empty resource_id in tier "hot" + Then sv get_scoped for "f3" should return None diff --git a/features/steps/scoped_view_steps.py b/features/steps/scoped_view_steps.py new file mode 100644 index 000000000..12337ff89 --- /dev/null +++ b/features/steps/scoped_view_steps.py @@ -0,0 +1,920 @@ +"""Step definitions for scoped backend view filtering. + +All steps prefixed with ``sv`` to avoid AmbiguousStep collisions with +existing step files in the project. +""" + +from __future__ import annotations + +from typing import Any + +from behave import given, then, when # type: ignore[import-untyped] + +from cleveragents.domain.models.acms.scope_resolution import ( + ResourceAliasResolver, + resolve_resource_scope, + validate_project_scope, + validate_resource_scope, +) +from cleveragents.domain.models.acms.scoped_view import ( + ResourceScope, + ScopedBackendView, + ScopeViolationError, + create_scoped_backend_set, +) +from cleveragents.domain.models.acms.stubs import ( + InMemoryGraphBackend, + InMemoryTextBackend, + InMemoryVectorBackend, +) +from cleveragents.domain.models.acms.tiers import ( + ContextTier, + TieredFragment, +) + +__all__: list[str] = [] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_fragment( + fid: str, + project: str = "", + resource: str = "", + tier: str = "hot", +) -> TieredFragment: + """Create a TieredFragment for testing.""" + return TieredFragment( + fragment_id=fid, + content=f"content-{fid}", + tier=ContextTier(tier), + project_name=project, + resource_id=resource, + ) + + +def _make_project( + name: str, + namespace: str = "local", + links: list[tuple[str, str | None]] | None = None, +) -> Any: + """Create a NamespacedProject with linked resources.""" + from cleveragents.domain.models.core.project import ( + LinkedResource, + NamespacedProject, + ) + + linked: list[LinkedResource] = [] + if links: + for resource_id, alias in links: + linked.append(LinkedResource(resource_id=resource_id, alias=alias)) + + return NamespacedProject( + name=name, + namespace=namespace, + linked_resources=linked, + ) + + +# --------------------------------------------------------------------------- +# ResourceScope +# --------------------------------------------------------------------------- + + +@given('sv a ResourceScope with resources "{res1}", "{res2}" and projects "{proj}"') +def step_sv_scope_two_res_one_proj( + context: Any, res1: str, res2: str, proj: str +) -> None: + context.sv_scope = ResourceScope( + resource_ids=frozenset({res1, res2}), + project_names=frozenset({proj}), + ) + + +@given('sv a ResourceScope with resources "{res1}" and projects "{proj}"') +def step_sv_scope_one_res_one_proj(context: Any, res1: str, proj: str) -> None: + context.sv_scope = ResourceScope( + resource_ids=frozenset({res1}), + project_names=frozenset({proj}), + ) + + +@then('sv the scope should contain resource "{res}"') +def step_sv_scope_contains_res(context: Any, res: str) -> None: + assert context.sv_scope.contains_resource(res), ( + f"Expected scope to contain resource '{res}'" + ) + + +@then('sv the scope should not contain resource "{res}"') +def step_sv_scope_not_contains_res(context: Any, res: str) -> None: + assert not context.sv_scope.contains_resource(res), ( + f"Expected scope NOT to contain resource '{res}'" + ) + + +@then('sv the scope should contain project "{proj}"') +def step_sv_scope_contains_proj(context: Any, proj: str) -> None: + assert context.sv_scope.contains_project(proj) + + +@then('sv the scope should not contain project "{proj}"') +def step_sv_scope_not_contains_proj(context: Any, proj: str) -> None: + assert not context.sv_scope.contains_project(proj) + + +@then("sv modifying the scope should raise an error") +def step_sv_scope_frozen(context: Any) -> None: + from pydantic import ValidationError + + try: + context.sv_scope.resource_ids = frozenset({"NEW"}) + raise AssertionError("Expected frozen model error") + except (ValidationError, AttributeError, TypeError): + pass + + +@then('sv temporal_scope should be "{expected}"') +def step_sv_temporal_scope(context: Any, expected: str) -> None: + assert context.sv_scope.temporal_scope == expected + + +@then('sv creating a scope with temporal_scope "{ts}" should raise ValidationError') +def step_sv_scope_bad_temporal(context: Any, ts: str) -> None: + from pydantic import ValidationError + + try: + ResourceScope( + resource_ids=frozenset({"R1"}), + project_names=frozenset({"p1"}), + temporal_scope=ts, + ) + raise AssertionError("Expected ValidationError") + except ValidationError: + pass + + +@then("sv creating a scope with no resources should succeed") +def step_sv_scope_empty_resources(context: Any) -> None: + scope = ResourceScope( + resource_ids=frozenset(), + project_names=frozenset({"p1"}), + ) + assert len(scope.resource_ids) == 0 + assert scope.contains_project("p1") + + +@then("sv creating a scope with no projects should raise ValidationError") +def step_sv_scope_no_projects(context: Any) -> None: + from pydantic import ValidationError + + try: + ResourceScope( + resource_ids=frozenset({"R1"}), + project_names=frozenset(), + ) + raise AssertionError("Expected ValidationError") + except ValidationError: + pass + + +# --------------------------------------------------------------------------- +# ResourceScope path filtering +# --------------------------------------------------------------------------- + + +@given('sv a ResourceScope with include_paths "{p1}", "{p2}"') +def step_sv_scope_include_paths(context: Any, p1: str, p2: str) -> None: + context.sv_scope = ResourceScope( + resource_ids=frozenset({"R1"}), + project_names=frozenset({"p1"}), + include_paths=(p1, p2), + ) + + +@given('sv a ResourceScope with exclude_paths "{p1}", "{p2}"') +def step_sv_scope_exclude_paths(context: Any, p1: str, p2: str) -> None: + context.sv_scope = ResourceScope( + resource_ids=frozenset({"R1"}), + project_names=frozenset({"p1"}), + exclude_paths=(p1, p2), + ) + + +@given('sv a ResourceScope with include_paths "{inc}" and exclude_paths "{exc}"') +def step_sv_scope_inc_exc_paths(context: Any, inc: str, exc: str) -> None: + context.sv_scope = ResourceScope( + resource_ids=frozenset({"R1"}), + project_names=frozenset({"p1"}), + include_paths=(inc,), + exclude_paths=(exc,), + ) + + +@then('sv path "{path}" should match') +def step_sv_path_matches(context: Any, path: str) -> None: + assert context.sv_scope.matches_path(path), ( + f"Expected path '{path}' to match scope filters" + ) + + +@then('sv path "{path}" should not match') +def step_sv_path_not_matches(context: Any, path: str) -> None: + assert not context.sv_scope.matches_path(path), ( + f"Expected path '{path}' NOT to match scope filters" + ) + + +# --------------------------------------------------------------------------- +# ScopedBackendView: fragment-level filtering +# --------------------------------------------------------------------------- + + +@given('sv a project-only ScopedBackendView for "{proj}"') +def step_sv_view_one_proj(context: Any, proj: str) -> None: + context.sv_view = ScopedBackendView( + allowed_projects=frozenset({proj}), + ) + context.sv_fragments = {} # dict[str, TieredFragment] + + +@given('sv a project-and-resource ScopedBackendView for "{proj}" resources "{res}"') +def step_sv_view_proj_res(context: Any, proj: str, res: str) -> None: + context.sv_view = ScopedBackendView( + allowed_projects=frozenset({proj}), + resource_ids=frozenset({res}), + ) + context.sv_fragments = {} + + +@given( + 'sv a deny-resource ScopedBackendView for "{proj}" allow "{r1}", "{r2}" deny "{d1}"' +) +def step_sv_view_proj_res_denied( + context: Any, proj: str, r1: str, r2: str, d1: str +) -> None: + context.sv_view = ScopedBackendView( + allowed_projects=frozenset({proj}), + resource_ids=frozenset({r1, r2}), + denied_resource_ids=frozenset({d1}), + ) + context.sv_fragments = {} + + +@given('sv a fragment "{fid}" in project "{proj}" with resource "{res}"') +def step_sv_fragment_proj_res(context: Any, fid: str, proj: str, res: str) -> None: + context.sv_fragments[fid] = _make_fragment(fid, project=proj, resource=res) + + +@given('sv a fragment "{fid}" with no project') +def step_sv_fragment_no_proj(context: Any, fid: str) -> None: + context.sv_fragments[fid] = _make_fragment(fid, project="") + + +@then('sv fragment "{fid}" should be visible') +def step_sv_fragment_visible(context: Any, fid: str) -> None: + frag = context.sv_fragments[fid] + assert context.sv_view.is_visible(frag), f"Expected fragment '{fid}' to be visible" + + +@then('sv fragment "{fid}" should not be visible') +def step_sv_fragment_not_visible(context: Any, fid: str) -> None: + frag = context.sv_fragments[fid] + assert not context.sv_view.is_visible(frag), ( + f"Expected fragment '{fid}' NOT to be visible" + ) + + +# --------------------------------------------------------------------------- +# ScopedBackendView: backend search proxying +# --------------------------------------------------------------------------- + + +@given("sv a ScopedBackendView from that scope") +def step_sv_view_from_scope(context: Any) -> None: + context.sv_view = ScopedBackendView.from_resource_scope(context.sv_scope) + context.sv_fragments = {} + + +@given("sv an InMemoryTextBackend") +def step_sv_text_backend(context: Any) -> None: + context.sv_text_backend = InMemoryTextBackend() + + +@given("sv an InMemoryVectorBackend") +def step_sv_vector_backend(context: Any) -> None: + context.sv_vector_backend = InMemoryVectorBackend() + + +@given("sv an InMemoryGraphBackend") +def step_sv_graph_backend(context: Any) -> None: + context.sv_graph_backend = InMemoryGraphBackend() + + +@when('sv I search text for "{query}"') +def step_sv_search_text(context: Any, query: str) -> None: + context.sv_text_results = context.sv_view.search_text( + context.sv_text_backend, query + ) + context.sv_search_error = None + + +@then("sv the text search should return an empty list") +def step_sv_text_empty(context: Any) -> None: + assert context.sv_text_results == [] + + +@then("sv the text search should not raise an error") +def step_sv_text_no_error(context: Any) -> None: + assert context.sv_search_error is None + + +@when("sv I search vector with embedding") +def step_sv_search_vector(context: Any) -> None: + context.sv_vector_results = context.sv_view.search_vector( + context.sv_vector_backend, [0.1, 0.2, 0.3] + ) + + +@then("sv the vector search should return an empty list") +def step_sv_vector_empty(context: Any) -> None: + assert context.sv_vector_results == [] + + +@when('sv I search graph for "{query}"') +def step_sv_search_graph(context: Any, query: str) -> None: + context.sv_graph_result = context.sv_view.search_graph( + context.sv_graph_backend, query + ) + + +@then("sv the graph search should return an empty GraphResult") +def step_sv_graph_empty(context: Any) -> None: + assert context.sv_graph_result.triples == [] + + +# --------------------------------------------------------------------------- +# effective_scope +# --------------------------------------------------------------------------- + + +@given( + 'sv a triple-resource ScopedBackendView for "{proj}" with "{r1}", "{r2}", "{r3}" denying "{d}"' +) +def step_sv_view_three_res_denied( + context: Any, proj: str, r1: str, r2: str, r3: str, d: str +) -> None: + context.sv_view = ScopedBackendView( + allowed_projects=frozenset({proj}), + resource_ids=frozenset({r1, r2, r3}), + denied_resource_ids=frozenset({d}), + ) + context.sv_fragments = {} + + +@given('sv a single-deny ScopedBackendView for "{proj}" allow "{r}" deny "{d}"') +def step_sv_view_one_res_denied(context: Any, proj: str, r: str, d: str) -> None: + context.sv_view = ScopedBackendView( + allowed_projects=frozenset({proj}), + resource_ids=frozenset({r}), + denied_resource_ids=frozenset({d}), + ) + context.sv_fragments = {} + + +@then('sv effective_scope should contain "{r1}" and "{r2}" but not "{r3}"') +def step_sv_effective_contains_not(context: Any, r1: str, r2: str, r3: str) -> None: + eff = context.sv_view.effective_scope() + assert r1 in eff, f"Expected {r1} in effective_scope" + assert r2 in eff, f"Expected {r2} in effective_scope" + assert r3 not in eff, f"Expected {r3} NOT in effective_scope" + + +@then("sv effective_scope should be empty") +def step_sv_effective_empty(context: Any) -> None: + eff = context.sv_view.effective_scope() + assert len(eff) == 0, f"Expected empty effective_scope, got {eff}" + + +# --------------------------------------------------------------------------- +# from_resource_scope +# --------------------------------------------------------------------------- + + +@when("sv I create a ScopedBackendView from that ResourceScope") +def step_sv_create_view_from_scope(context: Any) -> None: + context.sv_view = ScopedBackendView.from_resource_scope(context.sv_scope) + + +@then('sv the view should allow project "{proj}"') +def step_sv_view_allows_proj(context: Any, proj: str) -> None: + assert proj in context.sv_view.allowed_projects + + +@then('sv the view resource_ids should contain "{r1}" and "{r2}"') +def step_sv_view_res_contains(context: Any, r1: str, r2: str) -> None: + assert r1 in context.sv_view.resource_ids + assert r2 in context.sv_view.resource_ids + + +# --------------------------------------------------------------------------- +# ScopedBackendSet +# --------------------------------------------------------------------------- + + +@given("sv a ScopedBackendSet with text, vector, and graph backends") +def step_sv_backend_set_all(context: Any) -> None: + context.sv_backend_set = create_scoped_backend_set( + context.sv_scope, + text_backend=InMemoryTextBackend(), + vector_backend=InMemoryVectorBackend(), + graph_backend=InMemoryGraphBackend(), + ) + + +@given("sv a ScopedBackendSet with no text backend") +def step_sv_backend_set_no_text(context: Any) -> None: + context.sv_backend_set = create_scoped_backend_set( + context.sv_scope, + text_backend=None, + vector_backend=InMemoryVectorBackend(), + graph_backend=InMemoryGraphBackend(), + ) + + +@when('sv I search text via backend set for "{query}"') +def step_sv_backend_set_search(context: Any, query: str) -> None: + context.sv_backend_set_results = context.sv_backend_set.search_text(query) + + +@then("sv the backend set text search should succeed") +def step_sv_backend_set_ok(context: Any) -> None: + assert isinstance(context.sv_backend_set_results, list) + + +@then("sv the backend set text search should return empty list") +def step_sv_backend_set_empty(context: Any) -> None: + assert context.sv_backend_set_results == [] + + +@when("sv I search vector via backend set") +def step_sv_backend_set_vector(context: Any) -> None: + context.sv_backend_set_vec_results = context.sv_backend_set.search_vector( + [0.1, 0.2, 0.3] + ) + + +@then("sv the backend set vector search should return empty list") +def step_sv_backend_set_vec_empty(context: Any) -> None: + assert context.sv_backend_set_vec_results == [] + + +@when('sv I search graph via backend set for "{query}"') +def step_sv_backend_set_graph(context: Any, query: str) -> None: + context.sv_backend_set_graph_result = context.sv_backend_set.search_graph(query) + + +@then("sv the backend set graph search should return empty GraphResult") +def step_sv_backend_set_graph_empty(context: Any) -> None: + assert context.sv_backend_set_graph_result.triples == [] + + +# --------------------------------------------------------------------------- +# ScopeViolationError +# --------------------------------------------------------------------------- + + +@when('sv I raise a ScopeViolationError with resource "{res}" and projects "{proj}"') +def step_sv_raise_scope_error(context: Any, res: str, proj: str) -> None: + context.sv_error = ScopeViolationError( + "test error", + resource_ids=(res,), + scope_project_names=(proj,), + ) + + +@then('sv the error resource_ids should contain "{res}"') +def step_sv_error_res(context: Any, res: str) -> None: + assert res in context.sv_error.resource_ids + + +@then('sv the error scope_project_names should contain "{proj}"') +def step_sv_error_proj(context: Any, proj: str) -> None: + assert proj in context.sv_error.scope_project_names + + +# --------------------------------------------------------------------------- +# validate_project_scope +# --------------------------------------------------------------------------- + + +@then('sv validating projects "{req}" against available "{a1}", "{a2}" should pass') +def step_sv_validate_proj_pass(context: Any, req: str, a1: str, a2: str) -> None: + validate_project_scope(frozenset({req}), frozenset({a1, a2})) + + +@then( + 'sv validating projects "{req}" against available "{a1}", "{a2}" should raise ScopeViolationError' +) +def step_sv_validate_proj_fail_one(context: Any, req: str, a1: str, a2: str) -> None: + try: + validate_project_scope(frozenset({req}), frozenset({a1, a2})) + raise AssertionError("Expected ScopeViolationError") + except ScopeViolationError: + pass + + +@then( + 'sv validating mixed projects "{r1}", "{r2}" against available "{a1}", "{a2}" should raise ScopeViolationError' +) +def step_sv_validate_proj_fail_two( + context: Any, r1: str, r2: str, a1: str, a2: str +) -> None: + try: + validate_project_scope(frozenset({r1, r2}), frozenset({a1, a2})) + raise AssertionError("Expected ScopeViolationError") + except ScopeViolationError: + pass + + +@then("sv validating empty requested projects should raise ValueError") +def step_sv_validate_proj_empty(context: Any) -> None: + try: + validate_project_scope(frozenset(), frozenset({"p1"})) + raise AssertionError("Expected ValueError") + except ValueError: + pass + + +# --------------------------------------------------------------------------- +# validate_resource_scope +# --------------------------------------------------------------------------- + + +@then('sv validating resources "{res}" against that scope should pass') +def step_sv_validate_res_pass(context: Any, res: str) -> None: + validate_resource_scope(frozenset({res}), context.sv_scope) + + +@then( + 'sv validating resources "{res}" against that scope should raise ScopeViolationError' +) +def step_sv_validate_res_fail(context: Any, res: str) -> None: + try: + validate_resource_scope(frozenset({res}), context.sv_scope) + raise AssertionError("Expected ScopeViolationError") + except ScopeViolationError: + pass + + +# --------------------------------------------------------------------------- +# ResourceAliasResolver +# --------------------------------------------------------------------------- + + +@given('sv a project "{ns}/{name}" with resource "{res}" aliased "{alias}"') +def step_sv_proj_one_alias( + context: Any, ns: str, name: str, res: str, alias: str +) -> None: + context.sv_project = _make_project(name, namespace=ns, links=[(res, alias)]) + + +@given( + 'sv a ResourceScope with two resources "{r1}", "{r2}" and two projects "{p1}", "{p2}"' +) +def step_sv_scope_two_res_two_proj( + context: Any, r1: str, r2: str, p1: str, p2: str +) -> None: + context.sv_scope = ResourceScope( + resource_ids=frozenset({r1, r2}), + project_names=frozenset({p1, p2}), + ) + + +@given( + 'sv a project "{ns}/{name}" with two resources "{r1}" aliased "{a1}" and "{r2}" aliased "{a2}"' +) +def step_sv_proj_two_aliases( + context: Any, ns: str, name: str, r1: str, a1: str, r2: str, a2: str +) -> None: + context.sv_project = _make_project(name, namespace=ns, links=[(r1, a1), (r2, a2)]) + + +@given('sv a project "{ns}/{name}" with duplicate alias "{alias}" on "{r1}" and "{r2}"') +def step_sv_proj_dup_alias( + context: Any, ns: str, name: str, alias: str, r1: str, r2: str +) -> None: + context.sv_project = _make_project( + name, namespace=ns, links=[(r1, alias), (r2, alias)] + ) + + +@given("sv a ResourceAliasResolver") +def step_sv_resolver(context: Any) -> None: + context.sv_resolver = ResourceAliasResolver() + + +@when('sv I resolve alias "{alias}"') +def step_sv_resolve_alias(context: Any, alias: str) -> None: + context.sv_resolved = context.sv_resolver.resolve(context.sv_project, alias) + + +@then('sv the resolved resource_id should be "{expected}"') +def step_sv_resolved_is(context: Any, expected: str) -> None: + assert context.sv_resolved == expected, ( + f"Expected '{expected}', got '{context.sv_resolved}'" + ) + + +@then("sv the resolved resource_id should be None") +def step_sv_resolved_none(context: Any) -> None: + assert context.sv_resolved is None + + +@then("sv resolving empty alias should raise ValueError") +def step_sv_resolve_empty(context: Any) -> None: + try: + context.sv_resolver.resolve(context.sv_project, "") + raise AssertionError("Expected ValueError") + except ValueError: + pass + + +@when('sv I resolve many aliases "{a1}", "{a2}"') +def step_sv_resolve_many(context: Any, a1: str, a2: str) -> None: + context.sv_resolved_set = context.sv_resolver.resolve_many( + context.sv_project, (a1, a2) + ) + + +@then('sv the resolved set should contain "{r1}" and "{r2}"') +def step_sv_resolved_set_contains(context: Any, r1: str, r2: str) -> None: + assert r1 in context.sv_resolved_set + assert r2 in context.sv_resolved_set + + +@then('sv the resolved set should contain "{r1}"') +def step_sv_resolved_set_contains_one(context: Any, r1: str) -> None: + assert r1 in context.sv_resolved_set + + +@when("sv I validate alias uniqueness") +def step_sv_validate_uniqueness(context: Any) -> None: + context.sv_uniqueness_errors = context.sv_resolver.validate_uniqueness( + context.sv_project + ) + + +@then('sv there should be {count:d} uniqueness error mentioning "{alias}"') +def step_sv_uniqueness_errors_with_alias(context: Any, count: int, alias: str) -> None: + assert len(context.sv_uniqueness_errors) == count + if count > 0: + assert any(alias in err for err in context.sv_uniqueness_errors) + + +@then("sv there should be {count:d} uniqueness errors") +def step_sv_uniqueness_errors_count(context: Any, count: int) -> None: + assert len(context.sv_uniqueness_errors) == count + + +# --------------------------------------------------------------------------- +# resolve_resource_scope +# --------------------------------------------------------------------------- + + +@given('sv a project "{ns}/{name}" with linked resources "{r1}", "{r2}"') +def step_sv_proj_linked_resources( + context: Any, ns: str, name: str, r1: str, r2: str +) -> None: + context.sv_project = _make_project( + name, namespace=ns, links=[(r1, None), (r2, None)] + ) + + +@when("sv I resolve the resource scope") +def step_sv_resolve_scope(context: Any) -> None: + context.sv_scope = resolve_resource_scope([context.sv_project]) + + +@when('sv I resolve the resource scope with include "{inc}"') +def step_sv_resolve_scope_include(context: Any, inc: str) -> None: + context.sv_scope = resolve_resource_scope( + [context.sv_project], include_resources=(inc,) + ) + + +@when('sv I resolve the resource scope with exclude "{exc}"') +def step_sv_resolve_scope_exclude(context: Any, exc: str) -> None: + context.sv_scope = resolve_resource_scope( + [context.sv_project], exclude_resources=(exc,) + ) + + +@then("sv the scope should have {count:d} resource_ids") +def step_sv_scope_res_count(context: Any, count: int) -> None: + assert len(context.sv_scope.resource_ids) == count, ( + f"Expected {count} resource_ids, got {len(context.sv_scope.resource_ids)}: " + f"{context.sv_scope.resource_ids}" + ) + + +@then("sv resolving scope with empty projects should raise ValueError") +def step_sv_resolve_scope_empty(context: Any) -> None: + try: + resolve_resource_scope([]) + raise AssertionError("Expected ValueError") + except ValueError: + pass + + +# --------------------------------------------------------------------------- +# Coverage: field validator coercion (scoped_view L226, L237) +# --------------------------------------------------------------------------- + + +@then("sv constructing view with list resource_ids should succeed") +def step_sv_view_list_res(context: Any) -> None: + view = ScopedBackendView( + allowed_projects=frozenset({"p"}), + resource_ids=["R1", "R2"], # type: ignore[arg-type] + ) + assert "R1" in view.resource_ids + assert isinstance(view.resource_ids, frozenset) + + +@then("sv constructing view with list denied_resource_ids should succeed") +def step_sv_view_list_denied(context: Any) -> None: + view = ScopedBackendView( + allowed_projects=frozenset({"p"}), + resource_ids=frozenset({"R1", "R2"}), + denied_resource_ids=["R2"], # type: ignore[arg-type] + ) + assert "R2" in view.denied_resource_ids + assert isinstance(view.denied_resource_ids, frozenset) + + +# --------------------------------------------------------------------------- +# Coverage: _require_effective_scope raises (scoped_view L326-330) +# --------------------------------------------------------------------------- + + +@then("sv searching text via denied view should raise ScopeViolationError") +def step_sv_search_text_denied(context: Any) -> None: + try: + context.sv_view.search_text(context.sv_text_backend, "test") + raise AssertionError("Expected ScopeViolationError") + except ScopeViolationError: + pass + + +# --------------------------------------------------------------------------- +# Coverage: ScopedBackendSet None backends (scoped_view L489, L505) +# --------------------------------------------------------------------------- + + +@given("sv a ScopedBackendSet with no vector backend") +def step_sv_backend_set_no_vector(context: Any) -> None: + context.sv_backend_set_novector = create_scoped_backend_set( + context.sv_scope, + text_backend=InMemoryTextBackend(), + vector_backend=None, + graph_backend=InMemoryGraphBackend(), + ) + + +@then("sv searching vector via no-vector set should return empty list") +def step_sv_novector_search(context: Any) -> None: + result = context.sv_backend_set_novector.search_vector([0.1]) + assert result == [] + + +@given("sv a ScopedBackendSet with no graph backend") +def step_sv_backend_set_no_graph(context: Any) -> None: + context.sv_backend_set_nograph = create_scoped_backend_set( + context.sv_scope, + text_backend=InMemoryTextBackend(), + vector_backend=InMemoryVectorBackend(), + graph_backend=None, + ) + + +@then("sv searching graph via no-graph set should return empty GraphResult") +def step_sv_nograph_search(context: Any) -> None: + result = context.sv_backend_set_nograph.search_graph("SELECT ?s") + assert result.triples == [] + + +# --------------------------------------------------------------------------- +# Coverage: resolve_many unresolvable (scoped_view L617) +# --------------------------------------------------------------------------- + + +@then("sv the resolved set size should be {count:d}") +def step_sv_resolved_set_size(context: Any, count: int) -> None: + assert len(context.sv_resolved_set) == count, ( + f"Expected {count} entries, got {len(context.sv_resolved_set)}" + ) + + +# --------------------------------------------------------------------------- +# Coverage: validate_uniqueness with no-alias resource (scoped_view L638) +# --------------------------------------------------------------------------- + + +@given("sv a project with aliased and unaliased resources") +def step_sv_proj_mixed_alias(context: Any) -> None: + context.sv_project = _make_project( + "myproj", + namespace="local", + links=[ + ("01HQ8ZDRX50000000000000001", "repo"), + ("01HQ8ZDRX50000000000000002", None), + ], + ) + + +# --------------------------------------------------------------------------- +# Coverage: validate_project_scope empty available +# --------------------------------------------------------------------------- + + +@then("sv validating projects against empty available should raise ValueError") +def step_sv_validate_proj_empty_avail(context: Any) -> None: + try: + validate_project_scope(frozenset({"p1"}), frozenset()) + raise AssertionError("Expected ValueError") + except ValueError: + pass + + +# --------------------------------------------------------------------------- +# Coverage: validate_resource_scope empty requested +# --------------------------------------------------------------------------- + + +@then("sv validating empty resources against scope should pass") +def step_sv_validate_res_empty_req(context: Any) -> None: + validate_resource_scope(frozenset(), context.sv_scope) + + +# --------------------------------------------------------------------------- +# P0-S1: is_visible empty resource_id deny-by-default +# --------------------------------------------------------------------------- + + +@given('sv a ScopedBackendView for projects "{proj}" with resource_ids "{rid}"') +def step_sv_view_with_resources(context: Any, proj: str, rid: str) -> None: + context.sv_view = ScopedBackendView( + allowed_projects=frozenset([proj]), + resource_ids=frozenset([rid]), + ) + + +@given('sv a ScopedBackendView for project "{proj}"') +def step_sv_view_project_only(context: Any, proj: str) -> None: + context.sv_view = ScopedBackendView( + allowed_projects=frozenset([proj]), + ) + + +@when('sv I check visibility of a fragment with project "{proj}" and empty resource_id') +def step_sv_check_empty_rid(context: Any, proj: str) -> None: + frag = TieredFragment( + fragment_id="empty-rid-frag", + content="test", + project_name=proj, + resource_id="", + tier=ContextTier.HOT, + ) + context.sv_vis_result = context.sv_view.is_visible(frag) + + +@then("sv the fragment should NOT be visible") +def step_sv_not_visible(context: Any) -> None: + assert context.sv_vis_result is False + + +@then("sv the fragment should be visible") +def step_sv_is_visible(context: Any) -> None: + assert context.sv_vis_result is True + + +# --------------------------------------------------------------------------- +# P2-B2: tuple coercion +# --------------------------------------------------------------------------- + + +@when("sv I create a ScopedBackendView with tuple allowed_projects") +def step_sv_tuple_projects(context: Any) -> None: + context.sv_view = ScopedBackendView( + allowed_projects=("proj-a",), + resource_ids=frozenset(), + ) + + +@then("sv the view should have {count:d} allowed project") +def step_sv_view_project_count(context: Any, count: int) -> None: + assert len(context.sv_view.allowed_projects) == count diff --git a/features/steps/scoped_view_tier_steps.py b/features/steps/scoped_view_tier_steps.py new file mode 100644 index 000000000..17d0d745b --- /dev/null +++ b/features/steps/scoped_view_tier_steps.py @@ -0,0 +1,282 @@ +"""Step definitions for ContextTierService scope enforcement tests. + +Extracted from ``scoped_view_steps.py`` to keep step files manageable. +All steps prefixed with ``sv`` to avoid AmbiguousStep collisions. +""" + +from __future__ import annotations + +from typing import Any + +from behave import given, then, when # type: ignore[import-untyped] + +from cleveragents.domain.models.acms.scope_resolution import ( + resolve_resource_scope, +) +from cleveragents.domain.models.acms.scoped_view import ( + ScopeViolationError, +) +from cleveragents.domain.models.acms.tiers import ( + ContextTier, + TieredFragment, +) + +__all__: list[str] = [] + + +def _make_fragment( + fid: str, + project: str = "", + resource: str = "", + tier: str = "hot", +) -> TieredFragment: + """Create a TieredFragment for testing.""" + return TieredFragment( + fragment_id=fid, + content=f"content-{fid}", + tier=ContextTier(tier), + project_name=project, + resource_id=resource, + ) + + +# --------------------------------------------------------------------------- +# ContextTierService: scope enforcement +# --------------------------------------------------------------------------- + + +@given("sv a ContextTierService") +def step_sv_tier_service(context: Any) -> None: + from cleveragents.application.services.context_tiers import ContextTierService + + context.sv_tier_svc = ContextTierService() + + +@when('sv I store fragment "{fid}" project "{proj}" resource "{res}" tier "{tier}"') +def step_sv_store_fragment( + context: Any, fid: str, proj: str, res: str, tier: str +) -> None: + frag = _make_fragment(fid, project=proj, resource=res, tier=tier) + context.sv_tier_svc.store(frag) + + +@then("sv get_scoped_by_resource should return {count:d} fragment") +def step_sv_scoped_by_resource(context: Any, count: int) -> None: + result = context.sv_tier_svc.get_scoped_by_resource(context.sv_scope) + assert len(result) == count, f"Expected {count} fragments, got {len(result)}" + + +@then('sv storing fragment with resource "{res}" should raise ScopeViolationError') +def step_sv_store_scope_check(context: Any, res: str) -> None: + frag = _make_fragment("bad-frag", project="proj-a", resource=res) + try: + context.sv_tier_svc.store_with_scope_check(frag, context.sv_scope) + raise AssertionError("Expected ScopeViolationError") + except ScopeViolationError: + pass + + +@then( + 'sv storing fragment with project "{proj}" and resource "{res}"' + " should raise ScopeViolationError" +) +def step_sv_store_scope_check_project(context: Any, proj: str, res: str) -> None: + frag = _make_fragment("bad-proj-frag", project=proj, resource=res) + try: + context.sv_tier_svc.store_with_scope_check(frag, context.sv_scope) + raise AssertionError("Expected ScopeViolationError for out-of-scope project") + except ScopeViolationError: + pass + + +@then('sv validating fragment "{fid}" against the scope should pass') +def step_sv_validate_fragment(context: Any, fid: str) -> None: + context.sv_tier_svc.validate_fragment_scope(fid, context.sv_scope) + + +@then( + 'sv validating fragment "{fid}" against the scope should raise ScopeViolationError' +) +def step_sv_validate_fragment_fail(context: Any, fid: str) -> None: + try: + context.sv_tier_svc.validate_fragment_scope(fid, context.sv_scope) + raise AssertionError("Expected ScopeViolationError") + except ScopeViolationError: + pass + + +# --------------------------------------------------------------------------- +# Coverage: context_tiers store empty id +# --------------------------------------------------------------------------- + + +@then("sv storing fragment with empty id should raise ValueError") +def step_sv_store_empty_id(context: Any) -> None: + frag = TieredFragment.model_construct( + fragment_id="", + content="content", + tier=ContextTier.HOT, + project_name="p", + resource_id="R1", + ) + try: + context.sv_tier_svc.store(frag) + raise AssertionError("Expected ValueError") + except ValueError: + pass + + +# --------------------------------------------------------------------------- +# Coverage: context_tiers get_scoped_view empty +# --------------------------------------------------------------------------- + + +@then("sv get_scoped_view with empty projects should return empty list") +def step_sv_scoped_view_empty(context: Any) -> None: + result = context.sv_tier_svc.get_scoped_view([]) + assert result == [] + + +# --------------------------------------------------------------------------- +# Coverage: context_tiers evict_lru empty +# --------------------------------------------------------------------------- + + +@then("sv evict_lru on empty cold tier should return empty list") +def step_sv_evict_empty(context: Any) -> None: + result = context.sv_tier_svc.evict_lru(ContextTier.COLD, 1) + assert result == [] + + +# --------------------------------------------------------------------------- +# Coverage: context_tiers get_scoped_metrics +# --------------------------------------------------------------------------- + + +@then('sv get_scoped_metrics for "{proj}" should show {h:d} hot and {w:d} warm') +def step_sv_scoped_metrics(context: Any, proj: str, h: int, w: int) -> None: + metrics = context.sv_tier_svc.get_scoped_metrics([proj]) + assert metrics.hot_count == h, f"Expected hot={h}, got {metrics.hot_count}" + assert metrics.warm_count == w, f"Expected warm={w}, got {metrics.warm_count}" + + +# --------------------------------------------------------------------------- +# Coverage: context_tiers validate_fragment_scope not found +# --------------------------------------------------------------------------- + + +@then("sv validating non-existent fragment should raise ValueError") +def step_sv_validate_nonexistent(context: Any) -> None: + try: + context.sv_tier_svc.validate_fragment_scope("ghost", context.sv_scope) + raise AssertionError("Expected ValueError") + except ValueError: + pass + + +# --------------------------------------------------------------------------- +# Coverage: context_tiers store_with_scope_check happy path +# --------------------------------------------------------------------------- + + +@then("sv store_with_scope_check should accept in-scope fragment") +def step_sv_store_scope_ok(context: Any) -> None: + frag = _make_fragment("ok-frag", project="proj-a", resource="RES01") + context.sv_tier_svc.store_with_scope_check(frag, context.sv_scope) + result = context.sv_tier_svc.get("ok-frag") + assert result is not None + + +# --------------------------------------------------------------------------- +# Coverage: context_tiers budget property +# --------------------------------------------------------------------------- + + +@then("sv tier service budget should be a TierBudget") +def step_sv_budget(context: Any) -> None: + from cleveragents.domain.models.acms.tiers import TierBudget + + budget = context.sv_tier_svc.budget + assert isinstance(budget, TierBudget) + + +# --------------------------------------------------------------------------- +# Coverage: context_tiers get_for_actor no project filter +# --------------------------------------------------------------------------- + + +@then("sv get_for_actor without projects should return a list") +def step_sv_actor_no_projects(context: Any) -> None: + from cleveragents.domain.models.acms.tiers import ActorRole + + result = context.sv_tier_svc.get_for_actor(ActorRole.STRATEGIST) + assert isinstance(result, list) + + +# --------------------------------------------------------------------------- +# Coverage: context_tiers _summarize_for_cold false branch +# --------------------------------------------------------------------------- + + +@when('sv I store a short warm fragment "{fid}"') +def step_sv_store_short_warm(context: Any, fid: str) -> None: + frag = TieredFragment( + fragment_id=fid, + content="short", + tier=ContextTier.WARM, + project_name="proj-a", + resource_id="RES01", + ) + context.sv_tier_svc.store(frag) + + +@then('sv demoting "{fid}" to cold should keep content intact') +def step_sv_demote_short(context: Any, fid: str) -> None: + result = context.sv_tier_svc.demote(fid) + assert result is not None + assert result.content == "short" + assert result.tier == ContextTier.COLD + + +# --------------------------------------------------------------------------- +# P0-S2: get_scoped +# --------------------------------------------------------------------------- + + +@then('sv get_scoped for "{fid}" should return the fragment') +def step_sv_get_scoped_found(context: Any, fid: str) -> None: + result = context.sv_tier_svc.get_scoped(fid, context.sv_scope) + assert result is not None + assert result.fragment_id == fid + + +@then('sv get_scoped for "{fid}" should return None') +def step_sv_get_scoped_none(context: Any, fid: str) -> None: + result = context.sv_tier_svc.get_scoped(fid, context.sv_scope) + assert result is None + + +# --------------------------------------------------------------------------- +# P1-SPEC1: DAG expansion +# --------------------------------------------------------------------------- + + +@when('sv I resolve scope with registry_lookup expanding "{parent}" to "{child}"') +def step_sv_resolve_with_dag(context: Any, parent: str, child: str) -> None: + def lookup(rid: str) -> frozenset[str]: + if rid == parent: + return frozenset([child]) + return frozenset() + + context.sv_scope = resolve_resource_scope( + [context.sv_project], + registry_lookup=lookup, + ) + + +@when( + 'sv I store fragment "{fid}" with project "{proj}" and empty resource_id in tier "{tier}"' +) +def step_sv_store_empty_rid(context: Any, fid: str, proj: str, tier: str) -> None: + frag = _make_fragment(fid, project=proj, resource="", tier=tier) + context.sv_tier_svc.store(frag) diff --git a/robot/helper_scoped_view.py b/robot/helper_scoped_view.py new file mode 100644 index 000000000..6db5ec692 --- /dev/null +++ b/robot/helper_scoped_view.py @@ -0,0 +1,273 @@ +"""Robot Framework helper for scoped backend view smoke tests. + +Provides a CLI-style interface for Robot to invoke scoped view creation, +resource scope resolution, backend proxying, and scope validation. +Exit code 0 = success, 1 = failure. + +Usage: + python robot/helper_scoped_view.py +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# Ensure the src directory is on the import path. +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from cleveragents.domain.models.acms.scope_resolution import ( # noqa: E402 + ResourceAliasResolver, + resolve_resource_scope, + validate_project_scope, + validate_resource_scope, +) +from cleveragents.domain.models.acms.scoped_view import ( # noqa: E402 + ResourceScope, + ScopedBackendView, + ScopeViolationError, + create_scoped_backend_set, +) +from cleveragents.domain.models.acms.stubs import ( # noqa: E402 + InMemoryGraphBackend, + InMemoryTextBackend, + InMemoryVectorBackend, +) +from cleveragents.domain.models.acms.tiers import ( # noqa: E402 + ContextTier, + TieredFragment, +) +from cleveragents.domain.models.core.project import ( # noqa: E402 + LinkedResource, + NamespacedProject, +) + + +def main() -> int: + """Entry point called by Robot Framework ``Run Process``.""" + if len(sys.argv) < 2: + print("Usage: helper_scoped_view.py ") + return 1 + + command: str = sys.argv[1] + + if command == "resource-scope": + try: + scope = ResourceScope( + resource_ids=frozenset({"R1", "R2"}), + project_names=frozenset({"local/api"}), + ) + assert scope.contains_resource("R1") + assert scope.contains_resource("R2") + assert not scope.contains_resource("R99") + assert scope.contains_project("local/api") + assert not scope.contains_project("local/frontend") + assert scope.temporal_scope == "current" + print("scoped-view-resource-scope-ok") + return 0 + except Exception as exc: + print(f"scoped-view-resource-scope-fail: {exc}") + return 1 + + if command == "path-filtering": + try: + scope = ResourceScope( + resource_ids=frozenset({"R1"}), + project_names=frozenset({"p1"}), + include_paths=("src/**/*.py",), + exclude_paths=("**/test_*",), + ) + assert scope.matches_path("src/main.py") + assert not scope.matches_path("src/test_main.py") + assert not scope.matches_path("docs/readme.md") + print("scoped-view-path-filtering-ok") + return 0 + except Exception as exc: + print(f"scoped-view-path-filtering-fail: {exc}") + return 1 + + if command == "fragment-filtering": + try: + view = ScopedBackendView( + allowed_projects=frozenset({"proj-a"}), + resource_ids=frozenset({"R1"}), + ) + frag_ok = TieredFragment( + fragment_id="f1", + content="c", + tier=ContextTier("hot"), + project_name="proj-a", + resource_id="R1", + ) + frag_bad_proj = TieredFragment( + fragment_id="f2", + content="c", + tier=ContextTier("hot"), + project_name="proj-b", + resource_id="R1", + ) + frag_bad_res = TieredFragment( + fragment_id="f3", + content="c", + tier=ContextTier("hot"), + project_name="proj-a", + resource_id="R99", + ) + assert view.is_visible(frag_ok) + assert not view.is_visible(frag_bad_proj) + assert not view.is_visible(frag_bad_res) + print("scoped-view-fragment-filtering-ok") + return 0 + except Exception as exc: + print(f"scoped-view-fragment-filtering-fail: {exc}") + return 1 + + if command == "backend-proxying": + try: + scope = ResourceScope( + resource_ids=frozenset({"R1"}), + project_names=frozenset({"local/api"}), + ) + view = ScopedBackendView.from_resource_scope(scope) + text_results = view.search_text(InMemoryTextBackend(), "test") + assert text_results == [] + vec_results = view.search_vector(InMemoryVectorBackend(), [0.1, 0.2, 0.3]) + assert vec_results == [] + graph_result = view.search_graph( + InMemoryGraphBackend(), "SELECT ?s WHERE { ?s a uko:X }" + ) + assert graph_result.triples == [] + print("scoped-view-backend-proxying-ok") + return 0 + except Exception as exc: + print(f"scoped-view-backend-proxying-fail: {exc}") + return 1 + + if command == "backend-set": + try: + scope = ResourceScope( + resource_ids=frozenset({"R1"}), + project_names=frozenset({"local/api"}), + ) + bset = create_scoped_backend_set( + scope, + text_backend=InMemoryTextBackend(), + vector_backend=InMemoryVectorBackend(), + graph_backend=InMemoryGraphBackend(), + ) + assert bset.search_text("q") == [] + assert bset.search_vector([0.1]) == [] + assert bset.search_graph("SELECT ?s WHERE { ?s a uko:X }").triples == [] + # None backend returns empty + bset_no_text = create_scoped_backend_set(scope) + assert bset_no_text.search_text("q") == [] + print("scoped-view-backend-set-ok") + return 0 + except Exception as exc: + print(f"scoped-view-backend-set-fail: {exc}") + return 1 + + if command == "scope-validation": + try: + # validate_project_scope: valid + validate_project_scope( + frozenset({"proj-a"}), frozenset({"proj-a", "proj-b"}) + ) + # validate_project_scope: invalid + raised = False + try: + validate_project_scope( + frozenset({"proj-c"}), frozenset({"proj-a", "proj-b"}) + ) + except ScopeViolationError: + raised = True + assert raised, "Expected ScopeViolationError for out-of-scope project" + + # validate_resource_scope: valid + scope = ResourceScope( + resource_ids=frozenset({"R1", "R2"}), + project_names=frozenset({"p1"}), + ) + validate_resource_scope(frozenset({"R1"}), scope) + # validate_resource_scope: invalid + raised = False + try: + validate_resource_scope(frozenset({"R99"}), scope) + except ScopeViolationError: + raised = True + assert raised, "Expected ScopeViolationError for out-of-scope resource" + + print("scoped-view-scope-validation-ok") + return 0 + except Exception as exc: + print(f"scoped-view-scope-validation-fail: {exc}") + return 1 + + if command == "alias-resolver": + try: + ulid1 = "01HQ8ZDRX50000000000000001" + ulid2 = "01HQ8ZDRX50000000000000002" + proj = NamespacedProject( + name="myproj", + namespace="local", + linked_resources=[ + LinkedResource(resource_id=ulid1, alias="repo"), + LinkedResource(resource_id=ulid2, alias="docs"), + ], + ) + resolver = ResourceAliasResolver() + # Resolve by alias + assert resolver.resolve(proj, "repo") == ulid1 + # Resolve by ID + assert resolver.resolve(proj, ulid1) == ulid1 + # Unknown returns None + assert resolver.resolve(proj, "nonexistent") is None + # resolve_many + resolved = resolver.resolve_many(proj, ("repo", "docs")) + assert ulid1 in resolved + assert ulid2 in resolved + # validate_uniqueness: no dupes + assert resolver.validate_uniqueness(proj) == [] + print("scoped-view-alias-resolver-ok") + return 0 + except Exception as exc: + print(f"scoped-view-alias-resolver-fail: {exc}") + return 1 + + if command == "resolve-scope": + try: + ulid1 = "01HQ8ZDRX50000000000000001" + ulid2 = "01HQ8ZDRX50000000000000002" + proj = NamespacedProject( + name="api", + namespace="local", + linked_resources=[ + LinkedResource(resource_id=ulid1, alias="repo"), + LinkedResource(resource_id=ulid2, alias="docs"), + ], + ) + # Full scope + scope = resolve_resource_scope([proj]) + assert len(scope.resource_ids) == 2 + # Include filter + scope_inc = resolve_resource_scope([proj], include_resources=("repo",)) + assert len(scope_inc.resource_ids) == 1 + assert ulid1 in scope_inc.resource_ids + # Exclude filter + scope_exc = resolve_resource_scope([proj], exclude_resources=("docs",)) + assert len(scope_exc.resource_ids) == 1 + assert ulid1 in scope_exc.resource_ids + print("scoped-view-resolve-scope-ok") + return 0 + except Exception as exc: + print(f"scoped-view-resolve-scope-fail: {exc}") + return 1 + + print(f"Unknown command: {command}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/robot/scoped_view.robot b/robot/scoped_view.robot new file mode 100644 index 000000000..82658c0a7 --- /dev/null +++ b/robot/scoped_view.robot @@ -0,0 +1,73 @@ +*** Settings *** +Documentation Smoke tests for ACMS Scoped Backend View filtering +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_scoped_view.py + +*** Test Cases *** +ResourceScope Creation And Containment + [Documentation] Verify ResourceScope creation, containment checks, and temporal default + ${result}= Run Process ${PYTHON} ${HELPER} resource-scope cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} scoped-view-resource-scope-ok + +ResourceScope Path Filtering + [Documentation] Verify include/exclude path glob matching with recursive patterns + ${result}= Run Process ${PYTHON} ${HELPER} path-filtering cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} scoped-view-path-filtering-ok + +ScopedBackendView Fragment Filtering + [Documentation] Verify fragment visibility filtering by project and resource + ${result}= Run Process ${PYTHON} ${HELPER} fragment-filtering cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} scoped-view-fragment-filtering-ok + +ScopedBackendView Backend Proxying + [Documentation] Verify text/vector/graph search proxying with scope injection + ${result}= Run Process ${PYTHON} ${HELPER} backend-proxying cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} scoped-view-backend-proxying-ok + +ScopedBackendSet Integration + [Documentation] Verify ScopedBackendSet wraps multiple backends with scope enforcement + ${result}= Run Process ${PYTHON} ${HELPER} backend-set cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} scoped-view-backend-set-ok + +Scope Validation Guards + [Documentation] Verify validate_project_scope and validate_resource_scope raise correctly + ${result}= Run Process ${PYTHON} ${HELPER} scope-validation cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} scoped-view-scope-validation-ok + +ResourceAliasResolver + [Documentation] Verify alias resolution, resolve_many, and uniqueness validation + ${result}= Run Process ${PYTHON} ${HELPER} alias-resolver cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} scoped-view-alias-resolver-ok + +Resolve Resource Scope With Filters + [Documentation] Verify resolve_resource_scope with include/exclude allowlist/denylist + ${result}= Run Process ${PYTHON} ${HELPER} resolve-scope cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} scoped-view-resolve-scope-ok diff --git a/src/cleveragents/application/services/context_tiers.py b/src/cleveragents/application/services/context_tiers.py index 20d6826fc..008d369d4 100644 --- a/src/cleveragents/application/services/context_tiers.py +++ b/src/cleveragents/application/services/context_tiers.py @@ -1,34 +1,35 @@ """Context Tier Service for hot/warm/cold fragment management. -The ``ContextTierService`` manages the lifecycle of context fragments -across three storage tiers: +Core tier lifecycle: store, get, promote, demote, evict, metrics. +Scope enforcement methods live in ``scoped_tiers.py`` and are mixed +in via ``ScopedTierMixin``. -- **Hot** — in-memory ``dict``, fastest access, token-budget limited. -- **Warm** — SQLite-backed (stub), moderate latency, decision-count limited. -- **Cold** — file-backed (stub), highest latency, summarised on demotion. - -Supports promotion (cold→warm→hot), demotion (hot→warm→cold) with a -summarisation hook, LRU eviction, per-actor filtered views, and -project-scoped isolation via ``ScopedBackendView``. - -Based on ``docs/specification.md`` ACMS tier sections and issue #208. +Based on ``docs/specification.md`` ACMS tier sections, issue #208, +and issue #193 (scoped backend view filtering). """ from __future__ import annotations from datetime import UTC, datetime +import structlog + +from cleveragents.application.services.scoped_tiers import ScopedTierMixin from cleveragents.config.settings import Settings +from cleveragents.domain.models.acms.scoped_view import ( + ScopedBackendView, +) from cleveragents.domain.models.acms.tiers import ( ActorContextView, ActorRole, ContextTier, - ScopedBackendView, TierBudget, TieredFragment, TierMetrics, ) +logger = structlog.get_logger(__name__) + # --------------------------------------------------------------------------- # Default budget when settings are not provided # --------------------------------------------------------------------------- @@ -41,12 +42,12 @@ _DEFAULT_MAX_DECISIONS_COLD = 5000 _COLD_SUMMARY_MAX_CHARS = 200 -class ContextTierService: +class ContextTierService(ScopedTierMixin): """Manage context fragments across hot/warm/cold tiers. - The service is designed for dependency injection via the DI - container. All mutable state lives in the three tier stores - and the metrics counters. + Scope enforcement (``get_scoped``, ``get_scoped_by_resource``, + ``validate_fragment_scope``, ``store_with_scope_check``, + ``get_scoped_metrics``) is provided by ``ScopedTierMixin``. """ def __init__(self, settings: Settings | None = None) -> None: @@ -146,8 +147,16 @@ class ContextTierService: # Apply project scoping if project_names: - scoped = ScopedBackendView(allowed_projects=project_names) + scoped = ScopedBackendView( + allowed_projects=frozenset(project_names), + ) candidates = [f for f in candidates if scoped.is_visible(f)] + logger.debug( + "tier.actor_view_filtered", + actor_role=actor_role.value, + after=len(candidates), + project_names=project_names, + ) # Sort by last_accessed descending, cap at max_fragments candidates.sort(key=lambda f: f.last_accessed, reverse=True) @@ -168,11 +177,18 @@ class ContextTierService: """ if not project_names: return [] - scoped = ScopedBackendView(allowed_projects=project_names) + scoped = ScopedBackendView( + allowed_projects=frozenset(project_names), + ) all_frags: list[TieredFragment] = [] for store in (self._hot, self._warm, self._cold): all_frags.extend(f for f in store.values() if scoped.is_visible(f)) all_frags.sort(key=lambda f: f.last_accessed, reverse=True) + logger.debug( + "tier.scoped_view_filtered", + project_names=project_names, + result_count=len(all_frags), + ) return all_frags # ------------------------------------------------------------------ @@ -266,32 +282,9 @@ class ContextTierService: warm_miss_count=self._warm_miss, ) - def get_scoped_metrics(self, project_names: list[str]) -> TierMetrics: - """Return metrics scoped to the given project names. - - Fragment counts (``hot_count``, ``warm_count``, ``cold_count``) - reflect only fragments belonging to the specified projects. - Hit/miss counters remain global as they are service-level cache - performance metrics that are not tracked per-project. - - Args: - project_names: Project names to scope fragment counts to. - - Raises: - ValueError: If *project_names* is empty. - """ - if not project_names: - raise ValueError("project_names must be non-empty") - scoped = ScopedBackendView(allowed_projects=project_names) - return TierMetrics( - hot_count=sum(1 for f in self._hot.values() if scoped.is_visible(f)), - warm_count=sum(1 for f in self._warm.values() if scoped.is_visible(f)), - cold_count=sum(1 for f in self._cold.values() if scoped.is_visible(f)), - hot_hit_count=self._hot_hit, - hot_miss_count=self._hot_miss, - warm_hit_count=self._warm_hit, - warm_miss_count=self._warm_miss, - ) + # Scope enforcement methods (get_scoped, get_scoped_by_resource, + # validate_fragment_scope, store_with_scope_check, get_scoped_metrics) + # are provided by ScopedTierMixin — see scoped_tiers.py. # ------------------------------------------------------------------ # Budget @@ -306,6 +299,19 @@ class ContextTierService: # Internal helpers # ------------------------------------------------------------------ + def _find_fragment(self, fragment_id: str) -> TieredFragment | None: + """Look up a fragment across all tiers **without** side effects. + + Unlike :meth:`get`, this does not call :meth:`_touch` and + therefore leaves ``last_accessed``, ``access_count``, and + hit/miss counters unchanged. Used by validation methods that + must be read-only. + """ + for store in (self._hot, self._warm, self._cold): + if fragment_id in store: + return store[fragment_id] + return None + def _store_for_tier( self, tier: ContextTier, diff --git a/src/cleveragents/application/services/scoped_tiers.py b/src/cleveragents/application/services/scoped_tiers.py new file mode 100644 index 000000000..8262eb5e5 --- /dev/null +++ b/src/cleveragents/application/services/scoped_tiers.py @@ -0,0 +1,219 @@ +"""Scope enforcement mixin for the Context Tier Service. + +Extracted from ``context_tiers.py`` to keep modules under the 500-line +guideline. Provides scope-aware retrieval, storage, validation, and +metrics methods that are mixed into ``ContextTierService``. + +All methods assume access to ``_hot``, ``_warm``, ``_cold`` tier stores +and the ``_find_fragment``, ``store``, ``get`` helpers defined on the +host class. + +Based on ``docs/specification.md`` ACMS scoped views (lines 42501--42566) +and Forgejo issue #193. +""" + +from __future__ import annotations + +import structlog + +from cleveragents.domain.models.acms.scope_resolution import ( + validate_resource_scope, +) +from cleveragents.domain.models.acms.scoped_view import ( + ResourceScope, + ScopedBackendView, + ScopeViolationError, +) +from cleveragents.domain.models.acms.tiers import ( + TieredFragment, + TierMetrics, +) + +logger = structlog.get_logger(__name__) + + +class ScopedTierMixin: + """Mixin providing scope enforcement for ``ContextTierService``. + + Requires the host class to provide: + + - ``_hot``, ``_warm``, ``_cold``: ``dict[str, TieredFragment]`` + - ``_hot_hit``, ``_hot_miss``, ``_warm_hit``, ``_warm_miss``: int + - ``get(fragment_id: str) -> TieredFragment | None`` + - ``store(fragment: TieredFragment) -> None`` + - ``_find_fragment(fragment_id: str) -> TieredFragment | None`` + """ + + # Declared for type checkers; actual attrs live on the host class. + _hot: dict[str, TieredFragment] + _warm: dict[str, TieredFragment] + _cold: dict[str, TieredFragment] + _hot_hit: int + _hot_miss: int + _warm_hit: int + _warm_miss: int + + def get(self, fragment_id: str) -> TieredFragment | None: ... + def store(self, fragment: TieredFragment) -> None: ... + def _find_fragment(self, fragment_id: str) -> TieredFragment | None: ... + + # ------------------------------------------------------------------ + # Scope-aware retrieval + # ------------------------------------------------------------------ + + def get_scoped( + self, + fragment_id: str, + scope: ResourceScope, + ) -> TieredFragment | None: + """Retrieve a fragment by ID with scope enforcement. + + Unlike :meth:`get`, this validates the fragment against the + provided scope before returning it. Returns ``None`` and + logs a warning for out-of-scope fragments. + + Args: + fragment_id: The fragment to retrieve. + scope: The active resource scope to enforce. + """ + frag = self.get(fragment_id) + if frag is None: + return None + + scoped = ScopedBackendView.from_resource_scope(scope) + if not scoped.is_visible(frag): + logger.warning( + "tier.get_scoped_denied", + fragment_id=fragment_id, + fragment_project=frag.project_name, + fragment_resource=frag.resource_id, + scope_projects=sorted(scope.project_names), + ) + return None + return frag + + def get_scoped_by_resource( + self, + scope: ResourceScope, + ) -> list[TieredFragment]: + """Return fragments filtered by a full ``ResourceScope``. + + Applies both project-name and resource-ID filtering. + + Args: + scope: The resolved resource scope for the plan. + """ + scoped = ScopedBackendView.from_resource_scope(scope) + all_frags: list[TieredFragment] = [] + rejected_count = 0 + for store in (self._hot, self._warm, self._cold): + for frag in store.values(): + if scoped.is_visible(frag): + all_frags.append(frag) + else: + rejected_count += 1 + all_frags.sort(key=lambda f: f.last_accessed, reverse=True) + if rejected_count: + logger.info( + "tier.scope_enforcement", + accepted=len(all_frags), + rejected=rejected_count, + project_names=sorted(scope.project_names), + resource_count=len(scope.resource_ids), + ) + return all_frags + + def validate_fragment_scope( + self, + fragment_id: str, + scope: ResourceScope, + ) -> None: + """Validate that a specific fragment is within scope. + + Raises: + ScopeViolationError: If the fragment is out of scope. + ValueError: If the fragment does not exist. + """ + frag = self._find_fragment(fragment_id) + if frag is None: + raise ValueError(f"Fragment not found: {fragment_id}") + + scoped = ScopedBackendView.from_resource_scope(scope) + if not scoped.is_visible(frag): + logger.warning( + "tier.fragment_scope_violation", + fragment_id=fragment_id, + fragment_project=frag.project_name, + fragment_resource=frag.resource_id, + scope_projects=sorted(scope.project_names), + ) + raise ScopeViolationError( + f"Fragment '{fragment_id}' is not within the active scope", + resource_ids=(frag.resource_id,) if frag.resource_id else (), + scope_project_names=tuple(sorted(scope.project_names)), + ) + + def store_with_scope_check( + self, + fragment: TieredFragment, + scope: ResourceScope, + ) -> None: + """Store a fragment after validating it is within scope. + + Checks both the fragment's project name and resource ID + against the active scope before storing. + + .. note:: + + Fragments with an empty ``project_name`` bypass the + project check (there is no project to enforce against). + Such fragments are still invisible to scoped reads + because ``ScopedBackendView.is_visible()`` rejects + empty project names. + + Raises: + ScopeViolationError: If the fragment references an + out-of-scope project or resource. + """ + if fragment.project_name and not scope.contains_project(fragment.project_name): + logger.warning( + "tier.store_project_violation", + fragment_id=fragment.fragment_id, + fragment_project=fragment.project_name, + scope_projects=sorted(scope.project_names), + ) + raise ScopeViolationError( + f"Fragment project '{fragment.project_name}' " + f"is not within the active scope", + scope_project_names=tuple(sorted(scope.project_names)), + ) + if fragment.resource_id: + validate_resource_scope( + frozenset({fragment.resource_id}), + scope, + ) + self.store(fragment) + + def get_scoped_metrics(self, project_names: list[str]) -> TierMetrics: + """Return metrics scoped to the given project names. + + Fragment counts reflect only fragments belonging to the + specified projects. Hit/miss counters remain global. + + Raises: + ValueError: If *project_names* is empty. + """ + if not project_names: + raise ValueError("project_names must be non-empty") + scoped = ScopedBackendView( + allowed_projects=frozenset(project_names), + ) + return TierMetrics( + hot_count=sum(1 for f in self._hot.values() if scoped.is_visible(f)), + warm_count=sum(1 for f in self._warm.values() if scoped.is_visible(f)), + cold_count=sum(1 for f in self._cold.values() if scoped.is_visible(f)), + hot_hit_count=self._hot_hit, + hot_miss_count=self._hot_miss, + warm_hit_count=self._warm_hit, + warm_miss_count=self._warm_miss, + ) diff --git a/src/cleveragents/domain/models/acms/__init__.py b/src/cleveragents/domain/models/acms/__init__.py index 297d4bd9f..969d28f9f 100644 --- a/src/cleveragents/domain/models/acms/__init__.py +++ b/src/cleveragents/domain/models/acms/__init__.py @@ -46,7 +46,13 @@ Tier types (from :mod:`~cleveragents.domain.models.acms.tiers`): - ``TierBudget`` -- Per-tier token/decision budget limits - ``ActorContextView`` -- Per-actor filtered view configuration - ``TierMetrics`` -- Hit/miss counters for cache monitoring -- ``ScopedBackendView`` -- Project-scoped resource isolation + +Scoped view types (from :mod:`~cleveragents.domain.models.acms.scoped_view`): +- ``ResourceScope`` -- Resolved resource/project scope for a plan +- ``ScopeViolationError`` -- Raised on out-of-scope access attempts +- ``ScopedBackendView`` -- Project-scoped backend query filtering +- ``ScopedBackendSet`` -- Bundle of scoped text/vector/graph views +- ``ResourceAliasResolver`` -- Alias-to-resource-ID resolution helper Analyzer types (from :mod:`~cleveragents.domain.models.acms.analyzers`): - ``UKOTriple`` -- Immutable subject-predicate-object triple @@ -85,6 +91,19 @@ from cleveragents.domain.models.acms.crp import ( ) from cleveragents.domain.models.acms.markdown_analyzer import MarkdownAnalyzer from cleveragents.domain.models.acms.python_analyzer import PythonAnalyzer +from cleveragents.domain.models.acms.scope_resolution import ( + ResourceAliasResolver, + resolve_resource_scope, + validate_project_scope, + validate_resource_scope, +) +from cleveragents.domain.models.acms.scoped_view import ( + ResourceScope, + ScopedBackendSet, + ScopedBackendView, + ScopeViolationError, + create_scoped_backend_set, +) from cleveragents.domain.models.acms.strategy import ( BackendSet, ContextStrategy, @@ -111,7 +130,6 @@ from cleveragents.domain.models.acms.tiers import ( ActorContextView, ActorRole, ContextTier, - ScopedBackendView, TierBudget, TieredFragment, TierMetrics, @@ -143,6 +161,10 @@ __all__: list[str] = [ "PlanContext", "PlanDecisionContextStrategy", "PythonAnalyzer", + "ResourceAliasResolver", + "ResourceScope", + "ScopeViolationError", + "ScopedBackendSet", "ScopedBackendView", "SemanticEmbeddingStrategy", "SimpleKeywordStrategy", @@ -158,4 +180,8 @@ __all__: list[str] = [ "UKOTriple", "VectorBackend", "VectorResult", + "create_scoped_backend_set", + "resolve_resource_scope", + "validate_project_scope", + "validate_resource_scope", ] diff --git a/src/cleveragents/domain/models/acms/scope_resolution.py b/src/cleveragents/domain/models/acms/scope_resolution.py new file mode 100644 index 000000000..a5b12d0f8 --- /dev/null +++ b/src/cleveragents/domain/models/acms/scope_resolution.py @@ -0,0 +1,325 @@ +"""Resource scope resolution, alias mapping, and validation guards. + +Extracted from ``scoped_view.py`` to keep modules under the 500-line +guideline. This module provides: + +| Type / Function | Role | +|----------------------------|-------------------------------------------------| +| ``ResourceAliasResolver`` | Resolves resource aliases to canonical ULIDs | +| ``validate_project_scope`` | Guard for mixed-project scope requests | +| ``validate_resource_scope``| Guard for out-of-scope resource requests | +| ``resolve_resource_scope`` | Builds a ``ResourceScope`` with allow/deny list | + +Based on ``docs/specification.md`` > ACMS > Scoped Views and Plan +Subgraph Projection (lines 42501--42566) and Forgejo issue #193. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING + +import structlog + +from cleveragents.domain.models.acms.scoped_view import ( + ResourceScope, + ScopeViolationError, +) + +if TYPE_CHECKING: + from cleveragents.domain.models.core.project import ( + NamespacedProject, + ) + +logger = structlog.get_logger(__name__) + + +# --------------------------------------------------------------------------- +# ResourceAliasResolver +# --------------------------------------------------------------------------- + + +class ResourceAliasResolver: + """Resolves resource aliases to resource IDs with uniqueness validation. + + Stateless helper that operates on project data passed to each + method. Used during scope resolution to translate user-facing + resource names and aliases into canonical ULID identifiers. + + Based on ``docs/specification.md`` LinkedResource alias field + (line 19715) and Forgejo issue #193. + """ + + def resolve( + self, + project: NamespacedProject, + alias_or_id: str, + ) -> str | None: + """Resolve a resource alias or ID to a canonical resource ID. + + Checks aliases first (exact match), then falls back to + resource_id matching. + + Args: + project: The project containing linked resources. + alias_or_id: An alias or resource ULID to resolve. + + Returns: + The canonical resource ULID, or ``None`` if not found. + + Raises: + ValueError: If *alias_or_id* is empty or whitespace-only. + """ + if not alias_or_id or not alias_or_id.strip(): + raise ValueError("alias_or_id cannot be empty") + + # Try alias match first + by_alias = project.get_linked_resource_by_alias(alias_or_id) + if by_alias is not None: + return by_alias.resource_id + + # Try direct resource_id match + by_id = project.get_linked_resource(alias_or_id) + if by_id is not None: + return by_id.resource_id + + return None + + def resolve_many( + self, + project: NamespacedProject, + names: tuple[str, ...], + ) -> frozenset[str]: + """Resolve multiple aliases/IDs to canonical resource IDs. + + Unresolvable names are logged and skipped. + + Args: + project: The project containing linked resources. + names: Aliases or resource ULIDs to resolve. + + Returns: + Frozenset of resolved resource ULIDs. + """ + resolved: set[str] = set() + for name in names: + rid = self.resolve(project, name) + if rid is not None: + resolved.add(rid) + else: + logger.warning( + "alias.unresolved", + alias=name, + project=project.namespaced_name, + ) + return frozenset(resolved) + + def validate_uniqueness( + self, + project: NamespacedProject, + ) -> list[str]: + """Validate that all aliases in a project are unique. + + Args: + project: The project to validate. + + Returns: + List of error messages for duplicate aliases (empty if valid). + """ + seen: dict[str, list[str]] = {} + for lr in project.linked_resources: + if lr.alias is not None: + seen.setdefault(lr.alias, []).append(lr.resource_id) + + errors: list[str] = [] + for alias, resource_ids in seen.items(): + if len(resource_ids) > 1: + errors.append( + f"Duplicate alias '{alias}' maps to resources: " + f"{', '.join(sorted(resource_ids))}" + ) + return errors + + +# --------------------------------------------------------------------------- +# Mixed project scope guard +# --------------------------------------------------------------------------- + + +def validate_project_scope( + requested_projects: frozenset[str], + available_projects: frozenset[str], +) -> None: + """Validate that all requested projects are available. + + Raises ``ScopeViolationError`` if any requested project is not + in the available set. This guards against context requests that + span unlinked projects. + + Args: + requested_projects: Project names the request references. + available_projects: Project names available in the scope. + + Raises: + ScopeViolationError: If any requested project is not available. + ValueError: If either argument is empty. + """ + if not requested_projects: + raise ValueError("requested_projects must be non-empty") + if not available_projects: + raise ValueError("available_projects must be non-empty") + + out_of_scope = requested_projects - available_projects + if out_of_scope: + sorted_bad = sorted(out_of_scope) + logger.warning( + "scope.project_violation", + out_of_scope=sorted_bad, + available=sorted(available_projects), + ) + raise ScopeViolationError( + f"Projects not in scope: {', '.join(sorted_bad)}. " + f"Available: {', '.join(sorted(available_projects))}", + scope_project_names=tuple(sorted(available_projects)), + ) + + +def validate_resource_scope( + requested_resource_ids: frozenset[str], + scope: ResourceScope, +) -> None: + """Validate that all requested resource IDs are within scope. + + Raises ``ScopeViolationError`` if any resource ID is not in the + scope's ``resource_ids``. + + Args: + requested_resource_ids: Resource ULIDs the request references. + scope: The active resource scope. + + Raises: + ScopeViolationError: If any resource is out of scope. + """ + if not requested_resource_ids: + return + + out_of_scope = requested_resource_ids - scope.resource_ids + if out_of_scope: + sorted_bad = sorted(out_of_scope) + logger.warning( + "scope.resource_violation", + out_of_scope=sorted_bad, + scope_resource_count=len(scope.resource_ids), + ) + raise ScopeViolationError( + f"Resources not in scope: {', '.join(sorted_bad)}", + resource_ids=tuple(sorted_bad), + scope_project_names=tuple(sorted(scope.project_names)), + ) + + +# --------------------------------------------------------------------------- +# Allowlist/denylist resolution +# --------------------------------------------------------------------------- + + +def resolve_resource_scope( + projects: list[NamespacedProject], + *, + include_resources: tuple[str, ...] = (), + exclude_resources: tuple[str, ...] = (), + include_paths: tuple[str, ...] = (), + exclude_paths: tuple[str, ...] = (), + temporal_scope: str = "current", + registry_lookup: Callable[[str], frozenset[str]] | None = None, +) -> ResourceScope: + """Resolve a ``ResourceScope`` from projects and filter arguments. + + Collects all resource IDs from linked resources across the given + projects, then applies allowlist/denylist filtering using alias + resolution. + + When *registry_lookup* is provided, each resource ID is expanded + to include its DAG descendants (per ``docs/specification.md`` + lines 42517--42521). The callback receives a resource ULID and + returns the frozenset of descendant ULIDs. When ``None``, no + DAG expansion is performed (flat ULID-set mode). + + Args: + projects: Projects whose linked resources form the base scope. + include_resources: Resource allowlist (names/aliases). + Empty means include all. + exclude_resources: Resource denylist (names/aliases). + Applied after allowlist. + include_paths: Path glob allowlist. + exclude_paths: Path glob denylist. + temporal_scope: One of ``"current"``, ``"recent"``, ``"all"``. + registry_lookup: Optional callback to expand a resource ULID + to its DAG descendants. Signature: + ``(resource_id: str) -> frozenset[str]``. + + Returns: + A ``ResourceScope`` with the resolved resource IDs and filters. + + Raises: + ValueError: If *projects* is empty. + """ + if not projects: + raise ValueError("projects must be non-empty") + + resolver = ResourceAliasResolver() + all_resource_ids: set[str] = set() + project_names: set[str] = set() + + for project in projects: + project_names.add(project.namespaced_name) + for lr in project.linked_resources: + all_resource_ids.add(lr.resource_id) + + # DAG expansion: include child resources per spec §42517-42521 + if registry_lookup is not None: + expanded: set[str] = set() + for rid in all_resource_ids: + expanded.add(rid) + expanded.update(registry_lookup(rid)) + all_resource_ids = expanded + + # Apply allowlist: resolve names/aliases to resource IDs + if include_resources: + allowed: set[str] = set() + for project in projects: + allowed.update(resolver.resolve_many(project, include_resources)) + all_resource_ids &= allowed + + # Apply denylist: resolve names/aliases to resource IDs to exclude + if exclude_resources: + denied: set[str] = set() + for project in projects: + denied.update(resolver.resolve_many(project, exclude_resources)) + all_resource_ids -= denied + + if not all_resource_ids: + logger.warning( + "scope.empty_after_filtering", + project_names=sorted(project_names), + include_resources=include_resources, + exclude_resources=exclude_resources, + ) + + return ResourceScope( + resource_ids=frozenset(all_resource_ids), + project_names=frozenset(project_names), + temporal_scope=temporal_scope, + include_resources=include_resources, + exclude_resources=exclude_resources, + include_paths=include_paths, + exclude_paths=exclude_paths, + ) + + +__all__: list[str] = [ + "ResourceAliasResolver", + "resolve_resource_scope", + "validate_project_scope", + "validate_resource_scope", +] diff --git a/src/cleveragents/domain/models/acms/scoped_view.py b/src/cleveragents/domain/models/acms/scoped_view.py new file mode 100644 index 000000000..8fe1675e8 --- /dev/null +++ b/src/cleveragents/domain/models/acms/scoped_view.py @@ -0,0 +1,473 @@ +"""Scoped backend view filtering for project-resource isolation. + +Core types: ``ResourceScope``, ``ScopeViolationError``, +``ScopedBackendView``, ``ScopedBackendSet``, ``create_scoped_backend_set``. + +Resolution helpers live in ``scope_resolution.py``. + +Based on ``docs/specification.md`` lines 42501--42566 and issue #193. +""" + +from __future__ import annotations + +from pathlib import PurePath + +import structlog +from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator + +from cleveragents.domain.models.acms.backends import ( + GraphBackend, + GraphResult, + TextBackend, + TextResult, + VectorBackend, + VectorResult, +) + +logger = structlog.get_logger(__name__) + + +# --------------------------------------------------------------------------- +# Exceptions +# --------------------------------------------------------------------------- + + +class ScopeViolationError(Exception): + """Raised when a context request references resources outside scope. + + Attributes: + resource_ids: The out-of-scope resource identifiers. + scope_project_names: The project names that define the scope. + """ + + def __init__( + self, + message: str, + *, + resource_ids: tuple[str, ...] = (), + scope_project_names: tuple[str, ...] = (), + ) -> None: + super().__init__(message) + self.resource_ids = resource_ids + self.scope_project_names = scope_project_names + + +# --------------------------------------------------------------------------- +# ResourceScope +# --------------------------------------------------------------------------- + + +class ResourceScope(BaseModel): + """Resolved set of resources and projects visible to a plan. + + Immutable once created. Based on ``specification.md`` lines 42510--42525. + """ + + resource_ids: frozenset[str] = Field( + ..., + description="Resource ULIDs in scope (empty = no resources after filtering)", + ) + project_names: frozenset[str] = Field( + ..., + min_length=1, + description="Namespaced project names in scope", + ) + temporal_scope: str = Field( + default="current", + description="Temporal scope for UKO node resolution", + pattern=r"^(current|recent|all)$", + ) + include_resources: tuple[str, ...] = Field( + default=(), + max_length=256, + description="Resource allowlist (names or aliases). Empty = all.", + ) + exclude_resources: tuple[str, ...] = Field( + default=(), + max_length=256, + description="Resource denylist (names or aliases). Applied after allowlist.", + ) + include_paths: tuple[str, ...] = Field( + default=(), + max_length=256, + description="Path glob allowlist. Empty = all.", + ) + exclude_paths: tuple[str, ...] = Field( + default=(), + max_length=256, + description="Path glob denylist. Applied after allowlist.", + ) + + model_config = ConfigDict(frozen=True) + + def contains_resource(self, resource_id: str) -> bool: + """Return True if *resource_id* is within this scope. + + Args: + resource_id: A resource ULID to check. + + Returns: + Whether the resource is in scope. + """ + return resource_id in self.resource_ids + + def contains_project(self, project_name: str) -> bool: + """Return True if *project_name* is within this scope. + + Args: + project_name: A namespaced project name to check. + + Returns: + Whether the project is in scope. + """ + return project_name in self.project_names + + def matches_path(self, path: str) -> bool: + """Return True if *path* passes include/exclude path filters. + + Uses ``PurePath.full_match()`` (requires Python >=3.12; this + project requires >=3.13 per ``pyproject.toml``). + """ + pp = PurePath(path) + if self.include_paths and not any( + pp.full_match(pat) for pat in self.include_paths + ): + return False + return not ( + self.exclude_paths and any(pp.full_match(pat) for pat in self.exclude_paths) + ) + + +# --------------------------------------------------------------------------- +# ScopedBackendView +# --------------------------------------------------------------------------- + + +class ScopedBackendView(BaseModel): + """Wraps a backend to restrict queries to a resource scope. + + Operates at two levels: backend-level (injects ``scope`` into + queries) and fragment-level (``is_visible()`` filters by project + and resource). Based on ``specification.md`` lines 42548--42566. + """ + + allowed_projects: frozenset[str] = Field( + ..., + min_length=1, + description="Project names the actor is permitted to access", + ) + resource_ids: frozenset[str] = Field( + default=frozenset(), + description="Resource ULIDs in scope (empty = allow all for backward compat)", + ) + denied_resource_ids: frozenset[str] = Field( + default=frozenset(), + description="Resource ULIDs explicitly denied (denylist)", + ) + + _effective_scope_cached: frozenset[str] = PrivateAttr( + default=frozenset(), + ) + + model_config = ConfigDict( + frozen=True, + ) + + def model_post_init(self, __context: object) -> None: + """Pre-compute effective scope (model is frozen after init).""" + if not self.resource_ids: + self._effective_scope_cached = frozenset() + else: + self._effective_scope_cached = self.resource_ids - self.denied_resource_ids + + @field_validator("allowed_projects", mode="before") + @classmethod + def _coerce_allowed_projects( + cls: type[ScopedBackendView], + v: frozenset[str] | list[str] | set[str] | tuple[str, ...], + ) -> frozenset[str]: + """Accept list/set/tuple and convert to frozenset.""" + if isinstance(v, (list, set, tuple)): + return frozenset(v) + return v + + @field_validator("resource_ids", mode="before") + @classmethod + def _coerce_resource_ids( + cls: type[ScopedBackendView], + v: frozenset[str] | list[str] | set[str] | tuple[str, ...], + ) -> frozenset[str]: + """Accept list/set/tuple and convert to frozenset.""" + if isinstance(v, (list, set, tuple)): + return frozenset(v) + return v + + @field_validator("denied_resource_ids", mode="before") + @classmethod + def _coerce_denied_resource_ids( + cls: type[ScopedBackendView], + v: frozenset[str] | list[str] | set[str] | tuple[str, ...], + ) -> frozenset[str]: + """Accept list/set/tuple and convert to frozenset.""" + if isinstance(v, (list, set, tuple)): + return frozenset(v) + return v + + # -- Resource scope checks ----------------------------------------------- + + def is_resource_in_scope(self, resource_id: str) -> bool: + """Return True if *resource_id* is allowed by this view. + + A resource is in scope when: + 1. It is in ``resource_ids`` (or ``resource_ids`` is empty), AND + 2. It is NOT in ``denied_resource_ids``. + + Args: + resource_id: A resource ULID to check. + + Returns: + Whether the resource passes the scope filter. + """ + if resource_id in self.denied_resource_ids: + return False + if self.resource_ids: + return resource_id in self.resource_ids + return True + + def effective_scope(self) -> frozenset[str]: + """Return ``resource_ids - denied_resource_ids`` (cached at init). + + Empty frozenset when ``resource_ids`` is empty (project-only mode). + """ + return self._effective_scope_cached + + # -- Fragment-level filtering (backward compat with tier service) --------- + + def is_visible(self, fragment: object) -> bool: + """Return True if *fragment* belongs to an allowed project. + + Fragments with an empty ``project_name`` are **excluded** by + default to prevent information leakage. + + When ``resource_ids`` is populated (ULID-level filtering is + active), fragments **must** have a non-empty ``resource_id`` + that passes ``is_resource_in_scope()``. Fragments with an + empty ``resource_id`` are denied to enforce resource-level + isolation (deny-by-default). + + Args: + fragment: A ``TieredFragment`` or any object with + ``project_name`` and optionally ``resource_id`` attrs. + + Returns: + Whether the fragment is visible in this view. + """ + project_name: str = getattr(fragment, "project_name", "") + if not project_name: + return False + if project_name not in self.allowed_projects: + return False + + # Resource-level filtering when resource_ids are set + resource_id: str = getattr(fragment, "resource_id", "") + if self.resource_ids: + # Deny-by-default: fragments without a resource_id are + # excluded when resource-level filtering is active. + if not resource_id: + return False + return self.is_resource_in_scope(resource_id) + + return True + + # -- Backend-level query proxying ---------------------------------------- + + def _require_effective_scope(self) -> frozenset[str]: + """Return the effective scope or raise if all resources are denied. + + Returns an empty frozenset in project-only mode (no resource + filter). Raises ``ScopeViolationError`` when ``resource_ids`` + is populated but all entries are denied. + """ + scope = self.effective_scope() + if self.resource_ids and not scope: + logger.warning( + "scope.empty_after_denylist", + allowed_projects=sorted(self.allowed_projects), + ) + raise ScopeViolationError( + "All resources in scope are denied", + scope_project_names=tuple(sorted(self.allowed_projects)), + ) + return scope + + def search_text( + self, + backend: TextBackend, + query: str, + *, + max_results: int = 20, + ) -> list[TextResult]: + """Search a text backend with scope injected.""" + scope = self._require_effective_scope() + return backend.search(query, scope=scope, max_results=max_results) + + def search_vector( + self, + backend: VectorBackend, + embedding: list[float], + *, + top_k: int = 20, + ) -> list[VectorResult]: + """Search a vector backend with scope injected.""" + scope = self._require_effective_scope() + return backend.similarity_search(embedding, scope=scope, top_k=top_k) + + def search_graph( + self, + backend: GraphBackend, + query: str, + ) -> GraphResult: + """Execute a SPARQL query on a graph backend with scope injected.""" + scope = self._require_effective_scope() + return backend.sparql_query(query, scope=scope) + + @classmethod + def from_resource_scope(cls, scope: ResourceScope) -> ScopedBackendView: + """Create a ``ScopedBackendView`` from a ``ResourceScope``. + + .. note:: + + ``denied_resource_ids`` is **not** forwarded because + ``resolve_resource_scope()`` already subtracts denied IDs + from ``resource_ids`` before constructing the + ``ResourceScope``. The scope's ``resource_ids`` is + therefore already the effective (post-deny) set. + + Args: + scope: The resolved resource scope. + + Returns: + A new ``ScopedBackendView`` configured with the scope's + resource IDs and project names. + """ + return cls( + allowed_projects=scope.project_names, + resource_ids=scope.resource_ids, + ) + + +# --------------------------------------------------------------------------- +# ScopedBackendSet +# --------------------------------------------------------------------------- + + +class ScopedBackendSet(BaseModel): + """Bundle of scoped backend views for text, vector, and graph. + + Created by ``create_scoped_backend_set()`` during context assembly. + Based on ``specification.md`` line 43050. + """ + + view: ScopedBackendView = Field( + ..., + description="The scoped view enforcing resource isolation", + ) + text_backend: TextBackend | None = Field( + default=None, + description="Text search backend (None if unavailable)", + ) + vector_backend: VectorBackend | None = Field( + default=None, + description="Vector search backend (None if unavailable)", + ) + graph_backend: GraphBackend | None = Field( + default=None, + description="Graph query backend (None if unavailable)", + ) + + model_config = ConfigDict(arbitrary_types_allowed=True) + + def search_text( + self, + query: str, + *, + max_results: int = 20, + ) -> list[TextResult]: + """Search text backend with scope enforcement. + + Args: + query: Natural-language or keyword query. + max_results: Maximum results to return. + + Returns: + Scoped text results (empty list if no text backend). + """ + if self.text_backend is None: + return [] + return self.view.search_text(self.text_backend, query, max_results=max_results) + + def search_vector( + self, + embedding: list[float], + *, + top_k: int = 20, + ) -> list[VectorResult]: + """Search vector backend with scope enforcement. + + Args: + embedding: Query embedding vector. + top_k: Maximum results to return. + + Returns: + Scoped vector results. + """ + if self.vector_backend is None: + return [] + return self.view.search_vector(self.vector_backend, embedding, top_k=top_k) + + def search_graph( + self, + query: str, + ) -> GraphResult: + """Execute SPARQL query with scope enforcement. + + Args: + query: SPARQL query string. + + Returns: + Scoped graph results. + """ + if self.graph_backend is None: + return GraphResult() + return self.view.search_graph(self.graph_backend, query) + + +def create_scoped_backend_set( + scope: ResourceScope, + *, + text_backend: TextBackend | None = None, + vector_backend: VectorBackend | None = None, + graph_backend: GraphBackend | None = None, +) -> ScopedBackendSet: + """Create a ``ScopedBackendSet`` from a ``ResourceScope``.""" + view = ScopedBackendView.from_resource_scope(scope) + logger.info( + "scoped_backend_set.created", + project_names=sorted(scope.project_names), + resource_count=len(scope.resource_ids), + ) + return ScopedBackendSet( + view=view, + text_backend=text_backend, + vector_backend=vector_backend, + graph_backend=graph_backend, + ) + + +__all__: list[str] = [ + "ResourceScope", + "ScopeViolationError", + "ScopedBackendSet", + "ScopedBackendView", + "create_scoped_backend_set", +] diff --git a/src/cleveragents/domain/models/acms/tiers.py b/src/cleveragents/domain/models/acms/tiers.py index d7f6fe475..ca1792cce 100644 --- a/src/cleveragents/domain/models/acms/tiers.py +++ b/src/cleveragents/domain/models/acms/tiers.py @@ -255,35 +255,22 @@ class TierMetrics(BaseModel): # --------------------------------------------------------------------------- -# ScopedBackendView +# ScopedBackendView — re-exported from scoped_view module # --------------------------------------------------------------------------- +# The full implementation (backend wrapping, resource-level filtering, +# allowlist/denylist, scope validation) lives in ``scoped_view.py``. +# This re-export preserves backward compatibility with existing imports +# from this module. +from cleveragents.domain.models.acms.scoped_view import ( # noqa: E402 + ScopedBackendView, +) - -class ScopedBackendView(BaseModel): - """Project-scoped filter ensuring actors never see resources outside scope. - - Enforces project isolation by restricting fragment visibility to - only those fragments whose ``project_name`` matches one of the - allowed project names. - """ - - allowed_projects: list[str] = Field( - ..., - min_length=1, - description="Project names the actor is permitted to access", - ) - - def is_visible(self, fragment: TieredFragment) -> bool: - """Return True if *fragment* belongs to an allowed project. - - Fragments with an empty ``project_name`` are **excluded** by - default to prevent information leakage. - """ - if not fragment.project_name: - return False - return fragment.project_name in self.allowed_projects - - model_config = ConfigDict( - str_strip_whitespace=True, - validate_assignment=True, - ) +__all__: list[str] = [ + "ActorContextView", + "ActorRole", + "ContextTier", + "ScopedBackendView", + "TierBudget", + "TierMetrics", + "TieredFragment", +]