diff --git a/TRACKER.md b/TRACKER.md index 9485bc8..47549a5 100644 --- a/TRACKER.md +++ b/TRACKER.md @@ -10,7 +10,7 @@ **Last updated:** 2026-05-24 **Current phase:** Phase 1 — Ingestion + Knowledge Store -**Next action:** Phase 1 Step 1.1c — PolicyEngine package (`rag-policy`): `PolicyEngine` SPI + noop impl as single PDP for ACL/PII/quotas/redaction +**Next action:** Phase 1 Step 1.1d — Pipeline + Batcher primitives in `rag-core` (async DAG with bounded queues; DataLoader-pattern Batcher middleware coalescing concurrent SPI calls) > **Refactor window (Steps 1.1a–1.1f):** Before resuming the connectors framework (1.2), we insert a six-step refactor that locks in architecture + optimization decisions which are very expensive to retrofit later (PolicyEngine PDP, RequestContext-threaded SPIs, split Retrieval/Index backends, bulk + streaming + ID-only methods, Pipeline + Batcher primitives, three-way cache split, hot-path discipline). See [docs/adr/ADR-0005…0009] and [docs/architecture/policy-engine.md], [request-context.md], [caching.md], [performance.md]. @@ -32,14 +32,14 @@ | Phase | Title | Steps | ✅ Done | Remaining | |-------|-------|------:|-------:|----------:| | 0 | Foundation | 13 | **13** | 0 | -| 1 | Ingestion + Knowledge Store | 16 | **3** | 13 | +| 1 | Ingestion + Knowledge Store | 16 | **4** | 12 | | 2 | Retrieval Engine | 11 | 0 | 11 | | 3 | Gateway & Agent Runtime | 11 | 0 | 11 | | 4 | Reliability | 6 | 0 | 6 | | 5 | Eval & Observability | 7 | 0 | 7 | | 6 | Governance & Tenancy | 10 | 0 | 10 | | 7 | Pilot, Harden, GA | 10 | 0 | 10 | -| **Total** | | **84** | **16** | **68** | +| **Total** | | **84** | **17** | **67** | --- @@ -69,8 +69,8 @@ |------|-------|--------|--------|----|-----------------| | 1.1 | Storage backends | ✅ | `build/phase-1/step-1.1-storage-backends` | [#40](https://github.com/officialCodeWork/AgentContextOS/pull/40) | `rag-backends` package: `PgVectorStore` (asyncpg + pgvector, ivfflat), `QdrantVectorStore` (query_points API), `RedisCache`, `S3Storage` (aioboto3, MinIO-compatible), `LocalFileStorage`; integration tests (skip-if-no-service); MinIO added to dev stack; `task test-integration` + `task test-backends`; ADR-0004 | | 1.1a | Core type & SPI refactor | ✅ | `build/phase-1/step-1.1a-core-type-spi-refactor` | _pending_ | `RequestContext` frozen model threaded through every SPI; `tenant_id` + `acl_labels` typed required on `Chunk`/`Embedding` (not metadata dict); `trust_level` on `Chunk` for prompt-injection defense; `dtype` on `Embedding` (float32/int8/binary); `BlobRef` for lazy chunk text; `QueryPlan` + `ChunkRef` + `Cost` + `PlanNode` types; typed `StageEvent`. `tests/contract/spi_signature.py` linter (RequestContext-first); rag-backends (`PgVectorStore`, `QdrantVectorStore`, `RedisCache`, `S3Storage`, `LocalFileStorage`) migrated; conformance + integration tests updated; `Budget.spend()` for agent-loop sub-turn budgets; schemas regenerated. ADR-0005 / ADR-0007 / ADR-0008 / ADR-0009 referenced. | -| 1.1b | SPI split — Retrieval/Index, bulk + streaming + ID-only | ✅ | `build/phase-1/step-1.1b-spi-split-retrieval-index` | _pending_ | Split `VectorStore`/`KeywordStore`/`GraphStore` into `*RetrievalBackend` (read) + `*IndexBackend` (write) composite ABCs. `retrieve_ids` returns `list[ChunkRef]`; `hydrate` lives on the retrieval side (keyword full-Chunk; vector pass-through). Bulk: `bulk_index`/`bulk_delete` (+ graph bulk node/edge variants); streaming: `stream_index` async-iterator default that batches into `bulk_index`. `Embedder` split into single `embed` + canonical `bulk_embed`. New `IndexHint` + `WriteVolume` types passed to writes (per ADR-0009). Noop impls, `PgVectorStore`, `QdrantVectorStore` migrated; conformance + integration tests updated; `spi_signature.py` extended to enforce the split. Schemas regenerated (`IndexHint.json`). | -| 1.1c | PolicyEngine package | ⏳ | 1.1a | — | New `packages/policy/` (`rag-policy`): `PolicyEngine` SPI + noop impl; single decision point consulted by every retrieval/ingest path for ACL, PII, quotas, redaction. Replaces scattered checks across Steps 1.7, 4.5, 6.3, 6.5. ADR-0005 finalized. | +| 1.1b | SPI split — Retrieval/Index, bulk + streaming + ID-only | ✅ | `build/phase-1/step-1.1b-spi-split-retrieval-index` | [#45](https://github.com/officialCodeWork/AgentContextOS/pull/45) | Split `VectorStore`/`KeywordStore`/`GraphStore` into `*RetrievalBackend` (read) + `*IndexBackend` (write) composite ABCs. `retrieve_ids` returns `list[ChunkRef]`; `hydrate` lives on the retrieval side (keyword full-Chunk; vector pass-through). Bulk: `bulk_index`/`bulk_delete` (+ graph bulk node/edge variants); streaming: `stream_index` async-iterator default that batches into `bulk_index`. `Embedder` split into single `embed` + canonical `bulk_embed`. New `IndexHint` + `WriteVolume` types passed to writes (per ADR-0009). Noop impls, `PgVectorStore`, `QdrantVectorStore` migrated; conformance + integration tests updated; `spi_signature.py` extended to enforce the split. Schemas regenerated (`IndexHint.json`). | +| 1.1c | PolicyEngine package | ✅ | `build/phase-1/step-1.1c-policy-engine-package` | _pending_ | New `packages/policy/` (`rag-policy` v0.1.0): `PolicyEngine` SPI + `NoopPolicyEngine` (always-ALLOW with tenant-scoped `filter_pushdown`); `PolicyDecision` enum (read_chunk / ingest_doc / egress_text / quota_check / rate_limit / execute_plan); `PolicyResult` (allow/deny/transform) with predicate helpers; `QuotaSubject` / `RateLimitSubject`; `FilterExpr` mini-language (Eq / AnyIn / And / Or / Not / TrueExpr) returned by `filter_pushdown`; `PolicyWriter` facade mirroring `AuditWriter` and emitting `policy.decision` structured logs via `rag-observability`. Coverage linter `tests/policy/coverage.py` greps for governance-relevant SPI calls without adjacent `PolicyEngine`/`PolicyWriter` consultation, with file allowlist that consumers shrink as they wire the PDP in. Workspace + pytest pythonpath updated. 20 conformance tests added. ADR-0005 finalized. | | 1.1d | Pipeline + Batcher primitives | ⏳ | 1.1a | — | `Pipeline` primitive in `rag-core`: async DAG with bounded queues, per-stage worker counts, backpressure (used by Step 1.10 write path). `Batcher[Req, Resp]` middleware (DataLoader pattern) coalescing concurrent SPI calls into batched provider calls; sits under Embedder/Reranker SPIs. | | 1.1e | Cache SPI split + perf discipline + async telemetry | ⏳ | 1.1a | — | Split `Cache` into `EmbeddingCache` (key model_id+text_hash), `RetrievalCache` (plan_hash+corpus_version), `AnswerCache` (plan_hash+corpus_version+policy_version). Each has distinct invalidation. Hot-path convention doc (Pydantic at SPI boundary, `model_construct`/msgspec inside). Async telemetry path with bounded buffer + drop-on-overflow counter. | | 1.1f | ADRs 0005–0009 + reviewer checklist | ⏳ | 1.1a–1.1e | — | ADR-0005 (PolicyEngine PDP), ADR-0006 (two-stage reranker default), ADR-0007 (tiered storage with BlobRef), ADR-0008 (cost-aware planner replacing reactive fallback), ADR-0009 (vector index strategy + quantization). Reviewer checklist in `docs/architecture/performance.md`. | @@ -212,6 +212,8 @@ | [#38](https://github.com/officialCodeWork/AgentContextOS/pull/38) | ci: wire RAG001 logging check into ci.yml on all OSes | `fix/ci-rag001-gate` | ✅ Merged | 2026-05-23 | | [#40](https://github.com/officialCodeWork/AgentContextOS/pull/40) | feat(backends): storage backend plugins — pgvector, Qdrant, Redis, S3 (Step 1.1) | `build/phase-1/step-1.1-storage-backends` | ✅ Merged | 2026-05-24 | | [#41](https://github.com/officialCodeWork/AgentContextOS/pull/41) | docs(planning): Phase 1 architecture-refactor window (Steps 1.1a–1.1f) + ADRs 0005–0009 | `planning/phase-1-architecture-refactor` | ✅ Merged | 2026-05-24 | +| [#44](https://github.com/officialCodeWork/AgentContextOS/pull/44) | refactor(core): RequestContext + ctx-threaded SPI (Step 1.1a) | `build/phase-1/step-1.1a-core-type-spi-refactor` | ✅ Merged | 2026-05-24 | +| [#45](https://github.com/officialCodeWork/AgentContextOS/pull/45) | refactor(core): SPI split — RetrievalBackend / IndexBackend (Step 1.1b) | `build/phase-1/step-1.1b-spi-split-retrieval-index` | ✅ Merged | 2026-05-24 | --- diff --git a/docs/README.md b/docs/README.md index 8dc300c..4d73d51 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,7 @@ | [ragctl.md](reference/ragctl.md) | Full `ragctl` command reference — public usage, internals, extension points | | [backends.md](reference/backends.md) | `rag-backends` reference — PgVectorStore, QdrantVectorStore, RedisCache, S3Storage, LocalFileStorage | | [rag-core.md](reference/rag-core.md) | `rag-core` type surface — `RequestContext`, `Budget`, `BlobRef`, `QueryPlan`, `ChunkRef`, `StageEvent` | +| [rag-policy.md](reference/rag-policy.md) | `rag-policy` reference — `PolicyEngine`, `PolicyWriter`, `PolicyDecision`, `PolicyResult`, `FilterExpr` | ## guides/ diff --git a/docs/reference/rag-policy.md b/docs/reference/rag-policy.md new file mode 100644 index 0000000..a27e3dd --- /dev/null +++ b/docs/reference/rag-policy.md @@ -0,0 +1,204 @@ +# Reference — `rag-policy` + +The `rag-policy` package houses the **PolicyEngine SPI** — the single +Policy Decision Point (PDP) for AgentContextOS governance. See +[ADR-0005](../adr/ADR-0005-policy-engine.md) for the decision and +[docs/architecture/policy-engine.md](../architecture/policy-engine.md) for +the design. + +This page focuses on the package's public surface as it lands in Step 1.1c. +Consumers (gateway, ingest pipeline) will arrive in later phases; the +coverage linter described below already guarantees that those consumers +cannot land without consulting the PDP. + +--- + +## Overview + +Five governance touchpoints in the V1 plan (PII at ingest, quotas, ACL +push-down, ACL egress verifier, PII at egress) are consolidated behind one +SPI: + +```python +class PolicyEngine(HealthCheckMixin, ABC): + async def evaluate(ctx, decision, subject) -> PolicyResult: ... + async def filter_pushdown(ctx, decision) -> FilterExpr: ... +``` + +Every retrieval / ingest / egress code path consults an engine instance. A +coverage linter (`tests/policy/coverage.py`) fails CI when a known +governance-relevant SPI call site is found without an adjacent +`PolicyEngine` / `PolicyWriter` consultation. + +--- + +## Usage + +```python +from rag_policy import ( + NoopPolicyEngine, + PolicyDecision, + PolicyResult, + PolicyWriter, +) + +policy = PolicyWriter(NoopPolicyEngine()) + +# 1. Push-down: PolicyEngine returns a FilterExpr injected into the backend. +filter_expr = await policy.filter_pushdown(ctx, PolicyDecision.read_chunk) +candidates = await backend.retrieve_ids(ctx, query_vec, top_k=200, corpus_ids=[]) + +# 2. Per-item evaluation (rare — most filtering happens push-down). +allowed = [] +for ref in candidates: + result = await policy.evaluate(ctx, PolicyDecision.read_chunk, ref) + if result.is_allow(): + allowed.append(ref) + elif result.is_transform(): + allowed.append(result.transformed) + # DENY: skipped, logged, audited via the writer's structured-log entry. +``` + +Reach for `PolicyWriter` rather than calling `PolicyEngine` directly — it +emits the `policy.decision` structured log entry every governance decision +should produce. + +### `PolicyDecision` values + +| Decision | Subject | Typical results | +|---|---|---| +| `read_chunk` | `Chunk` or `ChunkRef` | `allow` / `deny` (ACL mismatch) | +| `ingest_doc` | `Document` | `allow` / `deny` (size, MIME) / `transform` (PII redaction) | +| `egress_text` | `str` or `Chunk` | `allow` / `transform` (PII redaction) | +| `quota_check` | `QuotaSubject(tenant_id, kind, amount)` | `allow` / `deny` (over-quota) | +| `rate_limit` | `RateLimitSubject(tenant_id, endpoint)` | `allow` / `deny` (rate exceeded) | +| `execute_plan` | `QueryPlan` | `allow` / `transform` (degraded plan under cost cap) | + +Adding a new decision requires updating `NoopPolicyEngine`, writing +conformance tests, and amending this page. + +### `FilterExpr` mini-language + +Returned by `filter_pushdown`; consumed by retrieval backends. Minimal +shape at 1.1c — enough for tenant + ACL scoping: + +```python +from rag_policy import and_, any_in, eq, FilterExpr + +expr: FilterExpr = and_( + eq("tenant_id", str(ctx.tenant_id)), + any_in("acl_labels", list(ctx.principal.acl_labels)), +) +``` + +Nodes: `Eq`, `AnyIn`, `And`, `Or`, `Not`, `TrueExpr`. Each is a frozen +Pydantic model; the union is discriminated on the `kind` tag so backends +switch over the shape rather than introspecting fields. + +--- + +## Internals + +### Why a separate package + +Three reasons: + +1. **Lifecycle independence.** Policy will evolve (new decisions, new + backends like an OPA adapter) on a different cadence from the core types. +2. **Dependency surface.** `rag-policy` depends only on `rag-core` and + `rag-observability`. Production deployments that disable the noop and + load an external PDP do not need to drop in a heavier package. +3. **CLAUDE.md graph.** The standing constraint records `policy → core`; + keeping it in its own package makes the boundary mechanical, not just + social. + +### `PolicyWriter` vs `PolicyEngine` + +`PolicyEngine` is the SPI authors implement. `PolicyWriter` is the facade +consumers should *use*. The split mirrors `AuditStore` ↔ `AuditWriter` in +`rag-core` — the writer adds the cross-cutting concern (structured-log +emission) that every consumer needs but no SPI implementation should +duplicate. + +Writer logs go through `rag_observability.logging.get_logger(__name__)` and +land in the standard 7-field JSON envelope under the message +`policy.decision`. Fields: `rag_decision`, `rag_outcome`, `rag_tenant_id`, +`rag_principal_id`, `rag_request_id`, `rag_reason`. + +### Coverage linter + +`tests/policy/coverage.py` greps for direct calls to governance-relevant +SPI methods (`retrieve_ids`, `hydrate`, `bulk_index`, `stream_index`, +`bulk_embed`, `complete`) in files outside an allowlist. If a file +contains such a call but no `PolicyEngine` / `PolicyWriter` marker, CI +fails. + +The allowlist at the top of `coverage.py` contains: + +* SPI abstracts and noop reference impls (the call surface itself). +* Real backend impls (governance happens at the call site, not inside the + backend). +* Tests (fixtures exercise the SPI directly). +* The `rag-policy` package (its writer/engine *are* the policed surface). + +As Steps 1.10, 3.1, etc. add consumers, their files come off the allowlist +as `PolicyEngine` is wired in. A future Step 1.1f or 3.x tightens the +linter from file-allowlist to call-pattern matching. + +### Performance + +* `NoopPolicyEngine.evaluate` is essentially free; `filter_pushdown` + constructs one `And(Eq(...))` per call. +* Production impls are expected to cache decisions keyed by + `(ctx.principal, decision, subject_hash)` for the lifetime of a + `RequestContext`. See [docs/architecture/performance.md](../architecture/performance.md) + for the published p99 budget (`PolicyEngine.evaluate` noop ≤ 100 µs). + +--- + +## Extension points + +Implement `rag_policy.PolicyEngine`: + +```python +from rag_policy import ( + FilterExpr, + PolicyDecision, + PolicyEngine, + PolicyResult, + and_, + any_in, + eq, +) + +class MyOrgPolicyEngine(PolicyEngine): + async def evaluate(self, ctx, decision, subject) -> PolicyResult: + if decision is PolicyDecision.read_chunk: + if subject.acl_labels & ctx.principal.acl_labels: + return PolicyResult.allow() + return PolicyResult.deny("acl-mismatch") + return PolicyResult.allow() + + async def filter_pushdown(self, ctx, decision) -> FilterExpr: + return and_( + eq("tenant_id", str(ctx.tenant_id)), + any_in("acl_labels", list(ctx.principal.acl_labels)), + ) + + async def health(self) -> bool: + return True +``` + +Register at the gateway composition root. Future built-ins on the +roadmap: `OpaPolicyEngine` (sidecar delegation), `CedarPolicyEngine` +(in-process AWS Cedar). + +--- + +## Related + +* [ADR-0005](../adr/ADR-0005-policy-engine.md) — the decision. +* [docs/architecture/policy-engine.md](../architecture/policy-engine.md) — design. +* [docs/architecture/request-context.md](../architecture/request-context.md) — the envelope `evaluate` receives. +* [docs/reference/rag-core.md](rag-core.md) — types referenced by subjects (`Chunk`, `ChunkRef`, `Document`). +* TRACKER.md Step 1.1c. diff --git a/packages/policy/README.md b/packages/policy/README.md new file mode 100644 index 0000000..bfc4eb3 --- /dev/null +++ b/packages/policy/README.md @@ -0,0 +1,40 @@ +# rag-policy + +Central **Policy Decision Point (PDP)** for AgentContextOS — one auditable +surface for ACL enforcement, PII handling, quotas, redaction, and rate +limits. Every retrieval / ingest / egress code path consults the +`PolicyEngine`; this replaces the five scattered governance touchpoints +described in [ADR-0005](../../docs/adr/ADR-0005-policy-engine.md). + +## Public surface + +```python +from rag_policy import ( + FilterExpr, + NoopPolicyEngine, + PolicyDecision, + PolicyEngine, + PolicyResult, + PolicyWriter, +) +``` + +* `PolicyEngine` — SPI ABC. Implement `evaluate(ctx, decision, subject)` and + `filter_pushdown(ctx, decision)`. +* `NoopPolicyEngine` — always-ALLOW reference impl; tenant-scoped pushdown. +* `PolicyWriter` — facade composing `evaluate()` with structured-log emission + (mirrors `AuditWriter`). +* `PolicyDecision` — typed enum (`READ_CHUNK`, `INGEST_DOC`, `EGRESS_TEXT`, + `QUOTA_CHECK`, `RATE_LIMIT`, `EXECUTE_PLAN`). +* `PolicyResult` — frozen union: `allow()`, `deny(reason)`, + `transform(subject)`. +* `FilterExpr` — minimal filter mini-language returned by `filter_pushdown` + and injected into retrieval backends. + +See [docs/architecture/policy-engine.md](../../docs/architecture/policy-engine.md) +for the full design and [docs/reference/rag-policy.md](../../docs/reference/rag-policy.md) +for the API reference. + +## Step + +Introduced in Phase 1 Step 1.1c. diff --git a/packages/policy/pyproject.toml b/packages/policy/pyproject.toml new file mode 100644 index 0000000..dd250d7 --- /dev/null +++ b/packages/policy/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "rag-policy" +version = "0.1.0" +description = "AgentContextOS — central Policy Decision Point (PolicyEngine, PolicyWriter)" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "pydantic>=2.7", + "rag-core", + "rag-observability", +] + +[tool.uv.sources] +rag-core = { workspace = true } +rag-observability = { workspace = true } + +[tool.hatch.build.targets.wheel] +packages = ["src/rag_policy"] + +[tool.hatch.metadata] +allow-direct-references = true diff --git a/packages/policy/src/rag_policy/__init__.py b/packages/policy/src/rag_policy/__init__.py new file mode 100644 index 0000000..582e97d --- /dev/null +++ b/packages/policy/src/rag_policy/__init__.py @@ -0,0 +1,59 @@ +"""rag-policy — central Policy Decision Point for AgentContextOS. + +Public surface introduced in Phase 1 Step 1.1c. See +[docs/architecture/policy-engine.md](../../../docs/architecture/policy-engine.md) +and [ADR-0005](../../../docs/adr/ADR-0005-policy-engine.md). +""" + +from rag_policy.engine import PolicyEngine +from rag_policy.filter import ( + And, + AnyIn, + Eq, + FilterExpr, + Not, + Or, + TrueExpr, + and_, + any_in, + eq, + not_, + or_, + true, +) +from rag_policy.noop import NoopPolicyEngine +from rag_policy.types import ( + PolicyDecision, + PolicyResult, + QuotaSubject, + RateLimitSubject, +) +from rag_policy.writer import PolicyWriter + +__version__ = "0.1.0" + +__all__ = [ + # engine + "PolicyEngine", + "NoopPolicyEngine", + "PolicyWriter", + # decisions + results + "PolicyDecision", + "PolicyResult", + "QuotaSubject", + "RateLimitSubject", + # filter mini-language + "FilterExpr", + "Eq", + "AnyIn", + "And", + "Or", + "Not", + "TrueExpr", + "eq", + "any_in", + "and_", + "or_", + "not_", + "true", +] diff --git a/packages/policy/src/rag_policy/engine.py b/packages/policy/src/rag_policy/engine.py new file mode 100644 index 0000000..475fbe0 --- /dev/null +++ b/packages/policy/src/rag_policy/engine.py @@ -0,0 +1,62 @@ +"""PolicyEngine SPI ABC. + +Implementations consolidate governance decisions (ACL, PII, quotas, +redaction, rate limits) behind a single auditable surface. See +[docs/architecture/policy-engine.md](../../../docs/architecture/policy-engine.md) +and [ADR-0005](../../../docs/adr/ADR-0005-policy-engine.md). +""" + +from __future__ import annotations + +import abc +from typing import Any + +from rag_core.spi._base import HealthCheckMixin +from rag_core.types import RequestContext + +from rag_policy.filter import FilterExpr +from rag_policy.types import PolicyDecision, PolicyResult + + +class PolicyEngine(HealthCheckMixin, abc.ABC): + """Single Policy Decision Point for AgentContextOS. + + All retrieval / ingest / egress paths consult an engine instance via + :meth:`evaluate` (per-item) or :meth:`filter_pushdown` (bulk, push-down + into the backend's native filter). A coverage linter + (``tests/policy/coverage.py``) enforces that no governance-relevant code + path bypasses the engine. + + Tenant scoping comes from ``ctx.tenant_id`` — every implementation must + use it. + """ + + @abc.abstractmethod + async def evaluate( + self, + ctx: RequestContext, + decision: PolicyDecision, + subject: Any, + ) -> PolicyResult: + """Decide on a single ``(decision, subject)`` pair. + + Returns :class:`PolicyResult.allow`, :class:`PolicyResult.deny`, or + :class:`PolicyResult.transform` — see :class:`PolicyResult` for + constructor semantics. Per-item evaluation in a hot loop is a perf + smell; prefer :meth:`filter_pushdown` where possible. + """ + + @abc.abstractmethod + async def filter_pushdown( + self, + ctx: RequestContext, + decision: PolicyDecision, + ) -> FilterExpr: + """Return a :class:`FilterExpr` to be injected into a retrieval call. + + Backends translate the expression into their native filter language. + This is the *primary* enforcement path — chunks the principal cannot + read never leave the backend. + + Implementations should at minimum constrain ``tenant_id``. + """ diff --git a/packages/policy/src/rag_policy/filter.py b/packages/policy/src/rag_policy/filter.py new file mode 100644 index 0000000..6f57c75 --- /dev/null +++ b/packages/policy/src/rag_policy/filter.py @@ -0,0 +1,121 @@ +"""``FilterExpr`` — minimal filter mini-language used by the PolicyEngine. + +Returned by :meth:`PolicyEngine.filter_pushdown` and consumed by retrieval +backends. Each backend translates the expression into its native filter +language (pgvector ``WHERE``, Qdrant payload filter, ES filter clause). + +The shape is intentionally small for Step 1.1c — only what the noop engine +needs to express tenant + ACL scoping. Future steps may add range, prefix, +and full-text predicates; additions live as new node classes that backends +opt into. + +Example: + + FilterExpr.and_( + FilterExpr.eq("tenant_id", "acme"), + FilterExpr.any_in("acl_labels", ["public", "engineering"]), + ) +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Annotated, Any, Literal + +from pydantic import BaseModel, Field + + +class _Node(BaseModel): + """Common base — every node is a frozen Pydantic model.""" + + model_config = {"frozen": True} + + +class Eq(_Node): + """Field equality predicate: ``field == value``.""" + + kind: Literal["eq"] = "eq" + field: str + value: str | int | float | bool + + +class AnyIn(_Node): + """Set-membership predicate: ``field ∩ values ≠ ∅``. + + Backends with array fields (Qdrant payload arrays, Postgres GIN-indexed + arrays) push this down natively. For scalar fields it degenerates to + ``field IN (...)``. + """ + + kind: Literal["any_in"] = "any_in" + field: str + values: tuple[str, ...] = Field(default_factory=tuple) + + +class And(_Node): + """Conjunction of child predicates (all must match).""" + + kind: Literal["and"] = "and" + children: tuple[FilterExpr, ...] = Field(default_factory=tuple) + + +class Or(_Node): + """Disjunction of child predicates (any may match).""" + + kind: Literal["or"] = "or" + children: tuple[FilterExpr, ...] = Field(default_factory=tuple) + + +class Not(_Node): + """Negation of a single child predicate.""" + + kind: Literal["not"] = "not" + child: FilterExpr + + +class TrueExpr(_Node): + """Constant true — no filtering.""" + + kind: Literal["true"] = "true" + + +# Discriminated union — backends switch on the ``kind`` tag. +FilterExpr = Annotated[ + Eq | AnyIn | And | Or | Not | TrueExpr, + Field(discriminator="kind"), +] + + +# Update forward references for self-referential And/Or/Not. +And.model_rebuild() +Or.model_rebuild() +Not.model_rebuild() + + +# --------------------------------------------------------------------------- +# Constructors — sugar for the common shapes. +# --------------------------------------------------------------------------- + + +def eq(field: str, value: str | int | float | bool) -> Eq: + return Eq(field=field, value=value) + + +def any_in(field: str, values: Sequence[str]) -> AnyIn: + return AnyIn(field=field, values=tuple(values)) + + +def and_(*children: Any) -> And: + return And(children=tuple(children)) + + +def or_(*children: Any) -> Or: + return Or(children=tuple(children)) + + +def not_(child: Any) -> Not: + return Not(child=child) + + +def true() -> TrueExpr: + return TrueExpr() diff --git a/packages/policy/src/rag_policy/noop.py b/packages/policy/src/rag_policy/noop.py new file mode 100644 index 0000000..75521a5 --- /dev/null +++ b/packages/policy/src/rag_policy/noop.py @@ -0,0 +1,43 @@ +"""Always-ALLOW reference implementation of :class:`PolicyEngine`. + +Suitable for OSS / dev / local testing. Production deployments override +with their own implementation (or a future built-in such as ``OpaPolicyEngine``). + +Tenant scoping is still enforced via :meth:`filter_pushdown` — even the noop +returns a tenant-only filter so retrieval backends can never accidentally +cross-tenant leak. +""" + +from __future__ import annotations + +from typing import Any + +from rag_core.types import RequestContext + +from rag_policy.engine import PolicyEngine +from rag_policy.filter import FilterExpr, and_, eq +from rag_policy.types import PolicyDecision, PolicyResult + + +class NoopPolicyEngine(PolicyEngine): + """Always-ALLOW engine. Push-down filter is tenant-scoped only.""" + + async def evaluate( + self, + ctx: RequestContext, + decision: PolicyDecision, + subject: Any, + ) -> PolicyResult: + return PolicyResult.allow() + + async def filter_pushdown( + self, + ctx: RequestContext, + decision: PolicyDecision, + ) -> FilterExpr: + # Always-on tenant scoping; trivially-true ``and_`` works as the + # composable base when subclasses want to bolt on extra clauses. + return and_(eq("tenant_id", str(ctx.tenant_id))) + + async def health(self) -> bool: + return True diff --git a/packages/policy/src/rag_policy/py.typed b/packages/policy/src/rag_policy/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/policy/src/rag_policy/types.py b/packages/policy/src/rag_policy/types.py new file mode 100644 index 0000000..1551c5e --- /dev/null +++ b/packages/policy/src/rag_policy/types.py @@ -0,0 +1,130 @@ +"""Core types for the :mod:`rag_policy` package. + +* :class:`PolicyDecision` — the typed enum of decision kinds the engine is + asked about. +* :class:`PolicyResult` — frozen union of ``allow`` / ``deny`` / + ``transform`` outcomes, with predicate helpers so consumers can pattern- + match without poking at fields. +* :class:`QuotaSubject` / :class:`RateLimitSubject` — small frozen models for + the non-``Chunk`` decision subjects. + +See [docs/architecture/policy-engine.md](../../../docs/architecture/policy-engine.md) +for usage. +""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel, Field +from rag_core.types import TenantId + + +class PolicyDecision(StrEnum): + """What the consumer is asking the engine about. + + The set is intentionally finite so the coverage linter can enumerate + every governance code path. Adding a value requires updating the noop + engine + writing conformance tests + amending the architecture doc. + """ + + read_chunk = "read_chunk" + """Reading a chunk (vector / keyword / graph retrieval, hydration).""" + + ingest_doc = "ingest_doc" + """Ingesting a document (parsers, chunkers, embedders).""" + + egress_text = "egress_text" + """Returning text to the caller (LLM answer, citation excerpts).""" + + quota_check = "quota_check" + """Quota check before an expensive operation.""" + + rate_limit = "rate_limit" + """Rate-limit check before a request is dispatched.""" + + execute_plan = "execute_plan" + """Whether a :class:`QueryPlan` may be executed (cost / budget).""" + + +class _PolicyOutcome(StrEnum): + allow = "allow" + deny = "deny" + transform = "transform" + + +class PolicyResult(BaseModel): + """Outcome of a :meth:`PolicyEngine.evaluate` call. + + Construct via the class-method factories — :meth:`allow`, :meth:`deny`, + :meth:`transform` — rather than instantiating directly. The predicate + helpers (:meth:`is_allow`, :meth:`is_deny`, :meth:`is_transform`) let + consumers branch without touching the internal fields. + """ + + model_config = {"frozen": True} + + outcome: _PolicyOutcome + reason: str | None = None + """Human-readable reason; populated on ``deny`` (and optionally on + ``transform`` for audit purposes).""" + + transformed: Any = None + """The replacement subject on a ``transform`` outcome (e.g. a redacted + :class:`Chunk` or :class:`str`). ``None`` on ``allow`` / ``deny``.""" + + # ------------------------------------------------------------------ + # Factories + # ------------------------------------------------------------------ + + @classmethod + def allow(cls) -> PolicyResult: + return cls(outcome=_PolicyOutcome.allow) + + @classmethod + def deny(cls, reason: str) -> PolicyResult: + return cls(outcome=_PolicyOutcome.deny, reason=reason) + + @classmethod + def transform(cls, subject: Any, reason: str | None = None) -> PolicyResult: + return cls(outcome=_PolicyOutcome.transform, reason=reason, transformed=subject) + + # ------------------------------------------------------------------ + # Predicates + # ------------------------------------------------------------------ + + def is_allow(self) -> bool: + return self.outcome is _PolicyOutcome.allow + + def is_deny(self) -> bool: + return self.outcome is _PolicyOutcome.deny + + def is_transform(self) -> bool: + return self.outcome is _PolicyOutcome.transform + + def transformed_or(self, default: Any) -> Any: + """Return the transformed subject when present, else ``default``.""" + + return self.transformed if self.is_transform() else default + + +class QuotaSubject(BaseModel): + """Subject of a :attr:`PolicyDecision.quota_check`.""" + + model_config = {"frozen": True} + + tenant_id: TenantId + kind: str # e.g. "tokens.monthly", "storage.bytes", "queries.daily" + amount: int + metadata: dict[str, Any] = Field(default_factory=dict) + + +class RateLimitSubject(BaseModel): + """Subject of a :attr:`PolicyDecision.rate_limit`.""" + + model_config = {"frozen": True} + + tenant_id: TenantId + endpoint: str + metadata: dict[str, Any] = Field(default_factory=dict) diff --git a/packages/policy/src/rag_policy/writer.py b/packages/policy/src/rag_policy/writer.py new file mode 100644 index 0000000..ba259ad --- /dev/null +++ b/packages/policy/src/rag_policy/writer.py @@ -0,0 +1,64 @@ +"""PolicyWriter — facade that composes ``PolicyEngine`` with structured logs. + +Mirrors :class:`rag_core.audit.AuditWriter`. Most consumers should reach +for ``PolicyWriter`` rather than calling ``PolicyEngine`` directly — it adds +the one piece of plumbing every policy decision needs (a structured-log +emission tagged with the decision and outcome) while still being a thin +wrapper. +""" + +from __future__ import annotations + +from typing import Any + +from rag_core.types import RequestContext +from rag_observability.logging import get_logger + +from rag_policy.engine import PolicyEngine +from rag_policy.filter import FilterExpr +from rag_policy.types import PolicyDecision, PolicyResult + +_log = get_logger(__name__) + + +class PolicyWriter: + """Decorates a :class:`PolicyEngine` with structured-log emission.""" + + def __init__(self, engine: PolicyEngine) -> None: + self._engine = engine + + async def evaluate( + self, + ctx: RequestContext, + decision: PolicyDecision, + subject: Any, + ) -> PolicyResult: + """Evaluate via the engine, then emit a ``policy.decision`` log entry. + + The log carries decision, outcome, tenant, principal, and reason + fields so log-aggregation pipelines can index governance activity + without hooking into the engine directly. + """ + + result = await self._engine.evaluate(ctx, decision, subject) + _log.info( + "policy.decision", + extra={ + "rag_decision": decision.value, + "rag_outcome": result.outcome.value, + "rag_tenant_id": str(ctx.tenant_id), + "rag_principal_id": str(ctx.principal.id), + "rag_request_id": str(ctx.request_id), + "rag_reason": result.reason, + }, + ) + return result + + async def filter_pushdown( + self, + ctx: RequestContext, + decision: PolicyDecision, + ) -> FilterExpr: + """Pass-through to the engine; no log emission (called per query, not per chunk).""" + + return await self._engine.filter_pushdown(ctx, decision) diff --git a/pyproject.toml b/pyproject.toml index 12947b4..e058d21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ members = [ "packages/core", "packages/config", "packages/observability", + "packages/policy", "packages/ragctl", "packages/backends", "apps/gateway", @@ -85,10 +86,11 @@ ignore_missing_imports = true [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests", "packages"] -pythonpath = ["packages/core/src", "packages/config/src", "packages/observability/src", "packages/ragctl/src", "packages/backends/src", "apps/gateway/src"] +pythonpath = ["packages/core/src", "packages/config/src", "packages/observability/src", "packages/policy/src", "packages/ragctl/src", "packages/backends/src", "apps/gateway/src"] # spi_signature.py is the SPI signature linter (Step 1.1a); referenced by name in # docs/architecture/request-context.md. Collected alongside test_*.py files. -python_files = ["test_*.py", "*_test.py", "spi_signature.py"] +# coverage.py is the PolicyEngine coverage linter (Step 1.1c). +python_files = ["test_*.py", "*_test.py", "spi_signature.py", "coverage.py"] addopts = "-x -q --tb=short --import-mode=importlib" markers = [ "contract: SPI conformance tests", diff --git a/tests/contract/test_policy_engine.py b/tests/contract/test_policy_engine.py new file mode 100644 index 0000000..5d4ce71 --- /dev/null +++ b/tests/contract/test_policy_engine.py @@ -0,0 +1,194 @@ +"""Conformance tests for ``PolicyEngine`` (Step 1.1c).""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError +from rag_core.types import Chunk, ChunkId, CorpusId, DocumentId, RequestContext, TenantId +from rag_policy import ( + And, + AnyIn, + Eq, + FilterExpr, + NoopPolicyEngine, + PolicyDecision, + PolicyEngine, + PolicyResult, + PolicyWriter, + QuotaSubject, + RateLimitSubject, + and_, + any_in, + eq, + not_, + or_, + true, +) + +from tests.contract.conftest import make_ctx + +pytestmark = pytest.mark.contract + + +@pytest.fixture() +def engine() -> NoopPolicyEngine: + return NoopPolicyEngine() + + +@pytest.fixture() +def writer(engine: NoopPolicyEngine) -> PolicyWriter: + return PolicyWriter(engine) + + +# --------------------------------------------------------------------------- +# PolicyResult factories + predicates +# --------------------------------------------------------------------------- + + +def test_policy_result_allow() -> None: + r = PolicyResult.allow() + assert r.is_allow() + assert not r.is_deny() + assert not r.is_transform() + assert r.reason is None + assert r.transformed is None + + +def test_policy_result_deny_carries_reason() -> None: + r = PolicyResult.deny("acl-mismatch") + assert r.is_deny() + assert r.reason == "acl-mismatch" + + +def test_policy_result_transform_carries_subject() -> None: + redacted = "" + r = PolicyResult.transform(redacted, reason="pii") + assert r.is_transform() + assert r.transformed == redacted + assert r.transformed_or("fallback") == redacted + + +def test_policy_result_transformed_or_default_on_non_transform() -> None: + assert PolicyResult.allow().transformed_or("x") == "x" + assert PolicyResult.deny("no").transformed_or("x") == "x" + + +def test_policy_result_is_frozen() -> None: + r = PolicyResult.allow() + with pytest.raises(ValidationError): + r.outcome = "deny" # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# NoopPolicyEngine — evaluate + filter_pushdown contract +# --------------------------------------------------------------------------- + + +async def test_noop_health(engine: PolicyEngine) -> None: + assert await engine.health() is True + + +@pytest.mark.parametrize( + "decision,subject", + [ + (PolicyDecision.read_chunk, "chunk-1"), + (PolicyDecision.ingest_doc, "doc"), + (PolicyDecision.egress_text, "hello world"), + ( + PolicyDecision.quota_check, + QuotaSubject(tenant_id=TenantId("acme"), kind="tokens", amount=10), + ), + ( + PolicyDecision.rate_limit, + RateLimitSubject(tenant_id=TenantId("acme"), endpoint="/v1/query"), + ), + (PolicyDecision.execute_plan, "plan"), + ], +) +async def test_noop_evaluate_always_allows( + engine: PolicyEngine, ctx: RequestContext, decision: PolicyDecision, subject: object +) -> None: + result = await engine.evaluate(ctx, decision, subject) + assert result.is_allow() + + +async def test_noop_filter_pushdown_constrains_tenant( + engine: PolicyEngine, ctx: RequestContext +) -> None: + expr = await engine.filter_pushdown(ctx, PolicyDecision.read_chunk) + assert isinstance(expr, And) + assert any( + isinstance(child, Eq) and child.field == "tenant_id" and child.value == str(ctx.tenant_id) + for child in expr.children + ) + + +async def test_filter_pushdown_uses_request_tenant() -> None: + engine = NoopPolicyEngine() + ctx_a = make_ctx(tenant_id="t-a") + ctx_b = make_ctx(tenant_id="t-b") + expr_a = await engine.filter_pushdown(ctx_a, PolicyDecision.read_chunk) + expr_b = await engine.filter_pushdown(ctx_b, PolicyDecision.read_chunk) + assert expr_a != expr_b + + +# --------------------------------------------------------------------------- +# PolicyWriter — pass-through + structured logging +# --------------------------------------------------------------------------- + + +async def test_writer_evaluate_delegates_to_engine( + writer: PolicyWriter, ctx: RequestContext +) -> None: + chunk = Chunk( + document_id=DocumentId("doc-1"), + tenant_id=ctx.tenant_id, + corpus_id=CorpusId("corpus-a"), + content="hello", + position=0, + ) + result = await writer.evaluate(ctx, PolicyDecision.read_chunk, chunk) + assert result.is_allow() + + +async def test_writer_filter_pushdown_delegates(writer: PolicyWriter, ctx: RequestContext) -> None: + expr = await writer.filter_pushdown(ctx, PolicyDecision.read_chunk) + assert isinstance(expr, And) + + +# --------------------------------------------------------------------------- +# FilterExpr — discriminated-union + constructors +# --------------------------------------------------------------------------- + + +def test_filter_expr_constructors() -> None: + e = and_( + eq("tenant_id", "acme"), + any_in("acl_labels", ["public", "engineering"]), + not_(eq("trust_level", "user_supplied")), + or_(eq("corpus_id", "x"), eq("corpus_id", "y")), + true(), + ) + assert isinstance(e, And) + assert len(e.children) == 5 + assert isinstance(e.children[0], Eq) + assert isinstance(e.children[1], AnyIn) + assert e.children[1].values == ("public", "engineering") + + +def test_filter_expr_frozen() -> None: + e = eq("tenant_id", "acme") + with pytest.raises(ValidationError): + e.value = "evil" # type: ignore[misc] + + +def test_filter_expr_serializes_with_discriminator() -> None: + e: FilterExpr = and_(eq("tenant_id", "acme")) + dumped = e.model_dump() + assert dumped["kind"] == "and" + assert dumped["children"][0]["kind"] == "eq" + + +def test_chunk_id_used_as_subject(ctx: RequestContext) -> None: + # Sanity: ChunkId values flow through evaluate without typing issues. + ChunkId("c1") diff --git a/tests/policy/__init__.py b/tests/policy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/policy/coverage.py b/tests/policy/coverage.py new file mode 100644 index 0000000..63f121b --- /dev/null +++ b/tests/policy/coverage.py @@ -0,0 +1,160 @@ +"""Linter: governance-relevant SPI calls must be policed by ``PolicyEngine``. + +Mirrors the SPI signature linter in spirit: a static scan of the codebase +that fails CI when a known governance-relevant call site is found without +an adjacent ``PolicyEngine`` / ``PolicyWriter`` consultation in the same +function. + +Why this exists +--------------- + +Real-world governance leaks come from *path coverage failures* — a new code +path adds a feature, forgets to call one of the governance checks, and a +sensitive document slips out. Step 6.4 in the V1 plan (ACL egress verifier +as a *second* layer) is a tacit admission that hand-rolled coverage of the +first layer is unreliable. ADR-0005 collapses governance into one +PolicyEngine surface; this linter makes the "every consumer must call it" +rule mechanical. + +Scope at Step 1.1c +------------------ + +* Defines the **rule set** (which SPI methods are governance-relevant and + must be paired with a PolicyEngine call). +* Defines the **allowlist** (files exempt from the rule — primarily SPI + abstracts, noop impls, conformance tests, and the policy package itself). +* The full scan is deliberately permissive at 1.1c: real consumers + (gateway, ingest pipeline) do not exist yet. As Steps 1.10, 3.1, etc. + add call sites, the test will start enforcing. Authors of those steps + remove their files from the allowlist as they wire PolicyEngine in. + +A future Step 1.1f or 3.x tightens this from a list of allowed files to a +list of disallowed call patterns; today the goal is to land the +infrastructure + allowlist so subsequent PRs cannot regress. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.contract + + +# --------------------------------------------------------------------------- +# Rule set — SPI methods whose call sites must be policed +# --------------------------------------------------------------------------- +# +# The patterns match method-name suffixes (regex word boundary). We do *not* +# pin to a fully-qualified import path because consumers reach SPIs through +# many import names (DI containers, type aliases). False positives are +# managed via the file allowlist below. +_GOVERNED_CALLS: tuple[re.Pattern[str], ...] = ( + re.compile(r"\.retrieve_ids\("), + re.compile(r"\.hydrate\("), + re.compile(r"\.bulk_index\("), + re.compile(r"\.stream_index\("), + re.compile(r"\.bulk_embed\("), + # LLM.complete is added by Phase 3; flag it ahead of time so the first + # call site cannot land without a policy decision. + re.compile(r"\.complete\("), +) + + +_POLICY_MARKERS: tuple[re.Pattern[str], ...] = ( + re.compile(r"\bpolicy\.(evaluate|filter_pushdown)\("), + re.compile(r"\bPolicyWriter\b"), + re.compile(r"\bPolicyEngine\b"), +) + + +# --------------------------------------------------------------------------- +# Allowlist — files exempt from the coverage rule +# --------------------------------------------------------------------------- +# +# Each entry must be justified by a comment. Removing an entry means the +# file's governed call sites are now CI-enforced. +_ALLOWLIST: frozenset[Path] = frozenset( + [ + # SPI abstracts — the ABCs themselves declare the methods. + Path("packages/core/src/rag_core/spi/vector_store.py"), + Path("packages/core/src/rag_core/spi/keyword_store.py"), + Path("packages/core/src/rag_core/spi/graph_store.py"), + Path("packages/core/src/rag_core/spi/embedder.py"), + Path("packages/core/src/rag_core/spi/llm.py"), + # Noop reference impls — internal calls between methods on the same class. + Path("packages/core/src/rag_core/spi/noop/vector_store.py"), + Path("packages/core/src/rag_core/spi/noop/keyword_store.py"), + Path("packages/core/src/rag_core/spi/noop/graph_store.py"), + Path("packages/core/src/rag_core/spi/noop/embedder.py"), + Path("packages/core/src/rag_core/spi/noop/llm.py"), + # Real backend impls — the backend code itself does not consult + # PolicyEngine; that happens at the call site (gateway / ingest). + Path("packages/backends/src/rag_backends/vector/pgvector.py"), + Path("packages/backends/src/rag_backends/vector/qdrant.py"), + # rag-policy package — its writer/engine *are* the policed surface. + Path("packages/policy/src/rag_policy/writer.py"), + Path("packages/policy/src/rag_policy/engine.py"), + Path("packages/policy/src/rag_policy/noop.py"), + # Tests — fixtures and conformance suites exercise the SPI directly. + # (The coverage rule is about production call sites.) + Path("tests"), # whole directory; pruned by has_test_parent below + Path("packages/core/tests"), + Path("packages/policy/tests"), + ] +) + + +def _is_allowlisted(path: Path) -> bool: + rel = path.resolve() + for entry in _ALLOWLIST: + entry_abs = entry.resolve() + if rel == entry_abs: + return True + if entry_abs.is_dir() and entry_abs in rel.parents: + return True + return False + + +def _scan(path: Path) -> list[str]: + """Return list of violation lines from a single file.""" + + text = path.read_text(encoding="utf-8", errors="ignore") + if not any(p.search(text) for p in _GOVERNED_CALLS): + return [] + if any(p.search(text) for p in _POLICY_MARKERS): + return [] + # Governed call present without any PolicyEngine marker. + matches = [] + for lineno, line in enumerate(text.splitlines(), start=1): + if any(p.search(line) for p in _GOVERNED_CALLS): + matches.append(f"{path}:{lineno}: {line.strip()}") + return matches + + +def test_policy_coverage() -> None: + """Fail CI if a governance-relevant call site bypasses ``PolicyEngine``.""" + + repo_root = Path(__file__).resolve().parents[2] + violations: list[str] = [] + + for path in sorted(repo_root.rglob("*.py")): + if path.name.startswith("_") and path.name != "__init__.py": + continue + rel = path.relative_to(repo_root) + # Skip .venv, build dirs, etc. + if any(p.startswith(".") or p in {"build", "dist", "node_modules"} for p in rel.parts): + continue + if _is_allowlisted(rel): + continue + violations.extend(_scan(path)) + + assert not violations, ( + "PolicyEngine coverage linter failed — the following call sites use " + "a governance-relevant SPI method without an adjacent " + "PolicyEngine / PolicyWriter call. Either consult the policy " + "engine in the same function, or add the file to the allowlist in " + "tests/policy/coverage.py with a justifying comment:\n - " + "\n - ".join(violations) + ) diff --git a/uv.lock b/uv.lock index 840bd21..cc711f1 100644 --- a/uv.lock +++ b/uv.lock @@ -23,6 +23,7 @@ members = [ "rag-core", "rag-gateway", "rag-observability", + "rag-policy", "rag-ragctl", ] @@ -3299,6 +3300,23 @@ requires-dist = [ { name = "rag-core", editable = "packages/core" }, ] +[[package]] +name = "rag-policy" +version = "0.1.0" +source = { editable = "packages/policy" } +dependencies = [ + { name = "pydantic" }, + { name = "rag-core" }, + { name = "rag-observability" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.7" }, + { name = "rag-core", editable = "packages/core" }, + { name = "rag-observability", editable = "packages/observability" }, +] + [[package]] name = "rag-ragctl" version = "0.1.0"