-
Notifications
You must be signed in to change notification settings - Fork 0
runtime: add RLS-scoped pgvector CandidateIndex #106
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
60e2616
1babad4
a21642f
f7aea67
2760ce3
f5914f9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| """PostgreSQL ANN candidate discovery using one retained Runtime transaction.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from engine.runtime.content_io import CandidateIndexUnavailable | ||
| from engine.runtime.contracts import Acquire | ||
| from engine.runtime.evidence import CandidateRef | ||
| from engine.runtime.materialized import ( | ||
| MaterializedProjectionSession, | ||
| _discover_materialized_vector, | ||
| ) | ||
| from engine.supply import ( | ||
| CONTEXT_FRAGMENT_EMBEDDING_DIMENSION, | ||
| EmbeddingProfile, | ||
| EmbeddingProvider, | ||
| EmbeddingProviderUnavailable, | ||
| validate_embedding_batch, | ||
| ) | ||
|
|
||
| DEFAULT_VECTOR_CANDIDATE_LIMIT = 16 | ||
| MAX_VECTOR_CANDIDATE_LIMIT = 64 | ||
|
|
||
|
|
||
| class VectorCandidateIndexUnavailable(CandidateIndexUnavailable): | ||
| """Content-free failure of query embedding or ANN candidate discovery.""" | ||
|
|
||
|
|
||
| class PostgreSQLVectorCandidateIndex: | ||
| """Return bounded content-free pgvector candidates for one Acquire.""" | ||
|
|
||
| __slots__ = ("_embedding_profile", "_embedding_provider", "_limit") | ||
|
|
||
| def __init__( | ||
| self, | ||
| embedding_provider: EmbeddingProvider, | ||
| *, | ||
| limit: int = DEFAULT_VECTOR_CANDIDATE_LIMIT, | ||
| ) -> None: | ||
| try: | ||
| embedding_profile = embedding_provider.profile | ||
| except (AttributeError, TypeError, ValueError): | ||
| raise TypeError( | ||
| "Vector candidate index requires an embedding provider" | ||
| ) from None | ||
| if type(embedding_profile) is not EmbeddingProfile: | ||
| raise TypeError("Vector candidate index requires an embedding provider") | ||
| if embedding_profile.dimension != CONTEXT_FRAGMENT_EMBEDDING_DIMENSION: | ||
| raise ValueError("Vector candidate dimension does not match storage") | ||
| if type(limit) is not int or not 1 <= limit <= MAX_VECTOR_CANDIDATE_LIMIT: | ||
| raise ValueError("Vector candidate limit is not available") | ||
| self._embedding_provider = embedding_provider | ||
| self._embedding_profile = embedding_profile | ||
| self._limit = limit | ||
|
|
||
| def discover( | ||
| self, | ||
| request: Acquire, | ||
| projection_session: MaterializedProjectionSession, | ||
| ) -> tuple[CandidateRef, ...]: | ||
| if type(request) is not Acquire: | ||
| raise TypeError("Vector candidate discovery requires Acquire") | ||
| try: | ||
| query_embedding = validate_embedding_batch( | ||
| (request.need.query,), | ||
| self._embedding_provider.embed((request.need.query,)), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an external embedding provider is configured, this line performs a real network/provider call after Runtime computes the effective PackageBudget, but the discovery interface neither receives nor returns budget usage and the resulting Package and ContextRun still record provider calls, cost, and elapsed time as zero. A slow or chargeable embedding request can therefore exceed AGENTS.md reference: AGENTS.md:L93-L93 Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a valid activation concern but intentionally not a #101 product-lane Kernel/Protocol change. Issue #101 explicitly requires no CandidateIndex Protocol, Kernel, or construction changes and keeps the served composition NOT_ACTIVE. ADR-0067 now makes this a hard #102 activation gate: an external query-embedding provider cannot be served until Runtime propagates, enforces, and records provider-call/cost/elapsed usage; the follow-up may instead explicitly compose only the deterministic network-free twin classified as zero external calls/cost, and external configuration must fail closed. Sara independently reviewed this deferral boundary; #102 is the next kernel-lane activation issue. |
||
| self._embedding_profile, | ||
|
Comment on lines
+63
to
+66
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This is the first query-time embedding and vector CandidateIndex implementation, but the commit adds no decision record even though ADR-0066's revisit trigger ( AGENTS.md reference: AGENTS.md:L119-L123 Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed at 1babad4 with ADR-0067. It records query/publication profile compatibility, retained UserActor transaction ownership, filtered HNSW settings, content-free bounds, provider failure behavior, rank-independent assembly constraints, served NOT_ACTIVE scope, and exact-search/EXPLAIN/real-corpus revisit evidence. |
||
| )[0] | ||
| return _discover_materialized_vector( | ||
| projection_session, | ||
| query_embedding, | ||
| self._limit, | ||
| source_refs=( | ||
|
Comment on lines
+68
to
+72
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
stometa, when trusted policy operands reduce AGENTS.md reference: AGENTS.md:L22-L26 Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Valid activation concern, recorded as a hard boundary in ADR-0067 at 2760ce3. Issue #101 explicitly requires no CandidateIndex Protocol, Kernel, or engine/runtime/construction.py changes and keeps the served composition NOT_ACTIVE. The immediate #102 kernel-lane activation must propagate the Kernel-computed EffectiveScope to discovery as a removal-only prefilter before ANN LIMIT, while every result still receives exact Kernel reauthorization; until then the served vector carrier must fail configuration/remain NOT_ACTIVE. Caller narrowing fixed the product-lane seam case here but is explicitly documented as insufficient for served activation. |
||
| request.narrowing.source_refs | ||
| if request.narrowing is not None | ||
| else None | ||
| ), | ||
| resource_refs=( | ||
| request.narrowing.resource_refs | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
stometa, when Useful? React with 👍 / 👎. |
||
| if request.narrowing is not None | ||
| else None | ||
| ), | ||
| ) | ||
| except EmbeddingProviderUnavailable: | ||
| raise VectorCandidateIndexUnavailable( | ||
| "Vector candidate discovery is unavailable" | ||
|
Comment on lines
+83
to
+85
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the configured embedding provider raises Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed at 1babad4: VectorCandidateIndexUnavailable now derives from a generic CandidateIndexUnavailable seam failure, and HTTP maps that category to the existing content-free 503 service_unavailable contract. A real PG/HTTP integration test proves provider diagnostics and candidate lineage do not escape. |
||
| ) from None | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| --- | ||
| name: adr-0067-discover-vector-candidates-in-the-current-runtime-transaction | ||
| version: "1.0.0" | ||
| description: > | ||
| Embed Acquire queries with the publication-compatible profile and discover | ||
| bounded content-free pgvector candidates in the retained UserActor transaction. | ||
| --- | ||
|
|
||
| # 0067. Discover vector candidates in the current Runtime transaction | ||
|
|
||
| - Status: accepted | ||
| - Date: 2026-07-27 | ||
| - Refines: ADR-0009, ADR-0025, ADR-0066 | ||
|
|
||
| ## Context | ||
|
|
||
| ADR-0066 published fixed-dimension Fragment vectors and reserved query-time | ||
| embedding, compatibility, and filtered-ANN evidence for the first vector | ||
| CandidateIndex decision. Runtime already owns one current UserActor transaction | ||
| whose transaction-local settings drive FORCE RLS and whose nominal projection | ||
| session remains live through the sealed AuthorizationKernel. Opening a second | ||
| database session for retrieval would detach discovery from that trusted context. | ||
|
|
||
| HNSW applies post-scan filters, including RLS and active-resource predicates. | ||
| With selective Organization, Membership, source, or policy filters, a bounded | ||
| one-pass approximate scan can underfill even when visible rows exist. Candidate | ||
| ordering cannot be authorization input, and ADR-0025 deliberately makes current | ||
| deterministic assembly independent of untrusted candidate order. | ||
|
|
||
| ## Decision | ||
|
|
||
| `PostgreSQLVectorCandidateIndex` uses the explicit `EmbeddingProvider` seam and | ||
| requires exact dimensional compatibility with the stored 384-dimensional | ||
| vectors. Each | ||
| `Acquire` embeds only its exact query, validates the provider response through | ||
| the same float32 contract used by publication, and collapses provider failure | ||
| to one content-free availability category. HTTP maps that category to the | ||
| existing generic service-unavailable response. | ||
|
|
||
| The index calls a narrow vector-discovery operation on the current | ||
| `MaterializedProjectionSession`. Its PostgreSQL port executes on the retained | ||
| UserActor connection; it opens no connection and creates no transaction. The | ||
| query selects only Organization/source/resource/revision/fragment lineage, | ||
| orders by cosine distance with `<=>`, and applies an exact 1–64 limit. Body, | ||
| path, title, provider metadata, and distance score never leave persistence. | ||
| Every result remains an untrusted `CandidateRef` and still passes the unchanged | ||
| AuthorizationKernel before projection. | ||
|
|
||
| Caller-supplied source and resource narrowing is passed through only as an ANN | ||
| pre-filter before the candidate limit. It can remove candidates but cannot | ||
| grant access; the Kernel independently recomputes the authoritative | ||
| `EffectiveScope` intersection and exactly reauthorizes every surviving | ||
| `CandidateRef`. | ||
|
|
||
| Before the ANN query, the port sets pgvector `hnsw.iterative_scan` to strict | ||
| order and a bounded maximum scan-tuple ceiling with transaction-local settings. | ||
| This allows pgvector to continue scanning after RLS and live-resource filters | ||
| remove approximate neighbors while retaining bounded work and exact distance | ||
| order at the index seam. The pinned harness uses the pgvector version recorded | ||
| by the served database topology. | ||
|
|
||
| Candidate rank is intentionally not preserved through the current Kernel. | ||
| ADR-0025 makes assembly independent of candidate order so index rank cannot | ||
| affect access or the deterministic Package. This slice therefore provides | ||
| bounded vector recall, not relevance-ranked budget selection. Changing that | ||
| behavior requires a separately designed authorized ranking stage and is not | ||
| smuggled into this product-lane index change. | ||
|
|
||
| The served composition does not activate the index here. Its default remains | ||
| reject-all and reports Runtime delivery as not active. | ||
|
|
||
| The current `EmbeddingProfile` records dimension only. It does not prove that | ||
| publication and query vectors share one immutable model and input profile, so | ||
| this slice does not claim semantic embedding-profile compatibility. A served | ||
| external provider is prohibited until storage or immutable Release lineage | ||
| binds that identity and Runtime rejects mismatches. The bounded dogfood | ||
| activation may use only the deterministic network-free twin over a freshly | ||
| reimported corpus produced by that same twin. | ||
|
|
||
| An external query-embedding provider is also not eligible for served | ||
| composition while Runtime cannot meter and enforce that call against the | ||
| effective PackageBudget. The follow-up activation must either add that usage | ||
| propagation or explicitly compose only the deterministic network-free twin, | ||
| classified as zero external provider calls and zero provider cost. External | ||
| provider configuration must fail closed until the metered path exists. | ||
|
|
||
| The follow-up served activation must also pass the Kernel-computed | ||
| `EffectiveScope` to discovery as a removal-only pre-filter before the ANN | ||
| limit. Caller narrowing alone is insufficient when trusted policy operands are | ||
| stricter than the RLS-visible set. Every returned candidate must still pass the | ||
| Kernel's exact authorization and projection; the pre-filter never grants | ||
| authority. Until that propagation exists, the served vector carrier remains | ||
| `NOT_ACTIVE`. | ||
|
|
||
| ## Rationale | ||
|
|
||
| Keeping discovery on the retained UserActor transaction preserves the exact | ||
| FORCE-RLS and Membership lifetime already established for resolve; a second | ||
| connection would require reconstructing trusted context and create a drift-prone | ||
| authorization boundary. Strict-order iterative HNSW scanning bounds work while | ||
| continuing past rows removed by tenant, lifecycle, ACL, and narrowing filters. | ||
| Keeping rank outside the current Kernel preserves ADR-0025's deterministic, | ||
| rank-independent assembly until a separately measured authorized-ranking stage | ||
| can justify changing the sealed path. | ||
|
|
||
| ## Consequences | ||
|
|
||
| - Query embedding and publication share one dimension contract; semantic | ||
| model/input-profile identity is not yet persisted or release-bound. | ||
| - FORCE RLS narrows discovery as defense in depth; it never grants authority. | ||
| - Filtered HNSW scans can continue within a fixed work ceiling instead of | ||
| silently stopping after the first filtered approximate batch. | ||
| - Provider diagnostics, distances, bodies, paths, titles, and denied lineage do | ||
| not enter the candidate output or HTTP error response. | ||
| - Current PackageBudget selection remains deterministic and rank-independent; | ||
| semantic ranking quality is not claimed by this slice. | ||
| - Served external query embedding remains `NOT_ACTIVE` until its provider call, | ||
| cost, and elapsed usage can be enforced and recorded by Runtime. | ||
| - Served vector discovery remains `NOT_ACTIVE` until the Kernel-computed | ||
| EffectiveScope can restrict candidates before the ANN limit without becoming | ||
| an authorization decision at the index. | ||
|
|
||
| ## Revisit trigger | ||
|
|
||
| Revisit after the real golden set and dogfood corpus exist, or before activating | ||
| rank-sensitive budget selection. The exit evidence for tuning or replacing the | ||
| current strategy includes exact-search recall delta, underfilled-result rate, | ||
| `EXPLAIN ANALYZE`, corpus size, filter selectivity, hardware, iterative-scan or | ||
| oversampling settings, latency, and cost. Hybrid fusion and content-bearing | ||
| reranking remain separate evidence-triggered decisions. | ||
|
|
||
| Revisit before any served composition admits an external query-embedding | ||
| provider without exact PackageBudget usage propagation. | ||
|
|
||
| Revisit before any served external provider or mixed-origin corpus can be used | ||
| without immutable publication/query embedding-profile identity and mismatch | ||
| rejection. | ||
|
|
||
| Revisit before any served composition can limit ANN candidates without first | ||
| applying the current Kernel-computed EffectiveScope as a removal-only filter. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -164,6 +164,35 @@ def _canonical_candidate_revision(value: str) -> UUID | None: | |
| return parsed | ||
|
|
||
|
|
||
| _VECTOR_ITERATIVE_SCAN = "strict_order" | ||
| _VECTOR_MAX_SCAN_TUPLES = 20_000 | ||
| _VECTOR_CANDIDATE_SQL = """ | ||
| SELECT | ||
| resource.organization_id, | ||
| resource.source_ref, | ||
| fragment.resource_ref, | ||
| fragment.revision_id, | ||
| fragment.fragment_ref | ||
| FROM context_fragment AS fragment | ||
| JOIN context_resource AS resource | ||
| ON resource.organization_id = fragment.organization_id | ||
| AND resource.resource_ref = fragment.resource_ref | ||
| AND resource.active_revision_id = fragment.revision_id | ||
| AND resource.tombstoned IS FALSE | ||
| WHERE fragment.embedding IS NOT NULL | ||
| AND ( | ||
| CAST(:source_refs AS text[]) IS NULL | ||
| OR resource.source_ref = ANY(CAST(:source_refs AS text[])) | ||
| ) | ||
| AND ( | ||
| CAST(:resource_refs AS text[]) IS NULL | ||
| OR fragment.resource_ref = ANY(CAST(:resource_refs AS text[])) | ||
| ) | ||
| ORDER BY fragment.embedding <=> CAST(:query_embedding AS vector) | ||
| LIMIT :limit | ||
|
Comment on lines
+182
to
+192
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
stometa, when an AGENTS.md reference: AGENTS.md:L22-L26 Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed at f7aea67. The existing CandidateIndex Protocol and Kernel remain unchanged: the vector adapter forwards already-validated untrusted RequestNarrowing through the lifetime-bound materialized session, and production SQL applies exact source/resource predicates before ANN LIMIT. The Kernel still independently computes EffectiveScope and exactly reauthorizes every CandidateRef. The real HTTP/PG test now inserts more-than-limit strictly closer same-Organization distractors, proves the unfiltered top-1 omits the target, then proves a narrowed request returns the target Evidence; ANN and exact results agree and EXPLAIN covers the production query. |
||
| """ | ||
|
|
||
|
|
||
| class _PostgreSQLMaterializedProjectionPort: | ||
| """Two-stage Fragment reads on the owning current-UserActor transaction.""" | ||
|
|
||
|
|
@@ -184,6 +213,51 @@ def source_is_active(self, source_ref: UUID) -> bool: | |
| ).scalar_one() | ||
| return observed is True | ||
|
|
||
| def discover_vector( | ||
| self, | ||
| query_embedding: tuple[float, ...], | ||
| limit: int, | ||
| source_refs: tuple[str, ...] | None, | ||
| resource_refs: tuple[str, ...] | None, | ||
| ) -> tuple[CandidateRef, ...]: | ||
| self._connection.execute( | ||
| text( | ||
| "SELECT set_config('hnsw.iterative_scan', :iterative_scan, true), " | ||
| "set_config('hnsw.max_scan_tuples', :max_scan_tuples, true)" | ||
| ), | ||
| { | ||
| "iterative_scan": _VECTOR_ITERATIVE_SCAN, | ||
| "max_scan_tuples": str(_VECTOR_MAX_SCAN_TUPLES), | ||
| }, | ||
| ) | ||
| rows = self._connection.execute( | ||
| text(_VECTOR_CANDIDATE_SQL), | ||
| { | ||
| "query_embedding": "[" | ||
| + ",".join(repr(value) for value in query_embedding) | ||
| + "]", | ||
| "limit": limit, | ||
| "source_refs": list(source_refs) if source_refs is not None else None, | ||
| "resource_refs": ( | ||
| list(resource_refs) if resource_refs is not None else None | ||
| ), | ||
| }, | ||
| ) | ||
| candidates: list[CandidateRef] = [] | ||
| for row in rows: | ||
| if type(row.revision_id) is not UUID: | ||
| raise TypeError("vector discovery returned invalid revision lineage") | ||
| candidates.append( | ||
| CandidateRef( | ||
| organization_id=row.organization_id, | ||
| source_ref=row.source_ref, | ||
| resource_ref=row.resource_ref, | ||
| revision_ref=str(row.revision_id), | ||
| fragment_ref=row.fragment_ref, | ||
| ) | ||
| ) | ||
| return tuple(candidates) | ||
|
|
||
| def discover_exact_phrase( | ||
| self, | ||
| phrase_digest: str, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
stometa, when existing fragments were published with model/input profile A but Runtime is configured with another 384-dimensional provider—or the model configuration changes between publications—this constructor accepts the provider because
EmbeddingProfilerecords only dimension, even though cosine distances across those vector spaces are meaningless. The failure is silent and can return unrelated candidates rather than an availability error; persist an immutable model/input-profile identity with the vectors and reject incompatible query providers before discovery.AGENTS.md reference: AGENTS.md:L22-L26
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ADR-0067 is corrected at f5914f9: current EmbeddingProfile proves dimension only, not semantic model/input-profile compatibility, and #101 makes no broader claim. Issue #102 acceptance criteria now hard-gate activation to the deterministic network-free twin over a freshly reimported same-twin corpus; any external provider must fail configuration/remain NOT_ACTIVE until immutable publication/query profile identity is persisted or Release-bound with mismatch rejection and PackageBudget usage is propagated. The #102 body also now contains the causal EffectiveScope-before-LIMIT activation test.