From f97d00896f88c0871b19a208547203bc6f66b4e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=95=EB=AF=BC?= Date: Mon, 13 Jul 2026 15:02:40 +0900 Subject: [PATCH 01/10] =?UTF-8?q?docs(knowledge):=20=EB=9F=B0=ED=83=80?= =?UTF-8?q?=EC=9E=84=20=ED=9B=84=EB=B3=B4=20=ED=95=B4=EC=84=9D=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture.md | 3 +- docs/data_model.md | 4 +- ...-knowledge-runtime-candidate-resolution.md | 184 ++++++++++++++++++ docs/decisions/README.md | 1 + docs/features/knowledge/api_spec.md | 68 ++++++- docs/features/knowledge/component_spec.md | 55 ++++-- docs/features/knowledge/requirements.md | 17 +- docs/features/knowledge/test_cases.md | 27 ++- 8 files changed, 331 insertions(+), 28 deletions(-) create mode 100644 docs/decisions/ADR-0036-knowledge-runtime-candidate-resolution.md diff --git a/docs/architecture.md b/docs/architecture.md index c07500375..c1b51dadb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,7 +28,7 @@ Security Alert MVP는 [ADR-0028](decisions/ADR-0028-security-alert-detection-and | Security Alert Admin Service | Gateway application/service boundary | organization owner/manager 전용 alert 조회·상태 변경, safe evidence projection, lifecycle audit transaction을 제공한다 | | Security Alert Notification Projection | Gateway/Client notification boundary | 영속 alert를 source of truth로 두고 Sidebar summary와 `notifications.changed` 재조회 신호를 제공한다 | -Knowledge 통합 목표 구조에서는 Gateway/Shared 경계에 다음 domain service를 둔다. 아래 항목은 현재 구현 컴포넌트 전체가 아니라 [ADR-0014](decisions/ADR-0014-knowledge-base-document-atom-and-collection-boundary.md), [ADR-0015](decisions/ADR-0015-knowledge-skill-context-routing-boundary.md), [ADR-0017](decisions/ADR-0017-knowledge-integration-provisional-implementation-baseline.md), [ADR-0020](decisions/ADR-0020-knowledge-mcp-incremental-sync-boundary.md)의 target component다. +Knowledge 통합 목표 구조에서는 Gateway/Shared/Workflow Engine 경계에 다음 domain service를 둔다. 아래 항목은 현재 구현 컴포넌트 전체가 아니라 [ADR-0014](decisions/ADR-0014-knowledge-base-document-atom-and-collection-boundary.md), [ADR-0015](decisions/ADR-0015-knowledge-skill-context-routing-boundary.md), [ADR-0017](decisions/ADR-0017-knowledge-integration-provisional-implementation-baseline.md), [ADR-0020](decisions/ADR-0020-knowledge-mcp-incremental-sync-boundary.md), [ADR-0036](decisions/ADR-0036-knowledge-runtime-candidate-resolution.md)의 target component다. | 구성요소 | 책임 | | --- | --- | @@ -40,6 +40,7 @@ Knowledge 통합 목표 구조에서는 Gateway/Shared 경계에 다음 domain s | Knowledge Normalizer / Ingestion Pipeline | source item을 redacted canonical text와 document version artifact로 변환하고, indexing 성공 후 active version finalization을 수행한다. | | Knowledge Permission Helper | collection route 권한, KB `use`, source ACL freshness/requester authorization을 bulk 평가한다. Router, Builder, Workflow LLM node runtime은 permission row를 직접 조합하지 않는다. | | Knowledge Administration Application | KB object/property authorization, owner migration/bootstrap, organization-scoped domain delegation, self-escalation policy, lifecycle와 transaction-bound audit를 조율한다. Domain 관리 권한은 KB content/Collection route에 합산하지 않는다 ([ADR-0034](decisions/ADR-0034-knowledge-delegated-administration-and-rbac-boundary.md)). | +| Workflow Runtime Knowledge Candidate Resolver | Shared pure policy와 Workflow Engine `runtime_retrieval` use case/port, PostgreSQL adapter로 direct KB와 명시 selected Collection을 current audience 기준 재평가한다. Invocation마다 `REPEATABLE READ, READ ONLY` snapshot을 사용하고 Gateway Builder resolver를 import하지 않는다. API/graph/LLM wiring은 MBA-233 범위다 ([ADR-0036](decisions/ADR-0036-knowledge-runtime-candidate-resolution.md)). | | Collection Router / Retrieval Orchestrator | 권한 helper가 허용한 safe candidate set에서 collection/KB를 선택하고, metadata-aware/hierarchical retrieval 결과를 merge/rerank한다. | | Knowledge Skill Registry | Workflow Builder가 LLM node의 RAG 옵션을 구성할 때 사용할 provider-neutral Skill, version, visibility, freshness/eval 상태를 관리하는 target component다. Skill은 권한 source나 source of truth가 아니다. | | Skill Context Loader | 후속 target component로, 빌더 단계에서 safe skill metadata와 필요한 checklist/body를 gate 통과 후 점진적으로 로드한다. MBA-145 Agent Builder MVP는 Knowledge Skill body/checklist를 prompt context로 직접 로드하지 않고 ADR-0017 기본 RAG option 후보와 KB safe metadata만 사용한다. Raw skill body, hidden source reference, raw source title/path/url은 Builder input으로 제공하지 않는다. | diff --git a/docs/data_model.md b/docs/data_model.md index dfa39740d..710206254 100644 --- a/docs/data_model.md +++ b/docs/data_model.md @@ -829,7 +829,7 @@ knowledge_skills | 목표 테이블 | 역할 | 핵심 제약 | | --- | --- | --- | | `knowledge_collections` | collection/grouping/routing/UX/ops 단위 | `organization_id`, safe display name/description, source connector ref, system-managed flag, sync status. MVP anonymous public-only runtime은 `safe_metadata["visibility"] == "public"`을 public collection 판정으로 사용하며, 누락 또는 다른 값은 private로 취급한다. Source-managed KB는 별도 `source_public_exposure_policies` validation도 통과해야 public-only 후보가 된다. Source-derived display fields는 redacted/capped/display-policy-approved 값만 저장한다. | -| `knowledge_collection_items` | collection과 document-level KB의 link | collection membership은 child KB content retrieval 권한을 부여하지 않는다. Linking에는 collection manage와 KB manage가 모두 필요하다. | +| `knowledge_collection_items` | collection과 document-level KB의 link | collection membership은 child KB content retrieval 권한을 부여하지 않는다. Linking에는 collection manage와 KB manage가 모두 필요하다. Item 자체에는 lifecycle column이 없으므로 row 존재는 linked, unlink/missing은 membership 없음으로 해석하고 Collection과 child KB lifecycle을 별도로 평가한다. | | `team_knowledge_collection_permissions` / `user_knowledge_collection_permissions` | collection `read`/`route`/`manage`/`sync` 권한 저장 | ADR-0017 임시 baseline의 collection permission table이다. 기존 `auth_state` 계층으로 추론하지 않고 `permission_action` 값(`read`, `route`, `manage`, `sync`)을 저장하는 additive allow row를 기본으로 한다. Collection permission은 child KB content access를 상속하지 않고, router/controller는 permission row가 아니라 helper 결과를 소비한다. | | `team_knowledge_domain_permissions` / `user_knowledge_domain_permissions` | organization-scoped Knowledge 관리 위임 | ADR-0034의 `catalog_manage`, `permission_delegate`, `lifecycle_manage`, `sync_manage` additive allow를 저장한다. Optional expiry를 평가 시점에 적용하며 KB content/Collection route 권한을 상속하지 않는다. | | `knowledge_bases` | document/source item 단위 permission/retrieval/sync/lifecycle atom | target 의미는 `granularity=document`로 고정한다. Source-managed KB는 protected source identity와 sync state를 갖고, KB `use`와 source ACL gate를 모두 통과해야 retrieval 대상이 된다. Target column 후보에는 `active_document_version_id`, `source_identity_id`, lifecycle/sync state가 포함된다. | @@ -840,7 +840,7 @@ knowledge_skills | `source_subject_mappings` | Nodease execution subject와 source system subject mapping lifecycle | `organization_id`, connector/source identity, Nodease subject ref, protected source subject ref, mapping state(`mapped`, `unmapped`, `ambiguous`, `stale`, `revoked` 후보), mapping epoch, last_verified_at, expires_at, revoked_at, audit-safe reason이 필요하다. `unmapped`, `ambiguous`, `stale` revalidate failure, `revoked`는 private source-managed retrieval에서 fail-closed다. | | `source_policy_kb_use_grants` | organization-approved connector/source policy가 provision한 KB `use` allow row | Manual `team_knowledge_permissions`/`user_knowledge_permissions`와 별도 table로 둔다. `organization_id`, `knowledge_base_id`, subject type/id, source policy id, protected `source_identity_id`, provisioned_by, expires_at, revocation behavior, active/inactive state, audit-safe reason, freshness epoch가 필요하다. Permission helper는 이 row를 mbased KB `use` allow 후보로 합산하되 source ACL/requester authorization gate를 별도로 적용한다. | | `source_public_exposure_policies` | Source-managed KB를 anonymous public-only 후보로 공개하기 위한 source/connector 승인 사실 | Collection visibility와 별도다. `organization_id`, `approval_scope`(`connector`, `source_identity`, `collection`, `knowledge_base` 후보), scope별 target id, approved_by, approved_at, expires_at, source_identity_id, connector_id, revocation_behavior, reverification cadence, explicit acknowledgement, active/revoked state, audit-safe reason이 필요하다. `approval_scope`와 target field가 일치하지 않는 row는 public-only 후보에서 제외한다. Connector-wide approval은 broad exposure이므로 organization manager approval, expiry, reverification, revocation behavior가 모두 필요하다. | -| `source_authorization_provenance` | source ACL authorization provenance를 permission helper가 소비할 수 있게 materialize한 target table | Source permission action/provenance, source authorization state, freshness epoch, requester subject ref를 KB permission `auth_state`와 구분한다. Raw source permission 값은 source ACL facts 또는 safe metadata에 둔다. 이 table은 mbased KB `use` gate를 자동 대체하지 않는다. 이름은 KB `use` grant처럼 읽히지 않아야 하므로 grant 중심 이름을 쓰지 않는다. | +| `source_authorization_provenance` | source ACL authorization provenance를 permission helper가 소비할 수 있게 materialize한 target table | Source permission action/provenance, source authorization state, freshness epoch, requester subject ref를 KB permission `auth_state`와 구분한다. Raw source permission 값은 source ACL facts 또는 safe metadata에 둔다. 이 table은 mbased KB `use` gate를 자동 대체하지 않는다. MBA-232 runtime resolver는 현재 materialized row만 사용하며 live connector 호출/cache는 구현하지 않는다. Missing/stale/mismatched/denied/unknown row는 fail-closed다. 이름은 KB `use` grant처럼 읽히지 않아야 하므로 grant 중심 이름을 쓰지 않는다. | | `knowledge_ingestion_outbox` | indexing/finalization/cleanup side effect 조정 | active version finalization, orphan cleanup, object storage/vector index cleanup, retry/dead-letter, recovery scanner의 기준 record다. Idempotency key, owner/fencing token, lease expiry, status(`pending`, `leased`, `succeeded`, `retry_scheduled`, `dead_lettered`, `cancelled` 후보), target artifact reference, attempt count, max attempts, next retry timestamp, retryability, safe reason code, dead-letter timestamp/reason, re-drive marker가 필요하다. Active pointer swap과 previous version `superseded` 표시, processed state commit, outbox insert는 같은 DB transaction 안에서 수행한다. External index success 후 DB finalize failure, DB finalize success 후 cleanup failure를 복구할 수 있어야 하며 pre-finalized artifact는 retrieval-visible하면 안 된다. | | `knowledge_skills` | Workflow Builder가 LLM node의 RAG 옵션을 구성할 때 참고하는 provider-neutral 절차/context/routing artifact | `organization_id`, safe display name/description, owner/review state, visibility policy, publication state가 필요하다. Skill은 권한 source나 source of truth가 아니며 child KB content permission을 부여하지 않는다. | | `knowledge_skill_versions` | skill body/checklist/routing rule의 version | raw source content, raw source title/path/url, raw principal, raw ACL fact, restricted document list, hidden KB id, raw prompt/completion/provider response를 저장하지 않는다. `freshness_state`, `last_validated_at`, `eval_status`, `source_version_refs` 또는 safe refs가 필요하다. | diff --git a/docs/decisions/ADR-0036-knowledge-runtime-candidate-resolution.md b/docs/decisions/ADR-0036-knowledge-runtime-candidate-resolution.md new file mode 100644 index 000000000..ba0384476 --- /dev/null +++ b/docs/decisions/ADR-0036-knowledge-runtime-candidate-resolution.md @@ -0,0 +1,184 @@ +# ADR-0036: Knowledge runtime candidate resolution 경계 + +Status: Accepted + +Related ADRs: [ADR-0014](ADR-0014-knowledge-base-document-atom-and-collection-boundary.md), [ADR-0018](ADR-0018-workflow-rag-anonymous-public-only-runtime.md), [ADR-0020](ADR-0020-knowledge-mcp-incremental-sync-boundary.md), [ADR-0022](ADR-0022-incremental-hexagonal-architecture-adoption.md), [ADR-0034](ADR-0034-knowledge-delegated-administration-and-rbac-boundary.md) + +## Context + +MBA-231은 KB object/property RBAC, Collection action, Knowledge domain 위임, +source authorization provenance를 하나의 관리 경계로 정리했다. 다음 단계는 +Workflow LLM node가 직접 선택한 KB와 선택한 Collection의 하위 KB를 실행 시점 +주체 기준으로 다시 해석하는 것이다. + +현재 Gateway의 `KnowledgeCandidateResolver`는 Builder 추천과 deployment preview를 +위한 safe metadata resolver다. Collection scope가 생략되면 route 가능한 Collection +subset 또는 직접 권한이 있는 KB를 탐색하는 Builder 편의 동작이 있고, Gateway +SQLAlchemy service와 response schema에 결합되어 있다. 이를 Workflow Engine에서 +가져다 쓰면 Builder 정책이 runtime authorization으로 승격되고 Gateway concrete +구현을 Worker가 역으로 import하게 된다. + +또한 Collection, membership, KB lifecycle/readiness, Team/User permission, source +authorization provenance를 여러 query로 읽는 동안 PostgreSQL 기본 `READ COMMITTED` +transaction에서는 서로 다른 commit 시점이 섞일 수 있다. Source-managed KB의 live +connector authorization과 public exposure approval은 목표 계약에는 있지만 현재 +MBA-232에서 사용할 완성된 runtime primitive가 아니다. + +## Options + +1. Gateway resolver를 Workflow Engine에서 직접 import한다. +2. Gateway resolver 전체를 shared service로 이동해 Builder와 runtime이 같은 동작을 + 사용한다. +3. Shared에는 순수 contract와 결정적 merge policy만 두고, Workflow Engine에 + runtime application use case, port, PostgreSQL adapter와 composition을 둔다. + +## Decision + +선택지 3을 채택한다. + +### Runtime과 Builder resolver 분리 + +- Gateway `KnowledgeCandidateResolver`는 Builder/preflight safe candidate resolver로 + 유지한다. +- MBA-232 runtime resolver는 Workflow Engine `runtime_retrieval` application + boundary가 소유한다. +- Shared package에는 FastAPI, Celery, SQLAlchemy, Gateway, Workflow Engine concrete + 구현을 모르는 immutable contract와 pure ordering/budget policy만 둔다. +- API, Workflow graph, Builder/preflight adapter, LLM node와 retrieval 연결은 + MBA-233 범위다. MBA-232 composition은 연결 가능한 seam까지만 제공한다. + +### Server-owned request와 audience + +Runtime request는 canonical `organization_id`, direct KB ID 목록, 명시적으로 선택한 +Collection ID 목록, server candidate budget과 다음 두 audience 중 하나를 가진다. + +- `AuthenticatedAudience`: canonical organization과 현재 execution user +- `AnonymousPublicAudience`: canonical organization만 보유하며 synthetic subject를 + 만들지 않음 + +Optional user 값에서 workflow owner, builder, deployment owner, credential principal, +KB creator 또는 service account로 fallback하는 동작은 금지한다. Service account와 +operator audience는 별도 lifecycle/approval 결정 전까지 이 union에 추가하지 않는다. + +Runtime은 명시한 Collection만 해석한다. Collection ID가 생략되거나 빈 목록이면 +Collection stream은 0개다. Organization 전체 Collection 탐색, query-aware routing, +semantic discovery는 수행하지 않는다. + +### Authenticated authorization + +- Direct KB는 active organization, KB active lifecycle, retrieval readiness, KB `use`, + source-managed인 경우 materialized source authorization gate를 통과해야 한다. + Collection `route`는 요구하지 않는다. +- Collection child는 Collection active lifecycle과 `route`, membership 존재, child KB + active lifecycle/readiness, child KB `use`, source-managed인 경우 materialized source + authorization을 모두 통과해야 한다. +- Collection `read/manage/sync`와 Knowledge domain `catalog_manage`, + `permission_delegate`, `lifecycle_manage`, `sync_manage`는 runtime `route/use`를 + 대체하지 않는다. + +### Anonymous public-only authorization + +- Selected Collection은 active이고 `safe_metadata["visibility"] == "public"`이어야 + 한다. 누락, 다른 값, malformed 값은 private로 취급한다. +- Direct KB도 active public Collection에 현재 연결되어 있어야 한다. +- Manual active/ready KB만 public candidate가 될 수 있다. +- Source-managed KB는 별도 source/connector public exposure approval store가 현재 + 구현되어 있지 않으므로 public Collection에 연결되어 있어도 fail-closed 제외한다. +- Anonymous path는 Team/User Collection/KB permission, domain permission, 로그인 + cookie의 user를 사용하지 않는다. + +### Membership, lifecycle와 readiness + +`KnowledgeCollectionItem`은 lifecycle column을 갖지 않는다. Row가 존재하면 현재 +membership이고 unlink 또는 missing이면 candidate가 아니다. Collection lifecycle과 +child KB lifecycle은 독립적으로 평가한다. + +KB는 active lifecycle이고 `sync_state != "source_deleted"`여야 한다. 기본 readiness는 +active `DocumentVersion.status == "ready"`다. 전환기에는 completed document에 연결된 +unversioned retrieval-visible chunk가 있는 legacy KB를 기존 문서 계약대로 허용할 수 +있다. Pre-finalized chunk, non-ready version 또는 document row 존재만으로 ready를 +추정하지 않는다. + +### Materialized source authorization + +MBA-232는 `SourceAuthorizationProvenance`에 materialize된 현재 DB fact만 사용한다. +Organization, KB/source identity, authenticated requester subject, active status, +freshness/expiry, requester authorization과 저장된 source action이 일치해야 한다. +Missing, inactive, stale, expired, unmapped, ambiguous, unverified, revoked, denied, +unknown, mismatched fact는 해당 KB를 제외한다. + +MBA-232 resolver는 connector client, `check_access_batch`, single `check_access`, HTTP +client 또는 runtime source-authorization cache를 호출하거나 구현하지 않는다. Live +재확인과 short-lived cache는 별도 source authorization 이슈에서 결정한다. Candidate +ID 또는 permission 결과도 invocation 사이에 cache하지 않는다. + +### Deterministic merge와 budget + +초기 server budget은 최대 20 unique KB다. + +1. 허용된 direct KB를 graph/configured order로 먼저 추가한다. +2. 남은 budget은 selected Collection configured order 기준 round-robin으로 채운다. +3. Collection 내부는 item rank, item creation time, KB UUID 순서를 사용한다. +4. Canonical KB UUID로 dedupe하고 처음 허용된 provenance를 보존한다. +5. Duplicate를 만나면 해당 slot을 소비하지 않고 traversal을 계속한다. + +Adapter의 membership scan도 선택한 첫 Collection이 나머지를 굶기지 않도록 fair하고 +bounded해야 한다. Budget 또는 scan cap 도달은 성공 결과에 fixed safe warning과 +bucketed summary를 붙이는 동작이며 authorization partial failure가 아니다. + +### PostgreSQL invocation snapshot + +Resolver adapter는 invocation마다 fresh SQLAlchemy session/transaction을 열고 첫 +query 전에 PostgreSQL `REPEATABLE READ`와 transaction read-only를 적용한다. 단순 +`READ ONLY`는 기본 `READ COMMITTED`에서 여러 statement의 snapshot을 고정하지 못한다. +Session-wide isolation 변경을 pooled connection에 남기지 않는다. + +Collection, membership, lifecycle/readiness, permission, materialized source +provenance query는 같은 snapshot을 사용한다. 중간에 commit된 변경은 다음 resolver +invocation부터 반영한다. Fake session test는 이 동시성 계약의 증명이 아니며 disposable +PostgreSQL two-transaction test를 둔다. + +### Result와 failure + +정책상 hidden, cross-organization, inactive, unlinked, not-ready, denied, stale, +unknown 후보는 identity를 노출하지 않고 제외한다. 후보가 0개면 provider/retrieval을 +호출하지 않는 `safe_no_result`를 반환한다. + +DB session, snapshot, repository 또는 authorization helper infrastructure가 실패하면 +이미 평가한 후보를 부분 결과로 반환하지 않는다. Resolver 전체를 fixed safe code의 +retryable infrastructure failure로 종료하고 retrieval/provider 호출 전에 닫는다. 일부 +authorized KB의 실제 retrieval timeout/partial result는 MBA-233 또는 기존 Retrieval +Orchestrator의 downstream 정책이다. + +Durable observability에는 routing mode, configured/authorized count bucket, +budget-limited boolean, fixed reason/warning, safe latency/correlation만 허용한다. Raw +query/graph/payload/content, source title/path/URL/principal/ACL, permission row, hidden +KB/Collection ID/name, exact denied count, credential, prompt/completion은 저장하지 않는다. + +## Consequences + +장점: + +- Builder convenience policy가 runtime authorization으로 승격되지 않는다. +- Workflow Engine이 Gateway concrete package를 import하지 않는다. +- Current execution audience, route/use/source gate와 anonymous public-only behavior를 + 한 테스트 가능한 경계에서 강제한다. +- Direct KB와 여러 Collection을 deterministic하고 bounded하게 함께 사용할 수 있다. +- 한 invocation 안에서 permission/membership revision이 섞이는 것을 막는다. + +비용: + +- Gateway Builder resolver와 runtime resolver가 목적상 공존하며 MBA-233에서 명시적 + adapter/contract mapping이 필요하다. +- Resolver마다 fresh repeatable-read transaction을 열어야 한다. +- Live source authorization이 연결되기 전에는 materialized provenance freshness에 + 의존하고 source-managed anonymous candidate는 항상 제외된다. + +## Follow-up + +- MBA-232는 pure policy, Workflow Engine application/port, PostgreSQL adapter, + composition과 policy/DB/concurrency test를 구현한다. +- MBA-233은 additive Workflow graph field, Builder selector, deployment preflight, + LLM node와 bounded retrieval wiring을 구현한다. +- Source live authorization, public exposure store, service account/operator audience, + query-aware organization-wide routing은 각각 별도 결정과 이슈가 필요하다. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index c46a8e3a4..32bf488ed 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -55,6 +55,7 @@ ADR 본문은 작성 시점의 결정 과정을 보존하는 기록 문서다. ` | [ADR-0033](ADR-0033-conversation-memory-contract-completion.md) | Accepted | Conversation Memory 계약 공백 보정 | Runtime provenance에 활성 control dependency를 포함하고 ProviderExecutionCapability authority를 LLM Credentials로 고정한다. 초기 session surface에서 Workflow Editor test를 제외하며 Access Grant V1은 standalone rotation/grace 없이 즉시 replacement/revoke한다. 현재 target 설계이며 legacy Memory 구현 완료를 의미하지 않는다. | | [ADR-0034](ADR-0034-knowledge-delegated-administration-and-rbac-boundary.md) | Accepted | Knowledge 위임 관리와 KB RBAC 경계 | MBA-231은 owner를 귀속 정보로 전환하고 KB object/property action, Team/User Knowledge domain delegation, self-escalation 차단, transaction-bound audit, public exposure·hard delete 조직 관리자 경계를 구현한다. | | [ADR-0035](ADR-0035-external-effect-idempotency-boundary.md) | Accepted | Workflow external effect 멱등성 경계 | 모든 실행 표면의 stable execution/node invocation identity와 compare variant 분리, operation 변경 우회를 막는 stable effect slot, 독립 DB session을 사용하는 durable attempt, 같은 execution 재진입과 claim loser 처리, 고정 replay deadline, supported operation에만 적용하는 versioned HMAC key, canonical JSON 최대 65,536-byte replay result와 변경 불가능한 provider contract profile을 확정했다. Generic HTTP mutation과 모든 Slack request는 중복 방지 `unknown`, GitHub issue comment는 `unsupported`이고 세 operation의 결과 재사용은 `unavailable`이며 migration/runtime/readiness를 구현했다. | +| [ADR-0036](ADR-0036-knowledge-runtime-candidate-resolution.md) | Accepted | Knowledge runtime candidate resolution 경계 | MBA-232 target은 direct KB와 명시 selected Collection을 execution audience 기준으로 재평가하는 pure policy, Workflow Engine use case/port, PostgreSQL repeatable-read read-only adapter다. API/graph/Builder/preflight/LLM wiring은 MBA-233 범위이며 MBA-232 code 반영 여부는 구현 커밋에서 갱신한다. | ## 참고 보고서 diff --git a/docs/features/knowledge/api_spec.md b/docs/features/knowledge/api_spec.md index 62f7efe5a..89ed2c689 100644 --- a/docs/features/knowledge/api_spec.md +++ b/docs/features/knowledge/api_spec.md @@ -1,7 +1,7 @@ # Knowledge API Spec Status: Draft -이 문서는 Knowledge feature의 현재 API baseline과 목표 KB 통합 API 계약을 함께 기록한다. MBA-105 목표 API는 [ADR-0017](../../decisions/ADR-0017-knowledge-integration-provisional-implementation-baseline.md)의 임시 구현 baseline, Workflow RAG anonymous public-only runtime은 [ADR-0018](../../decisions/ADR-0018-workflow-rag-anonymous-public-only-runtime.md), MCP/API source connector와 incremental sync 경계는 [ADR-0020](../../decisions/ADR-0020-knowledge-mcp-incremental-sync-boundary.md), MBA-231 위임 관리와 KB RBAC cutover는 [ADR-0034](../../decisions/ADR-0034-knowledge-delegated-administration-and-rbac-boundary.md), 세부 구현 기준은 [implementation_baseline.md](implementation_baseline.md)를 따른다. Knowledge Skill 관련 API 경계는 [ADR-0015](../../decisions/ADR-0015-knowledge-skill-context-routing-boundary.md)를 따른다. +이 문서는 Knowledge feature의 현재 API baseline과 목표 KB 통합 API 계약을 함께 기록한다. MBA-105 목표 API는 [ADR-0017](../../decisions/ADR-0017-knowledge-integration-provisional-implementation-baseline.md)의 임시 구현 baseline, Workflow RAG anonymous public-only runtime은 [ADR-0018](../../decisions/ADR-0018-workflow-rag-anonymous-public-only-runtime.md), MCP/API source connector와 incremental sync 경계는 [ADR-0020](../../decisions/ADR-0020-knowledge-mcp-incremental-sync-boundary.md), MBA-231 위임 관리와 KB RBAC cutover는 [ADR-0034](../../decisions/ADR-0034-knowledge-delegated-administration-and-rbac-boundary.md), direct KB와 명시 selected Collection의 internal runtime resolver는 [ADR-0036](../../decisions/ADR-0036-knowledge-runtime-candidate-resolution.md), 세부 구현 기준은 [implementation_baseline.md](implementation_baseline.md)를 따른다. Knowledge Skill 관련 API 경계는 [ADR-0015](../../decisions/ADR-0015-knowledge-skill-context-routing-boundary.md)를 따른다. ## Current Baseline Endpoints @@ -68,11 +68,61 @@ Redis progress는 `indexing`/`processing`에서만 사용하며 `pending`과 | Raw/compliance view | `/api/v1/knowledge/kbs/{kb_id}/raw-artifacts/*` | Raw/compliance gate 이후 선택적 protected raw content access. RAG answer API에서 사용하지 않음 | | Source connectors | `/api/v1/knowledge/sources/*` | Source connection, sync, tombstone, ACL status, remediation | | Knowledge skills | `/api/v1/knowledge/skills/*` | Provider-neutral skill registry, version, freshness/eval status, safe metadata. 주 사용처는 빌더 단계 LLM node의 RAG 옵션 구성 | -| 실행 시점 RAG retrieval | 내부 service call | Workflow LLM node의 RAG 옵션 실행 시 collection-routed 또는 KB-candidate-routed retrieval. Builder/preflight 후보 조회는 `/api/v1/knowledge/candidates/resolve`를 사용할 수 있지만, runtime retrieval은 내부 service boundary로 다시 권한을 평가한다 | +| 실행 시점 RAG candidate resolution/retrieval | 내부 service call | MBA-232는 direct KB + 명시 selected Collection을 current audience로 재평가하는 Workflow Engine internal resolver contract만 제공한다. Builder/preflight `/api/v1/knowledge/candidates/resolve`, public API, graph/LLM/retrieval wiring은 분리하며 실제 연결은 MBA-233 범위다 | | Knowledge domain permissions | `/api/v1/knowledge/domain-permissions`, `/api/v1/knowledge/domain-capabilities` | Organization manager가 Team/User 관리 action을 위임하고 caller의 safe capability를 조회 | 공개 HTTP path가 필요한 경우에는 별도 API gate review에서 path 이름과 JSON/SSE shape를 확정한다. MBA-105의 필수 계약은 collection listing(`collection.read`), collection routing(`collection.route`), KB content permission, source ACL state, document version citation identity의 분리다. Skill authoring, test, submit-for-review, publish/deprecate, Workflow Playground skill binding API는 아직 승인된 계약이 아니다. +### MBA-232 Workflow Runtime Candidate Resolver + +이 계약은 public HTTP request/response가 아니라 Workflow Engine 내부 application/port +contract다. Gateway의 Builder/deployment-preview `KnowledgeCandidateResolver`와 다른 +책임을 가진다. MBA-232에서 `/api/v1/*` path, Workflow graph field, Client schema, +deployment preflight와 LLM node wiring은 추가하거나 변경하지 않는다. + +Input: + +| 필드 | 규칙 | +| --- | --- | +| `audience` | `AuthenticatedAudience(organization_id, user_id)` 또는 `AnonymousPublicAudience(organization_id)` closed union. Owner/builder/deployment owner/credential principal/service account fallback 금지 | +| `direct_kb_ids` | Server-owned configured order. Duplicate는 first position 유지. Defensive cap 20 | +| `collection_ids` | Server-owned 명시 selected Collection configured order. Missing/empty는 stream 0개이며 organization-wide fallback 금지. Defensive cap 20 | +| `candidate_budget` | Server-owned unique KB cap. 1 이상 20 이하 | +| `candidate_scan_cap` | Server operations cap. Selected Collection을 공정하게 scan하며 response에 exact hidden 구조를 노출하지 않음 | + +Authenticated resolution은 direct KB에 KB `use`와 applicable materialized source gate를 +요구하고 Collection `route`는 요구하지 않는다. Collection child는 active Collection +`route`, membership, child KB `use`, applicable materialized source gate를 모두 요구한다. +Knowledge domain permission과 Collection `read/manage/sync`는 이 gate를 대체하지 않는다. + +Anonymous resolution은 selected active public Collection child 또는 active public +Collection에 연결된 direct manual KB만 허용한다. Team/User/domain grant를 사용하지 +않는다. Source/connector public exposure primitive가 현재 없으므로 source-managed KB는 +모두 제외한다. + +Output: + +| 필드 | 규칙 | +| --- | --- | +| `status` | `resolved` 또는 `safe_no_result` | +| `candidates` | 최대 20개의 authorized canonical KB identity와 internal first provenance. Direct configured order 우선, Collection round-robin, canonical KB dedupe | +| `routing_mode` | `direct`, `collection`, `mixed`, `none` 중 fixed safe value | +| count summary | Configured/evaluated/eligible 수의 safe bucket만 허용. Hidden/denied exact count 금지 | +| `budget_limited` / warning | Deterministic budget 또는 bounded scan 도달 여부와 fixed safe warning | + +Candidate policy exclusion은 safe omission이고 candidate 0개는 provider/retrieval 전 +`safe_no_result`다. DB session, PostgreSQL snapshot, repository 또는 authorization helper +infrastructure failure는 partial candidate를 반환하지 않는 typed retryable +whole-resolution error다. Error는 fixed code/retryability만 가지며 raw SQL/exception, +identifier, source metadata 또는 payload를 포함하지 않는다. Partial KB retrieval timeout은 +이 internal resolver output이 아니라 MBA-233/downstream Retrieval Orchestrator 계약이다. + +Resolver adapter는 invocation마다 fresh PostgreSQL transaction을 열고 첫 query 전에 +`REPEATABLE READ, READ ONLY`를 적용한다. Current Collection/membership/KB +lifecycle/readiness/permission/materialized provenance는 같은 snapshot에서 읽고 candidate +또는 authorization 결과를 invocation 사이에 cache하지 않는다. Live connector +`check_access*`와 runtime source authorization cache는 MBA-232에서 호출하지 않는다. + ### KB Permission Endpoints MBA-176의 Knowledge 직접 권한 API는 Organization resource permission surface와 같은 응답 envelope를 사용한다. KB 권한은 organization membership의 대체물이 아니며, active organization member에게만 effective permission으로 적용된다. @@ -184,7 +234,7 @@ cross-organization, deleted 또는 invisible resource는 `404 resource.hidden`, same-scope visible resource의 action 부족은 `403 permission.denied`다. 목록은 unauthorized row와 hidden count를 반환하지 않는다. -Builder와 deployment preflight가 사용할 MBA-105 candidate resolver contract는 다음 shape를 지켜야 한다. +Builder와 deployment preflight가 사용할 Gateway MBA-105 candidate resolver contract는 다음 shape를 지켜야 한다. 이 contract의 missing Collection scope fallback과 safe metadata response는 위 MBA-232 Workflow runtime resolver에 적용하지 않는다. | 필드 | 규칙 | | --- | --- | @@ -398,14 +448,14 @@ MBA-176에서 source/connector public exposure approval primitive가 아직 구 ### Workflow Runtime RAG Execution Subject -Workflow runtime에서 RAG를 호출하는 API나 내부 service call은 가능한 경우 `execution_subject`를 명시한다. `execution_subject`는 interactive user, workflow runner, 승인된 service account, 업무상 지정된 operator처럼 권한 평가에 사용할 주체다. MVP에서 `execution_subject`가 없으면 retrieval은 실패가 아니라 anonymous public-only로 낮아진다. +Workflow runtime에서 RAG를 호출하는 API나 내부 service call은 server-resolved execution audience를 명시한다. MBA-232 contract는 interactive/current user를 `AuthenticatedAudience`로, subject 부재를 synthetic identity 없는 `AnonymousPublicAudience`로 표현한다. 승인된 service account/operator audience는 별도 lifecycle/approval 계약 전까지 MBA-232 closed union에 포함하지 않는다. Subject가 없으면 retrieval은 실패가 아니라 anonymous public-only로 낮아진다. 필수 계약: | 항목 | 규칙 | | --- | --- | -| `execution_subject` | Workflow run context에서 명시적으로 resolve한 actor/service account. 있으면 KB permission과 source ACL 평가 기준 | -| `subject_resolution_reason` | interactive run, deployment service account, assigned operator, anonymous public-only 등 sanitized reason. 현재 MVP runtime은 reason field 없이도 subject 부재를 anonymous public-only로 해석할 수 있다 | +| `execution_subject` | Workflow run context에서 명시적으로 resolve한 current user. 있으면 `AuthenticatedAudience`의 KB permission과 materialized source authorization 평가 기준 | +| `subject_resolution_reason` | interactive user 또는 anonymous public-only 같은 sanitized reason. Service account/assigned operator는 별도 승인 전 MBA-232에 입력할 수 없다 | | `workflow_owner_id` | 감사/소유권 표시에는 사용할 수 있지만, 명시 설정 없이 retrieval 권한 fallback으로 사용하지 않는다 | | missing subject | Anonymous public-only retrieval. Active public collection에 연결된 active KB만 후보로 남기며 silent owner/user_id fallback은 금지 | | ambiguous or unsupported subject | Private retrieval fail-closed. Anonymous downgrade가 안전하게 판정되지 않으면 safe no-result 또는 failure policy를 따른다 | @@ -533,7 +583,8 @@ A/B 테스트, 비용 최적화, trace side panel은 다음 redaction-safe summa | Explicit KB mode | active organization, generation model/credential visibility, credential `use`, verified credential-model relation, KB visibility/resource hiding, KB use helper, source-managed KB의 source ACL/requester authorization, final evidence policy | | 빌더 단계 Knowledge Skill mode | active organization, skill visibility, skill safe metadata display, skill freshness/eval gate. Skill visibility는 collection route, KB permission, source ACL gate를 대체하지 않는다 | | 실행 시점 LLM node의 RAG 옵션 | execution subject가 있으면 해당 subject 기준 KB permission/source ACL gate와 final evidence policy. execution subject가 없으면 anonymous public-only gate와 final evidence policy. Explicit KB mode는 collection route를 생략할 수 있지만 KB visibility/use/source ACL/final evidence gate 또는 anonymous public-only gate를 생략하지 않는다. 빌더 단계 skill selection이나 workflow 작성자 권한을 실행 시점 data access로 전파하지 않는다 | -| Anonymous public-only Workflow RAG | active organization, active Knowledge Collection with `safe_metadata.visibility == "public"`, active linked KB, source-managed KB의 valid public exposure approval, final evidence policy. Public exposure approval primitive가 없으면 source-managed 후보는 `source_public_exposure_required`로 blocked. Workflow owner/deployment owner/app creator/`user_id` fallback 금지 | +| MBA-232 runtime selected Collection | explicit authenticated/anonymous audience, 명시 selected active Collection, authenticated `route` + child KB `use` + materialized source provenance 또는 anonymous public membership, active/ready KB, deterministic budget. Missing/empty Collection IDs는 fallback 없음 | +| Anonymous public-only Workflow RAG | active organization, active Knowledge Collection with `safe_metadata.visibility == "public"`, active linked manual KB, final evidence policy. Public exposure approval primitive가 없는 MBA-232에서는 source-managed 후보를 모두 제외한다. Workflow owner/deployment owner/app creator/`user_id` fallback 금지 | | Collection management | `collection.manage`; 기존 KB linking에는 `kb.manage`도 필요 | | Collection sync/remediation | `collection.sync` 또는 organization/admin operation policy. Raw content access를 의미하지 않는다 | | Raw content/export | Dedicated raw/compliance endpoint only. Raw/compliance permission, source-managed KB의 fresh source ACL, retention/legal-hold/purge check, response 전 raw access audit이 필요하다. 최종 enum 이름은 RBAC ADR에서 확정한다 | @@ -552,11 +603,12 @@ Resource hiding/no-result/evidence insufficiency API matrix는 [ADR-0017](../../ - Permission/source ACL gate를 통과한 뒤 발생한 source/connector operational failure. - Auto mode에서 권한 있는 candidate가 없는 경우. - Anonymous public-only mode에서 public candidate가 없는 경우. +- MBA-232 snapshot/repository/authorization infrastructure failure. 이 경우 candidate partial result를 만들지 않고 retrieval/provider 전에 fixed safe retryable error로 종료한다. - 권한 gate 이후 evidence가 없는 경우. - Evidence score, citation coverage, source tier policy 기준으로 근거가 부족한 경우. - Evidence sufficiency policy가 `policy_filtered` 또는 `operational_partial` reason을 반환하는 경우. -기준은 hidden KB/version/chunk identity를 드러내는 answer run, `rag.retrieve` success audit, citation id, trace metadata, durable summary를 만들지 않는 것이다. Scope 밖, organization mismatch, hidden deleted/archived resource, existence inference가 가능한 requester source authorization denied 또는 source ACL stale/unmapped/ambiguous/unverified/revoked 상태는 resource-hidden/404 또는 safe no-result로 닫는다. Partial result는 permission/source ACL/final evidence gates 이후 발생한 operational failure에만 허용한다. +기준은 hidden KB/version/chunk identity를 드러내는 answer run, `rag.retrieve` success audit, citation id, trace metadata, durable summary를 만들지 않는 것이다. Scope 밖, organization mismatch, hidden deleted/archived resource, existence inference가 가능한 requester source authorization denied 또는 source ACL stale/unmapped/ambiguous/unverified/revoked 상태는 resource-hidden/404 또는 safe no-result로 닫는다. MBA-232 candidate resolver의 DB/snapshot/authorization infrastructure failure에는 partial result를 허용하지 않는다. Partial result는 complete candidate authorization 이후 downstream retrieval에서 발생한 operational failure에만 허용한다. JSON/pre-stream error envelope는 `error.code`, `error.reason_code`, `error.message`, optional `correlation_id`, optional `retryable`만 포함한다. Hidden/resource-hidden path의 `message`는 generic text를 사용하고 target KB id/name/source path/count를 포함하지 않는다. Hidden/resource-hidden path의 external `reason_code`는 `resource.hidden`으로 일반화하며, `source_authorization.denied` 또는 `source_acl.stale/unmapped/ambiguous/unverified/revoked` 같은 세부 reason은 이미 존재가 authorized context에서 보이는 resource, admin/remediation context, 또는 내부 safe audit/trace allowlist에서만 사용할 수 있다. Stream 시작 후에는 HTTP status를 바꾸지 않고 `event: error` terminal event에 같은 semantic `code`/`reason_code`/`correlation_id`/`retryable` allowlist를 넣는다. diff --git a/docs/features/knowledge/component_spec.md b/docs/features/knowledge/component_spec.md index 39e6a15cd..167e21bfa 100644 --- a/docs/features/knowledge/component_spec.md +++ b/docs/features/knowledge/component_spec.md @@ -1,7 +1,7 @@ # Knowledge Component Spec Status: Draft -MBA-105 구현 baseline, 운영 기본값, permission helper output, active version finalization, resource hiding matrix는 [implementation_baseline.md](implementation_baseline.md)를 따른다. Workflow RAG에서 `execution_subject`가 없는 MVP public-only runtime은 [ADR-0018](../../decisions/ADR-0018-workflow-rag-anonymous-public-only-runtime.md)을 따른다. MCP/API source connector와 incremental sync 경계는 [ADR-0020](../../decisions/ADR-0020-knowledge-mcp-incremental-sync-boundary.md)을 따른다. +MBA-105 구현 baseline, 운영 기본값, permission helper output, active version finalization, resource hiding matrix는 [implementation_baseline.md](implementation_baseline.md)를 따른다. Workflow RAG에서 `execution_subject`가 없는 MVP public-only runtime은 [ADR-0018](../../decisions/ADR-0018-workflow-rag-anonymous-public-only-runtime.md)을 따른다. MCP/API source connector와 incremental sync 경계는 [ADR-0020](../../decisions/ADR-0020-knowledge-mcp-incremental-sync-boundary.md)을 따른다. Direct KB와 명시 selected Collection의 Workflow runtime candidate 해석은 [ADR-0036](../../decisions/ADR-0036-knowledge-runtime-candidate-resolution.md)을 따른다. ## Domain Components @@ -27,6 +27,7 @@ MBA-105 구현 baseline, 운영 기본값, permission helper output, active vers | Artifact Cleanup Reconciler | DB state와 object storage/vector index/external artifact cleanup을 outbox 기반으로 맞춘다 | DB commit 전 physical delete를 수행하지 않고 retry 가능한 cleanup만 실행한다 | | Knowledge Permission Helper | Collection `read`, collection `route`, KB use, source ACL freshness/requester authorization을 bulk 평가한다 | Router와 controller는 permission row가 아니라 helper 결과를 소비해야 한다 | | Knowledge Administration Application | Organization manager의 domain grant/revoke, active subject grant validation, stale grant permission-row revoke와 transaction-bound audit를 조율한다 | Grant subject lock과 revoke permission-row lock을 분리하고 controller가 subject 활성 상태를 추정하지 않는다 | +| Workflow Runtime Knowledge Candidate Resolver | Direct KB와 명시 selected Collection을 current authenticated/anonymous audience, lifecycle/readiness, route/use/source gate로 해석하고 direct-first/Collection-round-robin 20-KB set을 만든다 | Shared pure policy + Workflow Engine application/port + PostgreSQL snapshot adapter다. Gateway Builder resolver를 import하지 않고 retrieval/provider를 호출하지 않는다 | | Knowledge Collection Management Service | Manual Collection CRUD, item link/unlink/reorder, permission grant/revoke, visibility transition을 조율한다 | Controller에 business logic을 두지 않고, Collection 권한과 KB content 권한을 분리해서 검증한다 | | Knowledge Document Response Projector | 내부 `documents.meta_info`에서 safe operational field만 allowlist projection한다 | Encrypted config, connection/source identifier, DB/source config와 unknown nested field를 API response로 전달하지 않는다 | | Knowledge RAG Recommendation Adapter | `StructuredRequest` 기반 safe intent summary, node purpose summary, knowledge requirement, pending resolution reference를 받아 safe KB recommendation과 LLM node RAG option 후보를 만든다 | Raw natural language 전체를 받지 않고 권한 판단을 직접 하지 않는다. HTTP/serialized boundary에서는 `KnowledgeCandidateResolver`가 만든 server-issued reference만 사용하고, full safe candidate set 객체는 같은 backend 내부 service call에서만 ranking input으로 사용할 수 있다. 초기 구현은 `candidate_type=knowledge_base`만 반환하고 Collection은 safe summary metadata로만 제공한다 | @@ -40,6 +41,36 @@ MBA-105 구현 baseline, 운영 기본값, permission helper output, active vers | Audit/Trace Summarizer | Redaction-safe audit/trace/answer summary를 만든다 | Raw content/title/path/url은 제외하고, raw/compliance audit은 safe reference, decision, reason만 저장한다 | | RAG Answer Retention Worker | Terminal answer run의 retention purge를 수행하고 aggregate audit을 남긴다 | requested/running row를 삭제하지 않고 동시 purge를 row lock/marker로 방지한다 | +### MBA-232 Runtime Candidate Resolver Boundary + +구성요소는 다음으로 분리한다. + +| Layer | 책임 | 금지 | +| --- | --- | --- | +| Shared pure contract/policy | explicit audience/request/snapshot/result, direct-first/round-robin/dedupe/budget, safe bucket | SQLAlchemy, FastAPI, Celery, Gateway/Workflow concrete import | +| Workflow Engine application use case/port | request validation, snapshot port 1회 호출, pure policy 적용, whole-resolution failure mapping | SQL query, Gateway response schema, provider/retrieval side effect | +| PostgreSQL outbound adapter | fresh `REPEATABLE READ, READ ONLY` transaction, selected Collection/membership/readiness/permission/materialized provenance bulk projection | organization-wide discovery, live connector/source call, cross-invocation cache | +| Workflow Engine composition | session factory, adapter와 use case 조립 | LLM node business policy와 graph parsing | + +Gateway의 기존 `KnowledgeCandidateResolver`는 Builder recommendation/deployment +preview 경계다. Missing Collection scope에서 route-safe subset 또는 direct-KB fallback을 +사용할 수 있으나 MBA-232 runtime resolver에는 적용하지 않는다. Runtime +missing/empty Collection IDs는 Collection stream 0개다. Builder/preflight adapter, +Workflow graph와 LLM node/retrieval wiring은 MBA-233에서 연결한다. + +Runtime audience는 `AuthenticatedAudience(organization_id, user_id)`와 +`AnonymousPublicAudience(organization_id)`의 closed union이다. Anonymous audience에 +owner/builder/deployment owner/credential principal/service account를 합성하지 않는다. +Source-managed authenticated 후보는 materialized `SourceAuthorizationProvenance`만 +사용하고 live `check_access*`/cache를 호출하지 않는다. Public exposure primitive가 +없는 동안 source-managed anonymous 후보는 모두 제외한다. + +`KnowledgeCollectionItem`은 lifecycle을 갖지 않는다. Present row만 membership이고 +Collection/child KB lifecycle과 KB readiness를 별도로 평가한다. Snapshot/repository/ +authorization infrastructure failure는 partial candidate를 반환하지 않는 retryable +whole-resolution failure다. Budget cap은 successful safe warning이며 downstream +retrieval timeout과 구분한다. + Conversation Memory target adapter는 Knowledge Permission Helper의 bulk 결과를 `decision`, `principal_kind`, opaque `authorization_decision_revision`, `resource_revision`, `policy_revision`, `evaluated_at` contract로 투영한다. Source-managed KB의 source ACL revision은 decision revision에 반영한다. Lifecycle, KB permission, source ACL 중 필요한 revision이 없으면 allow를 추정하지 않고 `unknown`을 반환한다. Anonymous public audience에는 subject ID/revision을 합성하지 않는다. Retrieval Orchestrator는 최종 evidence와 함께 KB/document version, organization, sensitivity와 authorization-safe reference를 `RuntimeDataDependencyEnvelope`로 발급한다. Raw title/path/URL/content/ACL은 envelope에 포함하지 않는다. Client나 Workflow node가 canonical Knowledge dependency를 발급할 수 없고, V1에서는 answer content에 영향을 준 모든 Knowledge dependency를 필수로 취급한다. @@ -137,15 +168,17 @@ Purge는 일반 KB lifecycle state가 아니다. Retention/legal-hold purge, raw ### Runtime Collection Retrieval -1. Workflow runtime이 execution subject 또는 anonymous public-only context와 active organization을 검증한다. -2. Listing surface에는 collection `read`, routing scope에는 collection `route`를 bulk 평가한다. -3. Collection route scope와 KB permission helper/source ACL freshness/requester authorization 결과로 safe KB candidate set을 만든다. -4. `query_rewrite_mode`가 켜져 있으면 user query와 safe skill/template만 사용해 검색용 query를 만든다. Rewrite는 safe candidate set을 넓히지 않는다. -5. Retrieval orchestrator는 active ready version을 검색하고 evidence를 merge한다. -6. Source-of-Truth Tier는 authorized evidence 안에서 ranking, tie-break, conflict resolution hint로만 사용한다. -7. Final evidence policy와 evidence sufficiency check는 LLM prompt, answer generation, citation preview emission 전에 실행한다. -8. 근거가 부족하면 추측 답변을 만들지 않고 safe no-result 또는 insufficient-evidence response로 닫는다. -9. Answer/citation/audit/trace summary는 redaction-safe allowlist만 사용한다. +1. Workflow runtime이 server-owned canonical organization과 explicit authenticated audience 또는 anonymous public audience를 구성한다. Optional user/owner fallback은 사용하지 않는다. +2. MBA-232 resolver는 configured direct KB와 명시 selected Collection만 받아 fresh PostgreSQL `REPEATABLE READ, READ ONLY` snapshot을 연다. Missing/empty Collection scope는 0개다. +3. Authenticated audience에서는 direct KB `use`, selected Collection `route`와 각 child KB `use`, applicable materialized source provenance를 bulk 평가한다. Anonymous audience에서는 active public Collection membership을 평가하고 source-managed KB를 fail-closed 제외한다. +4. Active lifecycle, `source_deleted` exclusion, active ready version 또는 documented legacy retrieval-visible fallback을 적용한다. Collection item은 row presence만 membership으로 보고 parent/child lifecycle을 별도로 평가한다. +5. Direct configured order를 먼저 유지하고 selected Collection configured order의 round-robin으로 남은 20-KB budget을 채운다. Canonical KB ID로 dedupe하고 first provenance를 보존한다. +6. Candidate 0개는 provider/retrieval 전 `safe_no_result`, budget 제한은 fixed safe warning을 가진 성공이다. Snapshot/repository/authorization infrastructure failure는 partial candidate 없이 retryable whole-resolution failure다. +7. MBA-233에서 연결되는 Retrieval Orchestrator는 resolved active/ready KB만 검색하고 evidence를 merge한다. `query_rewrite_mode`는 safe candidate set을 넓히지 않는다. +8. Source-of-Truth Tier는 authorized evidence 안에서 ranking, tie-break, conflict resolution hint로만 사용한다. +9. Final evidence policy와 evidence sufficiency check는 LLM prompt, answer generation, citation preview emission 전에 실행한다. +10. 근거가 부족하면 추측 답변을 만들지 않고 safe no-result 또는 insufficient-evidence response로 닫는다. +11. Answer/citation/audit/trace summary는 redaction-safe allowlist만 사용한다. ### Explicit KB Retrieval @@ -201,7 +234,7 @@ Purge는 일반 KB lifecycle state가 아니다. Retention/legal-hold purge, raw - 초기 candidate cap은 `max_candidate_kbs=5000`, `max_route_collections=20`, `max_retrieval_kbs=20`, `max_chunks_per_kb=8`, `max_total_chunks=50`이다. 이 값은 운영 baseline이며 제품의 고정 계약이 아니다. - Candidate cap, fanout concurrency, timeout, partial failure behavior는 [implementation_baseline.md](implementation_baseline.md)의 baseline을 시작점으로 삼고, operations policy로 조정 가능해야 하며 운영 배포 전에 load test를 거쳐야 한다. - 가능한 경우 KB/version filter를 포함한 단일 vector/keyword query를 우선한다. Backend가 지원하지 못하면 concurrency와 timeout cap이 있는 bounded per-KB fanout을 사용한다. -- Candidate cache key에는 permission/freshness epoch를 포함해 ACL revocation이 stale candidate를 무효화해야 한다. +- MBA-232 runtime resolver는 candidate ID/authorization을 invocation 사이에 cache하지 않는다. 향후 candidate cache를 별도 승인할 경우 permission/freshness revision을 포함해 ACL revocation이 stale candidate를 무효화해야 한다. - Skill candidate cache key에는 skill version, freshness state, eval state, source version reference를 포함해 stale skill이나 source tier 변경이 즉시 무효화되어야 한다. - Query rewrite cache를 둘 경우 key에는 rewrite mode, safe template id, skill version, permission/freshness epoch를 포함해야 하며 raw rewritten query를 durable cache key나 trace key로 사용하지 않는다. - `llm_assisted` query rewrite는 추가 latency와 LLM cost를 만든다. 운영 배포 전 rewrite timeout, token/cost budget, fallback, load shedding, usage logging 기준을 load test에 포함한다. diff --git a/docs/features/knowledge/requirements.md b/docs/features/knowledge/requirements.md index 73745b265..453579d4d 100644 --- a/docs/features/knowledge/requirements.md +++ b/docs/features/knowledge/requirements.md @@ -9,7 +9,7 @@ Related Features: auth, organization, workflow, agent-builder, connectors, audit Workflow canvas에는 독립형 RAG 실행 노드를 도입하지 않는다. Knowledge retrieval, query rewrite, evidence sufficiency, source tier policy는 LLM node의 RAG 옵션으로 제공한다. -현재 구현은 manual Knowledge Base 생성, 문서 업로드/색인, metadata-aware retrieval, hierarchical RAG, standalone RAG Agent answer 기반을 제공한다. 목표 KB 통합 모델은 [ADR-0014](../../decisions/ADR-0014-knowledge-base-document-atom-and-collection-boundary.md)에 따라 Knowledge Base를 document/source item 단위 permission/retrieval/sync/lifecycle atom으로 재정의하고, Knowledge Collection을 grouping/routing/UX/ops 단위로 둔다. Knowledge Skill 경계는 [ADR-0015](../../decisions/ADR-0015-knowledge-skill-context-routing-boundary.md)를 따른다. MBA-105 구현 baseline은 [ADR-0017](../../decisions/ADR-0017-knowledge-integration-provisional-implementation-baseline.md), [ADR-0018](../../decisions/ADR-0018-workflow-rag-anonymous-public-only-runtime.md), [ADR-0020](../../decisions/ADR-0020-knowledge-mcp-incremental-sync-boundary.md), [implementation_baseline.md](implementation_baseline.md)를 따른다. +현재 구현은 manual Knowledge Base 생성, 문서 업로드/색인, metadata-aware retrieval, hierarchical RAG, standalone RAG Agent answer 기반을 제공한다. 목표 KB 통합 모델은 [ADR-0014](../../decisions/ADR-0014-knowledge-base-document-atom-and-collection-boundary.md)에 따라 Knowledge Base를 document/source item 단위 permission/retrieval/sync/lifecycle atom으로 재정의하고, Knowledge Collection을 grouping/routing/UX/ops 단위로 둔다. Knowledge Skill 경계는 [ADR-0015](../../decisions/ADR-0015-knowledge-skill-context-routing-boundary.md)를 따른다. MBA-105 구현 baseline은 [ADR-0017](../../decisions/ADR-0017-knowledge-integration-provisional-implementation-baseline.md), [ADR-0018](../../decisions/ADR-0018-workflow-rag-anonymous-public-only-runtime.md), [ADR-0020](../../decisions/ADR-0020-knowledge-mcp-incremental-sync-boundary.md), [implementation_baseline.md](implementation_baseline.md)를 따르고, direct KB와 명시 selected Collection의 Workflow runtime candidate 해석은 [ADR-0036](../../decisions/ADR-0036-knowledge-runtime-candidate-resolution.md)을 따른다. ## Current Baseline @@ -19,6 +19,8 @@ Workflow canvas에는 독립형 RAG 실행 노드를 도입하지 않는다. Kno - Knowledge Skill은 [ADR-0015](../../decisions/ADR-0015-knowledge-skill-context-routing-boundary.md)에 따른 provider-neutral target artifact이며, 현재 구현 완료 상태가 아니다. - 현재 `documents.meta_info`는 current metadata convention의 source of truth다. - Workflow LLM node RAG에서 `execution_subject`가 없으면 현재 MVP는 private KB retrieval을 실패시키는 대신 anonymous public-only로 낮춘다. Public-only 후보는 active Knowledge Collection의 `safe_metadata["visibility"] == "public"`에 연결된 active KB로 제한하고, source-managed KB는 별도 source/connector public exposure approval도 통과해야 한다. MBA-176 범위에서 이 approval primitive가 구현되어 있지 않으면 source-managed public 후보는 warning이 아니라 `source_public_exposure_required` blocked로 처리한다. +- 현재 Gateway `KnowledgeCandidateResolver`는 Builder recommendation/deployment preview용이다. Collection scope 생략 시 route-safe subset 또는 직접 권한 KB fallback을 사용할 수 있으며 Workflow runtime authorization primitive가 아니다. MBA-232 runtime resolver는 명시 selected Collection만 받고 organization-wide fallback을 금지하며 Workflow Engine application/adapter 경계가 소유한다. +- MBA-232 source-managed authenticated candidate는 현재 DB에 materialize된 `SourceAuthorizationProvenance`만 사용한다. Live connector authorization과 runtime source cache는 이 단계에 구현하지 않는다. Source public exposure primitive가 없으므로 MBA-232 anonymous public-only resolver는 source-managed KB를 모두 fail-closed 제외한다. - 목표 cutover 전까지 공식 문서는 현재 동작과 목표 모델을 분리해 읽어야 한다. ## Target Model @@ -119,6 +121,15 @@ Workflow canvas에는 독립형 RAG 실행 노드를 도입하지 않는다. Kno - FR-083 (Conversation Memory Target Integration): KB lifecycle, collection public visibility, team/user direct KB permission, organization membership/manager override, source ACL/public exposure policy가 authorization 결과에 영향을 주면 Knowledge authorization decision revision이 변경되어야 한다. - FR-084 (Conversation Memory Target Integration): Anonymous public audience는 synthetic subject ID/revision 없이 `anonymous_public_audience` principal kind로 public collection, active KB와 source public exposure gate를 평가해야 한다. Login cookie나 Conversation Access Grant로 private KB 권한을 높이지 않아야 한다. - FR-085 (Conversation Memory Target Integration): V1 Memory dependency는 optional 의미를 지원하지 않는다. Knowledge evidence가 answer에 영향을 주면 해당 dependency는 모두 필수이며 하나라도 current authorization을 잃으면 derived entry 전체를 제외해야 한다. +- FR-086 (MBA-232): Workflow runtime candidate request는 server-owned canonical organization과 `AuthenticatedAudience(organization_id, user_id)` 또는 `AnonymousPublicAudience(organization_id)` 중 하나, configured direct KB ID, 명시 selected Collection ID와 server candidate budget을 사용한다. Optional user에서 owner/builder/deployment owner/credential principal/service account로 fallback하지 않는다. +- FR-087 (MBA-232): Runtime은 명시 selected Collection만 해석한다. Collection ID 생략과 빈 목록은 모두 Collection stream 0개이며 organization-wide discovery/fallback을 수행하지 않는다. Direct KB는 Collection route 없이 KB `use`와 applicable source gate를 통과하고, Collection child는 Collection `route`와 독립적인 child KB `use`/source gate를 모두 통과해야 한다. +- FR-088 (MBA-232): Runtime candidate는 direct configured order를 먼저 유지하고 남은 budget을 selected Collection configured order의 round-robin으로 채운다. Collection 내부 tie-break는 item rank, item created time, KB UUID이며 canonical KB UUID로 dedupe하고 첫 provenance를 보존한다. 초기 budget은 최대 20 unique KB이고 budget/scan cap 도달은 fixed safe warning을 가진 성공 결과다. +- FR-089 (MBA-232): Runtime resolver PostgreSQL adapter는 invocation마다 fresh transaction을 열고 첫 query 전에 `REPEATABLE READ, READ ONLY`를 적용한다. Collection, membership, lifecycle/readiness, permission과 materialized source provenance는 같은 snapshot을 사용하고 candidate/authorization 결과를 invocation 사이에 cache하지 않는다. +- FR-090 (MBA-232): Authenticated source-managed KB는 active/fresh/matching materialized `SourceAuthorizationProvenance`가 필요하다. Missing, inactive, stale, expired, unmapped, ambiguous, unverified, revoked, denied, unknown 또는 organization/requester/KB/source mismatch는 fail-closed다. MBA-232는 connector client, `check_access_batch`, single `check_access` 또는 runtime source cache를 호출하지 않는다. +- FR-091 (MBA-232): Anonymous public audience는 normal Team/User/domain permission을 소비하지 않는다. Selected Collection child는 active public Collection membership, direct KB는 하나 이상의 active public Collection membership이 필요하다. Public exposure primitive가 없는 현재 schema에서 source-managed KB는 public Collection membership이나 authenticated provenance와 무관하게 모두 제외한다. +- FR-092 (MBA-232): `KnowledgeCollectionItem`은 lifecycle object가 아니다. Present row는 linked, unlink/missing은 membership 없음이며 Collection과 child KB lifecycle/readiness를 독립적으로 평가한다. +- FR-093 (MBA-232): Candidate policy exclusion은 identity를 노출하지 않는 safe omission이고 0개는 `safe_no_result`다. DB/session/snapshot/repository/authorization infrastructure failure는 이미 평가한 후보를 반환하지 않는 whole-resolution retryable safe failure이며 retrieval/provider 호출 전에 끝나야 한다. Partial authorized-KB retrieval timeout은 downstream Retrieval Orchestrator/MBA-233 범위다. +- FR-094 (MBA-232): Shared에는 framework/ORM/runtime concrete import가 없는 immutable contract와 pure merge policy만 두고 Workflow Engine `runtime_retrieval` application use case/port와 PostgreSQL adapter/composition이 concrete resolution을 소유한다. Gateway API, graph schema, Builder/preflight, LLM node와 retrieval wiring은 MBA-233 전까지 변경하지 않는다. ## Policies And Edge Cases @@ -141,13 +152,13 @@ Workflow canvas에는 독립형 RAG 실행 노드를 도입하지 않는다. Kno - Raw source content 조회는 Agent answer나 SSE stream과 분리된 raw/compliance flow로만 허용한다. 요청은 active organization, KB visibility, raw/compliance permission, source-managed KB의 fresh source ACL, retention/legal hold/purge policy, raw access audit 선기록을 모두 통과해야 한다. - PII/secret redaction policy는 output target별로 다르게 적용한다. Chunk/embedding/retrieval-visible text는 redacted canonical text, citation preview는 redacted+capped preview, audit/trace/log는 allowlist summary, raw/compliance view는 별도 권한 flow를 사용한다. - Collection list/router metadata는 authorized subset 기준으로만 계산한다. Exact child KB count, denied/hidden count, source distribution, unauthorized child에서 유래한 tag/category aggregate는 omit, bucket, 또는 request-scoped safe aggregate로 낮춘다. -- Auto collection router 입력에는 collection route scope와 KB permission/source ACL helper 결과를 통과한 authorized safe candidate와 safe metadata만 전달한다. Missing `collection_ids`는 organization 전체가 아니라 서버 정책상 route-allowed collection subset에서 시작한다. Raw source ACL, raw source id/url, exact hidden document count, exact denied count는 전달하지 않는다. +- Builder/recommendation auto collection router 입력에는 collection route scope와 KB permission/source ACL helper 결과를 통과한 authorized safe candidate와 safe metadata만 전달한다. 이 Builder 경계에서 missing `collection_ids`는 organization 전체가 아니라 서버 정책상 route-allowed collection subset에서 시작할 수 있다. MBA-232 Workflow runtime에서는 missing/empty `collection_ids`가 항상 Collection stream 0개이며 같은 fallback을 사용하지 않는다. Raw source ACL, raw source id/url, exact hidden document count, exact denied count는 전달하지 않는다. - Partial result 표시에는 `partial_result=true`, bucketed failed candidate count 또는 safe reason summary, retryability만 허용한다. Exact failed KB id/source distribution은 기본 저장하지 않는다. - Explicit KB mode는 collection.route를 생략할 수 있지만 KB helper/source ACL/final evidence gate를 생략할 수 없다. Explicit KB id가 scope 밖, organization mismatch, deleted/archived, requester source authorization denied, source ACL stale/unmapped/ambiguous/unverified/revoked, permission-unverified인 경우의 응답 shape와 answer-run/audit 생성 여부는 ADR-0017 resource hiding baseline을 따른다. - Organization manager remediation/admin view는 읽을 수 없는 source-managed KB에 대해 기본적으로 safe metadata와 remediation reason code만 표시한다. Raw title/path/url/content/source principal 표시에는 별도 display/raw-access policy gate가 필요하다. - Active version finalization은 indexing 성공 전 기존 active version을 비활성화하지 않는다. Crash/recovery/outbox/fencing token 계약은 ADR-0017 baseline에 따라 구현한다. - Builder/recommendation 후보는 retrieval-visible active version 경계를 따른다. `source_deleted` KB, active ready document version이 없는 KB는 기본적으로 후보에서 제외하지만, 전환기 legacy unversioned retrieval-visible chunk가 있는 KB는 후보로 유지할 수 있다. Active document version이 있으나 `ready`가 아니고 legacy retrieval-visible artifact도 없는 KB는 selectable ready 후보가 아니며, Builder/Recommendation surface는 이를 숨겨진 KB나 권한 없음으로 표현하지 않고 safe `indexing/not-ready` warning 또는 disabled option으로 표시해야 한다. 기존 active ready version은 유지되지만 최신 sync 상태가 `stale` 또는 `failed`인 KB는 후보로 남길 수 있으나, safe warning과 score penalty 또는 낮은 confidence를 함께 제공해야 한다. -- Builder/recommendation auto mode에서 route-allowed collection link 후보가 없고 client가 collection scope를 명시하지 않은 경우, resolver는 같은 active organization 안의 직접 권한 확인된 retrieval-visible KB도 safe candidate set으로 평가할 수 있다. 명시적으로 빈 collection scope를 보낸 경우에는 direct KB fallback을 적용하지 않는다. +- Builder/recommendation auto mode에서 route-allowed collection link 후보가 없고 client가 collection scope를 명시하지 않은 경우, Gateway resolver는 같은 active organization 안의 직접 권한 확인된 retrieval-visible KB도 safe candidate set으로 평가할 수 있다. 명시적으로 빈 collection scope를 보낸 경우에는 direct KB fallback을 적용하지 않는다. 이 Builder 편의 동작은 MBA-232 Workflow runtime resolver에 적용하지 않는다. - Destructive reset/reindex, legacy multi-document KB split/backfill, existing `team_knowledge_permissions`/RAG answer reference handling은 G1 data-preservation gate 승인 후에만 진행한다. - A/B 테스트나 비용 최적화 UI에서 `general RAG` baseline을 보여줄 때도 권한 없는 문서가 prompt, citation, trace, audit에 들어가면 안 된다. 보안상 안전하지 않은 baseline은 운영 실행이 아니라 historical, simulated, admin-only, 또는 이미 execution subject에게 허용된 resource 안의 비교로 제한한다. - 일반 사용자와 workflow 작성자 화면에는 권한/정책상 제외된 문서명, KB id, source path/url/title, 정확한 제외 개수를 표시하지 않는다. 필요한 경우 `권한/정책상 제외된 내부 문서 일부`, bucketed count, safe reason summary 같은 낮은 해상도의 표현만 사용한다. diff --git a/docs/features/knowledge/test_cases.md b/docs/features/knowledge/test_cases.md index 8431a471d..c51c66838 100644 --- a/docs/features/knowledge/test_cases.md +++ b/docs/features/knowledge/test_cases.md @@ -61,6 +61,26 @@ Status: Draft - Collection role bundle은 Viewer=`read`, Workflow Router=`read+route`, Maintainer=`read+manage`, Sync Operator=`read+sync` explicit row를 한 transaction에서 적용한다. 일부 row 또는 audit 저장 실패 시 bundle 전체를 rollback하고 KB `use` row를 만들지 않는다. - Domain `catalog_manage` actor는 private manual Collection과 membership을 관리할 수 있지만 public membership 변경은 Organization manager acknowledgement 없이는 차단된다. Source public exposure primitive가 없으면 source-managed KB의 public link/visibility 전환은 `source_public_exposure_required`로 fail-closed된다. +## MBA-232 Workflow Runtime Candidate Resolver Tests + +- Shared runtime contract는 `AuthenticatedAudience(organization_id, user_id)`와 `AnonymousPublicAudience(organization_id)`만 허용하고 optional subject, owner, builder, deployment owner, credential principal, service account fallback을 표현하지 않는다. +- Runtime `collection_ids` missing/empty는 Collection stream 0개다. Gateway Builder resolver의 route-safe subset/direct-KB fallback을 호출하거나 organization 전체 Collection을 query하면 테스트 실패다. +- Direct KB는 Collection route 없이 KB `use`와 applicable materialized source gate를 통과할 수 있다. Collection child는 selected active Collection `route`와 독립적인 child KB `use`/source gate를 모두 통과해야 한다. +- Collection `read/manage/sync`와 Knowledge domain `catalog_manage`, `permission_delegate`, `lifecycle_manage`, `sync_manage`만 가진 actor는 runtime candidate를 얻지 못한다. +- Direct candidates는 configured order를 유지하고 남은 budget은 selected Collection configured order의 round-robin으로 채운다. Collection 내부 tie-break는 item rank, item created time, KB UUID다. +- Direct와 여러 Collection에 중복된 KB는 canonical KB UUID로 한 번만 반환하고 처음 허용된 provenance를 유지한다. Duplicate를 건너뛴 뒤 뒤쪽 unique KB로 budget을 계속 채운다. +- Candidate budget 19/20 boundary는 deterministic success이고 21 이상의 direct/Collection reference 또는 budget 20 초과 request는 configuration validation에서 silent truncation 없이 차단한다. Dynamic membership overflow는 fixed `candidate_budget_limited` safe warning을 반환한다. +- `KnowledgeCollectionItem`에는 lifecycle을 가정하지 않는다. Present row는 linked, unlink/missing은 후보 없음이며 Collection과 child KB lifecycle을 따로 검증한다. +- Active ready version 또는 documented completed-document unversioned legacy chunk fallback만 ready다. Archived/deleted/source_deleted/non-ready/pre-finalized 후보는 identity 없이 제외한다. +- Authenticated source-managed KB는 active/fresh/unexpired/matching materialized `SourceAuthorizationProvenance`가 필요하다. Missing/inactive/stale/unmapped/ambiguous/unverified/revoked/denied/unknown/expired/organization-requester-KB-source mismatch는 fail-closed다. +- MBA-232 adapter는 connector client, HTTP client, `check_access_batch`, single `check_access`, runtime source authorization cache를 0회 호출한다. +- Anonymous selected Collection child는 active public Collection의 active/ready manual KB만 허용한다. Direct manual KB도 하나 이상의 active public Collection membership이 필요하다. Source public exposure primitive가 없는 동안 source-managed KB는 public membership과 authenticated provenance가 있어도 모두 제외한다. +- PostgreSQL adapter는 fresh transaction의 첫 query 전에 `REPEATABLE READ, READ ONLY`를 적용한다. Two-transaction test에서 resolver 시작 뒤 membership/route/use/provenance/lifecycle 변경이 commit되어도 current invocation은 한 snapshot만 보고 다음 invocation이 변경을 본다. +- Snapshot/repository/authorization infrastructure exception은 fixed safe retryable whole-resolution failure다. 이미 평가한 candidate partial set, raw SQL/exception, identifier, source metadata, exact count를 반환하거나 retrieval/provider mock을 호출하면 테스트 실패다. +- Candidate 0개는 `safe_no_result`, budget 제한은 successful warning이며 downstream partial retrieval failure와 구분한다. +- Query count는 candidate/Collection 수에 비례하는 N+1이 아니고 selected 20 Collections/5,000 membership fixture에서도 scan/memory/result가 bounded하고 fair해야 한다. +- Shared pure policy는 SQLAlchemy/FastAPI/Celery/Gateway/Workflow Engine concrete package를 import하지 않고 Workflow Engine runtime retrieval production code는 `apps.gateway.*`를 import하지 않는다. + ## Knowledge Base API Tests - KB create는 blank name을 DB insert 전에 거부하고 safe validation reason code만 반환한다. @@ -156,7 +176,7 @@ Status: Draft - Auto mode는 collection route helper와 KB permission/source ACL helper 결과로 candidate set을 만든다. - Auto mode의 collection/KB cap은 authorization 전 임의 row cap이 아니라 route/use/source ACL helper를 통과한 authorized subset에 적용한다. -- Auto mode에서 명시 `collection_ids`가 없으면 organization 전체 collection이 아니라 actor가 route할 수 있는 collection subset에서 시작한다. +- Builder/recommendation Auto mode에서 명시 `collection_ids`가 없으면 organization 전체 collection이 아니라 actor가 route할 수 있는 collection subset에서 시작한다. MBA-232 Workflow runtime은 missing/empty Collection scope를 0개로 유지하며 이 fallback을 사용하지 않는다. - Router는 authorized safe candidate와 safe metadata만 받는다. - Router는 raw source ACL fact, hidden KB id, raw source title/path/url, exact hidden count, raw content를 받지 않는다. - Knowledge RAG Recommendation Adapter는 `KnowledgeCandidateResolver`가 반환한 safe KB candidate만 ranking하고, permission/source ACL row를 직접 조회하거나 해석하지 않는다. @@ -179,7 +199,7 @@ Status: Draft - 권한 확인된 KB에 document row나 pre-finalized chunk artifact가 있지만 active ready version 또는 legacy retrieval-visible chunk가 없으면, Recommendation/Builder picker는 이를 권한 없음이나 숨겨진 KB처럼 조용히 숨기지 않고 `candidate_not_ready` 또는 `indexing_in_progress` 수준의 safe warning/disabled option으로 표시해야 한다. - KB detail response의 `documents[].chunk_count`와 LLM node Knowledge Base picker의 selectable-ready 판단은 같은 retrieval-visible 기준을 사용해야 한다. Active ready document version이 있으면 해당 version chunk만 세고, active version pointer가 없는 legacy KB는 legacy unversioned chunk만 fallback으로 센다. - Completed가 아닌 document, `ready`가 아닌 active/pending version, superseded/failed/pre-finalized version chunk, active version과 연결되지 않은 stale chunk는 `documents[].chunk_count`와 selectable-ready 판단에 포함하지 않는다. -- Auto mode에서 route-allowed collection link 후보가 없고 client가 collection scope를 명시하지 않은 경우, resolver는 직접 권한 확인된 retrieval-visible KB를 fallback 후보로 반환할 수 있다. 명시적으로 빈 collection scope를 보낸 경우에는 direct fallback을 적용하지 않고 후보 없음으로 유지해야 한다. +- Builder/recommendation Auto mode에서 route-allowed collection link 후보가 없고 client가 collection scope를 명시하지 않은 경우, Gateway resolver는 직접 권한 확인된 retrieval-visible KB를 fallback 후보로 반환할 수 있다. 명시적으로 빈 collection scope를 보낸 경우에는 direct fallback을 적용하지 않고 후보 없음으로 유지해야 한다. 이 동작을 MBA-232 Workflow runtime resolver에 재사용하면 테스트 실패다. - 기존 active ready version은 유지되지만 sync state가 `stale` 또는 `failed`인 KB는 후보로 남을 수 있으며, safe warning과 score penalty 또는 낮은 confidence가 함께 반환되어야 한다. - Adapter unavailable이고 권한 확인된 safe 후보 선택지가 있으면 `status=clarification_required`, `fallback_reason=adapter_unavailable`, `clarification_options`를 반환해야 한다. Safe 후보 선택지도 없으면 `status=unavailable`과 safe fallback reason으로 닫아야 한다. - Recommendation provenance는 `recommendation_strategy`, `safe_reason_code`, `used_signals`, safe matched terms, bucketed counts 같은 allowlist만 포함하고 raw source title/path/url, hidden id/name, exact denied count를 포함하지 않는다. @@ -192,6 +212,7 @@ Status: Draft - `subject_type="organization"` source-policy KB use grant는 active organization member에게만 적용되고 removed/suspended/invited/non-member user에게는 적용되지 않는다. - Runtime source authorization은 `check_access_batch`를 우선 사용하고, batch 미지원 source의 single `check_access` fallback은 bounded concurrency, per-call timeout, aggregate timeout을 강제한다. - `check_access_batch`가 일부 `denied`, `unknown`, timeout을 반환하면 해당 evidence만 fail-closed 제외되고 raw source error나 denied item title/path는 응답/trace/log에 남지 않는다. +- 위 두 live runtime source authorization 항목은 후속 target flow다. MBA-232 candidate resolver test는 materialized provenance만 소비하고 live batch/single/cache 호출이 전혀 없음을 별도로 고정한다. - 운영 `general RAG`도 KB permission/source ACL/final evidence gate를 통과한다. Test fixture에서 권한 없는 문서는 `general`, `permission_scoped`, `task_aware` 모든 mode의 prompt/citation/trace에 들어가지 않는다. - `general RAG`는 authorized resource 안의 broad retrieval로 동작하고, `task_aware` 또는 `permission_scoped` mode는 같은 authorized resource 안에서 더 작은 evidence set을 선택한다. - Query rewrite가 켜져도 user query와 safe skill/template만 입력으로 사용하며, 권한 없는 KB/문서를 candidate로 만들지 못한다. @@ -294,7 +315,7 @@ Status: Draft - Bulk permission helper는 per-KB database query 없이 user-candidate lookup과 KB-centric lookup을 처리한다. - Candidate cap은 stable ordering으로 큰 candidate set을 deterministic하게 잘라낸다. -- Candidate cache key는 permission/freshness epoch를 포함하고 ACL revocation 시 invalidation된다. +- MBA-232 runtime candidate ID/authorization은 invocation 사이에 cache하지 않는다. 향후 별도 승인된 candidate cache는 permission/freshness revision을 포함하고 ACL revocation 시 invalidation되어야 한다. - Runtime access cache key는 mapping epoch와 source ACL freshness epoch를 포함하고, source item/document version 단위로 분리된다. - Skill candidate cache key는 skill version, freshness state, eval state, source version reference를 포함하고 stale skill/source-tier 변경 시 invalidation된다. - 단일 filtered vector/keyword query를 우선한다. Bounded fanout을 사용하면 concurrency와 timeout cap을 강제한다. From 31d92d54ed176aed5e515c1557e09e99df12fb36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=95=EB=AF=BC?= Date: Mon, 13 Jul 2026 15:08:08 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat(knowledge):=20=EB=9F=B0=ED=83=80?= =?UTF-8?q?=EC=9E=84=20=ED=9B=84=EB=B3=B4=20=EC=A0=95=EC=B1=85=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/knowledge_runtime_candidates.py | 416 ++++++++++++++++++ .../test_knowledge_runtime_candidates.py | 255 +++++++++++ 2 files changed, 671 insertions(+) create mode 100644 apps/shared/domain/knowledge_runtime_candidates.py create mode 100644 apps/shared/tests/domain/test_knowledge_runtime_candidates.py diff --git a/apps/shared/domain/knowledge_runtime_candidates.py b/apps/shared/domain/knowledge_runtime_candidates.py new file mode 100644 index 000000000..fea561c72 --- /dev/null +++ b/apps/shared/domain/knowledge_runtime_candidates.py @@ -0,0 +1,416 @@ +"""Pure policy for Workflow runtime Knowledge candidate resolution. + +This module intentionally has no framework, ORM, queue, or concrete runtime +imports. Outbound adapters may only project already-authorized, ready facts into +the immutable snapshot contract defined here. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal, TypeAlias +from uuid import UUID + +DEFAULT_RUNTIME_CANDIDATE_BUDGET = 20 +MAX_RUNTIME_CANDIDATE_BUDGET = 20 +MAX_RUNTIME_DIRECT_KB_REFERENCES = 20 +MAX_RUNTIME_COLLECTION_REFERENCES = 20 +DEFAULT_RUNTIME_CANDIDATE_SCAN_CAP = 5000 +MAX_RUNTIME_CANDIDATE_SCAN_CAP = 5000 + +AudienceKind: TypeAlias = Literal["authenticated", "anonymous_public"] +CandidateProvenanceKind: TypeAlias = Literal["direct", "collection"] +CandidateResolutionStatus: TypeAlias = Literal["resolved", "safe_no_result"] +CandidateRoutingMode: TypeAlias = Literal["direct", "collection", "mixed", "none"] + + +class KnowledgeRuntimeCandidateConfigurationError(ValueError): + """A fixed-code, non-retryable server-owned request error.""" + + def __init__(self, reason_code: str) -> None: + self.reason_code = reason_code + super().__init__(reason_code) + + +def _require_uuid(value: object, *, reason_code: str) -> UUID: + if not isinstance(value, UUID): + raise KnowledgeRuntimeCandidateConfigurationError(reason_code) + return value + + +def _validated_uuid_tuple( + values: object, + *, + reason_code: str, + deduplicate: bool, +) -> tuple[UUID, ...]: + if isinstance(values, (str, bytes)): + raise KnowledgeRuntimeCandidateConfigurationError(reason_code) + try: + raw_values = tuple(values) # type: ignore[arg-type] + except TypeError as exc: + raise KnowledgeRuntimeCandidateConfigurationError(reason_code) from exc + + result: list[UUID] = [] + seen: set[UUID] = set() + for raw_value in raw_values: + value = _require_uuid(raw_value, reason_code=reason_code) + if deduplicate and value in seen: + continue + seen.add(value) + result.append(value) + return tuple(result) + + +@dataclass(frozen=True, slots=True) +class AuthenticatedAudience: + organization_id: UUID + user_id: UUID + kind: Literal["authenticated"] = field(default="authenticated", init=False) + + def __post_init__(self) -> None: + _require_uuid(self.organization_id, reason_code="audience_organization_invalid") + _require_uuid(self.user_id, reason_code="audience_user_invalid") + + +@dataclass(frozen=True, slots=True) +class AnonymousPublicAudience: + organization_id: UUID + kind: Literal["anonymous_public"] = field( + default="anonymous_public", + init=False, + ) + + def __post_init__(self) -> None: + _require_uuid(self.organization_id, reason_code="audience_organization_invalid") + + +KnowledgeRuntimeAudience: TypeAlias = AuthenticatedAudience | AnonymousPublicAudience + + +@dataclass(frozen=True, slots=True) +class KnowledgeRuntimeCandidateRequest: + audience: KnowledgeRuntimeAudience + direct_kb_ids: tuple[UUID, ...] = () + collection_ids: tuple[UUID, ...] = () + candidate_budget: int = DEFAULT_RUNTIME_CANDIDATE_BUDGET + candidate_scan_cap: int = DEFAULT_RUNTIME_CANDIDATE_SCAN_CAP + + def __post_init__(self) -> None: + if not isinstance( + self.audience, + (AuthenticatedAudience, AnonymousPublicAudience), + ): + raise KnowledgeRuntimeCandidateConfigurationError("audience_invalid") + + direct_kb_ids = _validated_uuid_tuple( + self.direct_kb_ids, + reason_code="direct_reference_invalid", + deduplicate=True, + ) + collection_ids = _validated_uuid_tuple( + self.collection_ids, + reason_code="collection_reference_invalid", + deduplicate=True, + ) + if len(direct_kb_ids) > MAX_RUNTIME_DIRECT_KB_REFERENCES: + raise KnowledgeRuntimeCandidateConfigurationError( + "direct_reference_limit_exceeded" + ) + if len(collection_ids) > MAX_RUNTIME_COLLECTION_REFERENCES: + raise KnowledgeRuntimeCandidateConfigurationError( + "collection_reference_limit_exceeded" + ) + if ( + not isinstance(self.candidate_budget, int) + or isinstance(self.candidate_budget, bool) + or not 1 + <= self.candidate_budget + <= MAX_RUNTIME_CANDIDATE_BUDGET + ): + raise KnowledgeRuntimeCandidateConfigurationError( + "candidate_budget_invalid" + ) + if ( + not isinstance(self.candidate_scan_cap, int) + or isinstance(self.candidate_scan_cap, bool) + or not self.candidate_budget + <= self.candidate_scan_cap + <= MAX_RUNTIME_CANDIDATE_SCAN_CAP + ): + raise KnowledgeRuntimeCandidateConfigurationError( + "candidate_scan_cap_invalid" + ) + + object.__setattr__(self, "direct_kb_ids", direct_kb_ids) + object.__setattr__(self, "collection_ids", collection_ids) + + @property + def organization_id(self) -> UUID: + return self.audience.organization_id + + +@dataclass(frozen=True, slots=True) +class KnowledgeCollectionCandidateStream: + collection_id: UUID + eligible_kb_ids: tuple[UUID, ...] = () + + def __post_init__(self) -> None: + _require_uuid( + self.collection_id, + reason_code="snapshot_collection_reference_invalid", + ) + object.__setattr__( + self, + "eligible_kb_ids", + _validated_uuid_tuple( + self.eligible_kb_ids, + reason_code="snapshot_kb_reference_invalid", + deduplicate=False, + ), + ) + + +@dataclass(frozen=True, slots=True) +class KnowledgeRuntimeCandidateSnapshot: + """Authorized and ready facts loaded in one invocation snapshot.""" + + eligible_direct_kb_ids: tuple[UUID, ...] = () + collection_streams: tuple[KnowledgeCollectionCandidateStream, ...] = () + policy_excluded_count: int = 0 + scan_limited: bool = False + + def __post_init__(self) -> None: + object.__setattr__( + self, + "eligible_direct_kb_ids", + _validated_uuid_tuple( + self.eligible_direct_kb_ids, + reason_code="snapshot_kb_reference_invalid", + deduplicate=False, + ), + ) + if isinstance(self.collection_streams, (str, bytes)): + raise ValueError("snapshot_collection_streams_invalid") + try: + streams = tuple(self.collection_streams) + except TypeError as exc: + raise ValueError("snapshot_collection_streams_invalid") from exc + if not all( + isinstance(stream, KnowledgeCollectionCandidateStream) + for stream in streams + ): + raise ValueError("snapshot_collection_streams_invalid") + if ( + not isinstance(self.policy_excluded_count, int) + or isinstance(self.policy_excluded_count, bool) + or self.policy_excluded_count < 0 + ): + raise ValueError("snapshot_policy_excluded_count_invalid") + if not isinstance(self.scan_limited, bool): + raise ValueError("snapshot_scan_limited_invalid") + object.__setattr__(self, "collection_streams", streams) + + +@dataclass(frozen=True, slots=True) +class KnowledgeRuntimeCandidateProvenance: + kind: CandidateProvenanceKind + collection_id: UUID | None = None + + def __post_init__(self) -> None: + if self.kind == "direct": + if self.collection_id is not None: + raise ValueError("direct_candidate_collection_invalid") + return + if self.kind == "collection": + _require_uuid( + self.collection_id, + reason_code="candidate_collection_reference_invalid", + ) + return + raise ValueError("candidate_provenance_invalid") + + +@dataclass(frozen=True, slots=True) +class KnowledgeRuntimeCandidate: + knowledge_base_id: UUID + provenance: KnowledgeRuntimeCandidateProvenance + + def __post_init__(self) -> None: + _require_uuid( + self.knowledge_base_id, + reason_code="candidate_kb_reference_invalid", + ) + + +@dataclass(frozen=True, slots=True) +class KnowledgeRuntimeCandidateResolution: + status: CandidateResolutionStatus + candidates: tuple[KnowledgeRuntimeCandidate, ...] + routing_mode: CandidateRoutingMode + configured_direct_count_bucket: str + configured_collection_count_bucket: str + eligible_candidate_count_bucket: str + selected_candidate_count_bucket: str + policy_excluded_count_bucket: str + budget_limited: bool + scan_limited: bool + warning_codes: tuple[str, ...] + reason_code: str | None = None + + +def bucket_safe_count(value: int) -> str: + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise ValueError("safe_count_invalid") + if value == 0: + return "0" + if value == 1: + return "1" + if value <= 10: + return "2-10" + if value <= 100: + return "11-100" + return "100+" + + +def _ordered_collection_streams( + request: KnowledgeRuntimeCandidateRequest, + snapshot: KnowledgeRuntimeCandidateSnapshot, +) -> tuple[KnowledgeCollectionCandidateStream, ...]: + selected_collection_ids = set(request.collection_ids) + first_stream_by_collection: dict[UUID, KnowledgeCollectionCandidateStream] = {} + for stream in snapshot.collection_streams: + if stream.collection_id not in selected_collection_ids: + continue + first_stream_by_collection.setdefault(stream.collection_id, stream) + return tuple( + first_stream_by_collection[collection_id] + for collection_id in request.collection_ids + if collection_id in first_stream_by_collection + ) + + +def _all_ordered_unique_candidates( + request: KnowledgeRuntimeCandidateRequest, + snapshot: KnowledgeRuntimeCandidateSnapshot, +) -> list[KnowledgeRuntimeCandidate]: + ordered: list[KnowledgeRuntimeCandidate] = [] + seen: set[UUID] = set() + + eligible_direct_ids = set(snapshot.eligible_direct_kb_ids) + for knowledge_base_id in request.direct_kb_ids: + if knowledge_base_id not in eligible_direct_ids or knowledge_base_id in seen: + continue + seen.add(knowledge_base_id) + ordered.append( + KnowledgeRuntimeCandidate( + knowledge_base_id=knowledge_base_id, + provenance=KnowledgeRuntimeCandidateProvenance(kind="direct"), + ) + ) + + streams = _ordered_collection_streams(request, snapshot) + stream_indexes = [0 for _stream in streams] + while streams: + added_in_round = False + remaining = False + for stream_index, stream in enumerate(streams): + item_index = stream_indexes[stream_index] + while item_index < len(stream.eligible_kb_ids): + remaining = True + knowledge_base_id = stream.eligible_kb_ids[item_index] + item_index += 1 + stream_indexes[stream_index] = item_index + if knowledge_base_id in seen: + continue + seen.add(knowledge_base_id) + ordered.append( + KnowledgeRuntimeCandidate( + knowledge_base_id=knowledge_base_id, + provenance=KnowledgeRuntimeCandidateProvenance( + kind="collection", + collection_id=stream.collection_id, + ), + ) + ) + added_in_round = True + break + if not remaining or not added_in_round: + break + return ordered + + +def _routing_mode( + candidates: tuple[KnowledgeRuntimeCandidate, ...], +) -> CandidateRoutingMode: + kinds = {candidate.provenance.kind for candidate in candidates} + if kinds == {"direct"}: + return "direct" + if kinds == {"collection"}: + return "collection" + if kinds == {"direct", "collection"}: + return "mixed" + return "none" + + +def resolve_knowledge_runtime_candidates( + request: KnowledgeRuntimeCandidateRequest, + snapshot: KnowledgeRuntimeCandidateSnapshot, +) -> KnowledgeRuntimeCandidateResolution: + if not isinstance(request, KnowledgeRuntimeCandidateRequest): + raise KnowledgeRuntimeCandidateConfigurationError("request_invalid") + if not isinstance(snapshot, KnowledgeRuntimeCandidateSnapshot): + raise ValueError("snapshot_invalid") + + ordered_candidates = _all_ordered_unique_candidates(request, snapshot) + candidates = tuple(ordered_candidates[: request.candidate_budget]) + budget_limited = len(ordered_candidates) > request.candidate_budget + warning_codes: list[str] = [] + if budget_limited: + warning_codes.append("candidate_budget_limited") + if snapshot.scan_limited: + warning_codes.append("candidate_scan_limited") + + status: CandidateResolutionStatus = "resolved" if candidates else "safe_no_result" + return KnowledgeRuntimeCandidateResolution( + status=status, + candidates=candidates, + routing_mode=_routing_mode(candidates), + configured_direct_count_bucket=bucket_safe_count(len(request.direct_kb_ids)), + configured_collection_count_bucket=bucket_safe_count( + len(request.collection_ids) + ), + eligible_candidate_count_bucket=bucket_safe_count(len(ordered_candidates)), + selected_candidate_count_bucket=bucket_safe_count(len(candidates)), + policy_excluded_count_bucket=bucket_safe_count( + snapshot.policy_excluded_count + ), + budget_limited=budget_limited, + scan_limited=snapshot.scan_limited, + warning_codes=tuple(warning_codes), + reason_code=( + None if candidates else "knowledge_candidates.safe_no_result" + ), + ) + + +__all__ = [ + "AnonymousPublicAudience", + "AuthenticatedAudience", + "DEFAULT_RUNTIME_CANDIDATE_BUDGET", + "DEFAULT_RUNTIME_CANDIDATE_SCAN_CAP", + "KnowledgeCollectionCandidateStream", + "KnowledgeRuntimeAudience", + "KnowledgeRuntimeCandidate", + "KnowledgeRuntimeCandidateConfigurationError", + "KnowledgeRuntimeCandidateProvenance", + "KnowledgeRuntimeCandidateRequest", + "KnowledgeRuntimeCandidateResolution", + "KnowledgeRuntimeCandidateSnapshot", + "MAX_RUNTIME_CANDIDATE_BUDGET", + "MAX_RUNTIME_CANDIDATE_SCAN_CAP", + "MAX_RUNTIME_COLLECTION_REFERENCES", + "MAX_RUNTIME_DIRECT_KB_REFERENCES", + "bucket_safe_count", + "resolve_knowledge_runtime_candidates", +] diff --git a/apps/shared/tests/domain/test_knowledge_runtime_candidates.py b/apps/shared/tests/domain/test_knowledge_runtime_candidates.py new file mode 100644 index 000000000..c7ae7c736 --- /dev/null +++ b/apps/shared/tests/domain/test_knowledge_runtime_candidates.py @@ -0,0 +1,255 @@ +from dataclasses import FrozenInstanceError +from uuid import UUID + +import pytest + +from apps.shared.domain.knowledge_runtime_candidates import ( + AnonymousPublicAudience, + AuthenticatedAudience, + KnowledgeCollectionCandidateStream, + KnowledgeRuntimeCandidateConfigurationError, + KnowledgeRuntimeCandidateRequest, + KnowledgeRuntimeCandidateSnapshot, + bucket_safe_count, + resolve_knowledge_runtime_candidates, +) + + +def _id(value: int) -> UUID: + return UUID(int=value) + + +def _request( + *, + direct: tuple[UUID, ...] = (), + collections: tuple[UUID, ...] = (), + budget: int = 20, + scan_cap: int = 5000, +) -> KnowledgeRuntimeCandidateRequest: + return KnowledgeRuntimeCandidateRequest( + audience=AuthenticatedAudience( + organization_id=_id(9000), + user_id=_id(9001), + ), + direct_kb_ids=direct, + collection_ids=collections, + candidate_budget=budget, + candidate_scan_cap=scan_cap, + ) + + +def _snapshot( + *, + direct: tuple[UUID, ...] = (), + streams: tuple[KnowledgeCollectionCandidateStream, ...] = (), + excluded: int = 0, + scan_limited: bool = False, +) -> KnowledgeRuntimeCandidateSnapshot: + return KnowledgeRuntimeCandidateSnapshot( + eligible_direct_kb_ids=direct, + collection_streams=streams, + policy_excluded_count=excluded, + scan_limited=scan_limited, + ) + + +def _stream(collection: int, *knowledge_bases: int): + return KnowledgeCollectionCandidateStream( + collection_id=_id(collection), + eligible_kb_ids=tuple(_id(value) for value in knowledge_bases), + ) + + +def test_audience_contract_is_explicit_and_immutable(): + authenticated = AuthenticatedAudience( + organization_id=_id(1), + user_id=_id(2), + ) + anonymous = AnonymousPublicAudience(organization_id=_id(1)) + + assert authenticated.kind == "authenticated" + assert anonymous.kind == "anonymous_public" + assert not hasattr(anonymous, "user_id") + with pytest.raises(FrozenInstanceError): + authenticated.user_id = _id(3) # type: ignore[misc] + + +def test_request_deduplicates_references_by_first_configured_position(): + request = _request( + direct=(_id(3), _id(1), _id(3), _id(2)), + collections=(_id(12), _id(11), _id(12)), + ) + + assert request.direct_kb_ids == (_id(3), _id(1), _id(2)) + assert request.collection_ids == (_id(12), _id(11)) + + +@pytest.mark.parametrize( + ("kwargs", "reason_code"), + [ + ({"budget": 0}, "candidate_budget_invalid"), + ({"budget": 21}, "candidate_budget_invalid"), + ({"budget": 20, "scan_cap": 19}, "candidate_scan_cap_invalid"), + ({"scan_cap": 5001}, "candidate_scan_cap_invalid"), + ( + {"direct": tuple(_id(value) for value in range(1, 22))}, + "direct_reference_limit_exceeded", + ), + ( + {"collections": tuple(_id(value) for value in range(101, 122))}, + "collection_reference_limit_exceeded", + ), + ], +) +def test_request_rejects_invalid_server_owned_limits(kwargs, reason_code): + with pytest.raises(KnowledgeRuntimeCandidateConfigurationError) as exc_info: + _request(**kwargs) + + assert exc_info.value.reason_code == reason_code + assert str(exc_info.value) == reason_code + + +def test_direct_candidates_are_pinned_before_collection_round_robin(): + request = _request( + direct=(_id(1), _id(2)), + collections=(_id(101), _id(102)), + ) + snapshot = _snapshot( + direct=(_id(2), _id(1)), + streams=(_stream(102, 4, 6), _stream(101, 3, 5)), + ) + + result = resolve_knowledge_runtime_candidates(request, snapshot) + + assert [candidate.knowledge_base_id for candidate in result.candidates] == [ + _id(1), + _id(2), + _id(3), + _id(4), + _id(5), + _id(6), + ] + assert [candidate.provenance.kind for candidate in result.candidates] == [ + "direct", + "direct", + "collection", + "collection", + "collection", + "collection", + ] + assert result.routing_mode == "mixed" + + +def test_duplicate_keeps_first_provenance_and_does_not_consume_round(): + request = _request( + direct=(_id(1),), + collections=(_id(101), _id(102)), + ) + snapshot = _snapshot( + direct=(_id(1),), + streams=(_stream(101, 1, 2, 3), _stream(102, 2, 4)), + ) + + result = resolve_knowledge_runtime_candidates(request, snapshot) + + assert [candidate.knowledge_base_id for candidate in result.candidates] == [ + _id(1), + _id(2), + _id(4), + _id(3), + ] + assert result.candidates[0].provenance.kind == "direct" + assert result.candidates[1].provenance.collection_id == _id(101) + assert result.candidates[2].provenance.collection_id == _id(102) + + +def test_unselected_collection_and_unconfigured_direct_fact_cannot_widen_scope(): + request = _request( + direct=(_id(1), _id(2)), + collections=(_id(101),), + ) + snapshot = _snapshot( + direct=(_id(99), _id(2)), + streams=(_stream(999, 7), _stream(101, 3)), + ) + + result = resolve_knowledge_runtime_candidates(request, snapshot) + + assert [candidate.knowledge_base_id for candidate in result.candidates] == [ + _id(2), + _id(3), + ] + + +def test_budget_is_deterministic_success_with_safe_warning(): + request = _request( + direct=tuple(_id(value) for value in range(1, 20)), + collections=(_id(101),), + budget=20, + ) + snapshot = _snapshot( + direct=request.direct_kb_ids, + streams=(_stream(101, 20, 21),), + ) + + result = resolve_knowledge_runtime_candidates(request, snapshot) + + assert result.status == "resolved" + assert len(result.candidates) == 20 + assert result.candidates[-1].knowledge_base_id == _id(20) + assert result.budget_limited is True + assert result.warning_codes == ("candidate_budget_limited",) + assert result.eligible_candidate_count_bucket == "11-100" + + +def test_scan_limit_is_separate_safe_warning(): + request = _request(collections=(_id(101),)) + snapshot = _snapshot( + streams=(_stream(101, 1),), + scan_limited=True, + ) + + result = resolve_knowledge_runtime_candidates(request, snapshot) + + assert result.budget_limited is False + assert result.scan_limited is True + assert result.warning_codes == ("candidate_scan_limited",) + + +def test_zero_candidates_returns_safe_no_result_without_identity_summary(): + request = _request(direct=(_id(1),), collections=(_id(101),)) + + result = resolve_knowledge_runtime_candidates( + request, + _snapshot(excluded=17), + ) + + assert result.status == "safe_no_result" + assert result.candidates == () + assert result.routing_mode == "none" + assert result.reason_code == "knowledge_candidates.safe_no_result" + assert result.configured_direct_count_bucket == "1" + assert result.configured_collection_count_bucket == "1" + assert result.policy_excluded_count_bucket == "11-100" + assert not hasattr(result, "excluded_candidate_ids") + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (0, "0"), + (1, "1"), + (2, "2-10"), + (10, "2-10"), + (11, "11-100"), + (100, "11-100"), + (101, "100+"), + ], +) +def test_safe_count_buckets(value, expected): + assert bucket_safe_count(value) == expected + + +def test_safe_count_bucket_rejects_negative_internal_count(): + with pytest.raises(ValueError, match="safe_count_invalid"): + bucket_safe_count(-1) From f03a2b23283e4d1e6b23fbb0ff0ad2ca3c6a79c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=95=EB=AF=BC?= Date: Mon, 13 Jul 2026 15:09:45 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat(workflow):=20Knowledge=20=ED=9B=84?= =?UTF-8?q?=EB=B3=B4=20=ED=95=B4=EC=84=9D=20=EC=9C=A0=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/runtime_retrieval/__init__.py | 1 + .../runtime_retrieval/knowledge_candidates.py | 71 +++++++++++ .../test_knowledge_candidates.py | 110 ++++++++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 apps/workflow_engine/application/runtime_retrieval/__init__.py create mode 100644 apps/workflow_engine/application/runtime_retrieval/knowledge_candidates.py create mode 100644 apps/workflow_engine/tests/application/runtime_retrieval/test_knowledge_candidates.py diff --git a/apps/workflow_engine/application/runtime_retrieval/__init__.py b/apps/workflow_engine/application/runtime_retrieval/__init__.py new file mode 100644 index 000000000..30df24cfc --- /dev/null +++ b/apps/workflow_engine/application/runtime_retrieval/__init__.py @@ -0,0 +1 @@ +"""Workflow Engine runtime retrieval application boundaries.""" diff --git a/apps/workflow_engine/application/runtime_retrieval/knowledge_candidates.py b/apps/workflow_engine/application/runtime_retrieval/knowledge_candidates.py new file mode 100644 index 000000000..cd6c1a25d --- /dev/null +++ b/apps/workflow_engine/application/runtime_retrieval/knowledge_candidates.py @@ -0,0 +1,71 @@ +"""Application boundary for runtime Knowledge candidate resolution.""" + +from __future__ import annotations + +from typing import Protocol + +from apps.shared.domain.knowledge_runtime_candidates import ( + KnowledgeRuntimeCandidateConfigurationError, + KnowledgeRuntimeCandidateRequest, + KnowledgeRuntimeCandidateResolution, + KnowledgeRuntimeCandidateSnapshot, + resolve_knowledge_runtime_candidates, +) + + +class KnowledgeRuntimeCandidateSnapshotPort(Protocol): + """Loads authorized and ready facts within one invocation snapshot.""" + + def load_snapshot( + self, + request: KnowledgeRuntimeCandidateRequest, + ) -> KnowledgeRuntimeCandidateSnapshot: ... + + +class KnowledgeRuntimeCandidateInfrastructureError(RuntimeError): + """Sanitized retryable failure raised before retrieval/provider effects.""" + + def __init__( + self, + reason_code: str = "knowledge_candidate_resolver_unavailable", + ) -> None: + self.reason_code = reason_code + self.retryable = True + super().__init__(reason_code) + + +class KnowledgeRuntimeCandidateResolver: + def __init__( + self, + *, + snapshot_port: KnowledgeRuntimeCandidateSnapshotPort, + ) -> None: + self._snapshot_port = snapshot_port + + def resolve( + self, + request: KnowledgeRuntimeCandidateRequest, + ) -> KnowledgeRuntimeCandidateResolution: + if not isinstance(request, KnowledgeRuntimeCandidateRequest): + raise KnowledgeRuntimeCandidateConfigurationError("request_invalid") + + failed = False + result: KnowledgeRuntimeCandidateResolution | None = None + try: + snapshot = self._snapshot_port.load_snapshot(request) + result = resolve_knowledge_runtime_candidates(request, snapshot) + except Exception: + # Do not retain a raw DB/source exception as public exception context. + # Concrete observability, if added, must map it to a separate safe code. + failed = True + + if failed or result is None: + raise KnowledgeRuntimeCandidateInfrastructureError() + return result + + +__all__ = [ + "KnowledgeRuntimeCandidateInfrastructureError", + "KnowledgeRuntimeCandidateResolver", + "KnowledgeRuntimeCandidateSnapshotPort", +] diff --git a/apps/workflow_engine/tests/application/runtime_retrieval/test_knowledge_candidates.py b/apps/workflow_engine/tests/application/runtime_retrieval/test_knowledge_candidates.py new file mode 100644 index 000000000..2e72abee1 --- /dev/null +++ b/apps/workflow_engine/tests/application/runtime_retrieval/test_knowledge_candidates.py @@ -0,0 +1,110 @@ +from uuid import UUID + +import pytest + +from apps.shared.domain.knowledge_runtime_candidates import ( + AuthenticatedAudience, + KnowledgeRuntimeCandidateConfigurationError, + KnowledgeRuntimeCandidateRequest, + KnowledgeRuntimeCandidateSnapshot, +) +from apps.workflow_engine.application.runtime_retrieval.knowledge_candidates import ( + KnowledgeRuntimeCandidateInfrastructureError, + KnowledgeRuntimeCandidateResolver, +) + + +def _id(value: int) -> UUID: + return UUID(int=value) + + +def _request() -> KnowledgeRuntimeCandidateRequest: + return KnowledgeRuntimeCandidateRequest( + audience=AuthenticatedAudience( + organization_id=_id(100), + user_id=_id(101), + ), + direct_kb_ids=(_id(1),), + ) + + +class _SnapshotPort: + def __init__(self, snapshot=None, error=None): + self.snapshot = snapshot or KnowledgeRuntimeCandidateSnapshot() + self.error = error + self.requests = [] + + def load_snapshot(self, request): + self.requests.append(request) + if self.error is not None: + raise self.error + return self.snapshot + + +def test_resolver_loads_exactly_one_snapshot_and_applies_pure_policy(): + request = _request() + port = _SnapshotPort( + KnowledgeRuntimeCandidateSnapshot( + eligible_direct_kb_ids=(_id(1),), + ) + ) + resolver = KnowledgeRuntimeCandidateResolver(snapshot_port=port) + + result = resolver.resolve(request) + + assert port.requests == [request] + assert result.status == "resolved" + assert [candidate.knowledge_base_id for candidate in result.candidates] == [ + _id(1) + ] + + +def test_resolver_rejects_wrong_request_before_opening_snapshot(): + port = _SnapshotPort() + resolver = KnowledgeRuntimeCandidateResolver(snapshot_port=port) + + with pytest.raises(KnowledgeRuntimeCandidateConfigurationError) as exc_info: + resolver.resolve(object()) # type: ignore[arg-type] + + assert exc_info.value.reason_code == "request_invalid" + assert port.requests == [] + + +def test_port_failure_is_sanitized_and_drops_raw_exception_context(): + raw_marker = "sensitive-source-marker" + port = _SnapshotPort(error=RuntimeError(raw_marker)) + resolver = KnowledgeRuntimeCandidateResolver(snapshot_port=port) + + with pytest.raises(KnowledgeRuntimeCandidateInfrastructureError) as exc_info: + resolver.resolve(_request()) + + error = exc_info.value + assert error.reason_code == "knowledge_candidate_resolver_unavailable" + assert error.retryable is True + assert str(error) == "knowledge_candidate_resolver_unavailable" + assert raw_marker not in repr(error) + assert error.__cause__ is None + assert error.__context__ is None + + +def test_invalid_snapshot_projection_is_whole_resolution_failure(): + class _InvalidSnapshotPort: + def load_snapshot(self, request): + del request + return object() + + resolver = KnowledgeRuntimeCandidateResolver(snapshot_port=_InvalidSnapshotPort()) + + with pytest.raises(KnowledgeRuntimeCandidateInfrastructureError): + resolver.resolve(_request()) + + +def test_policy_exclusion_is_safe_no_result_not_infrastructure_failure(): + resolver = KnowledgeRuntimeCandidateResolver( + snapshot_port=_SnapshotPort(KnowledgeRuntimeCandidateSnapshot()) + ) + + result = resolver.resolve(_request()) + + assert result.status == "safe_no_result" + assert result.reason_code == "knowledge_candidates.safe_no_result" From 10b062dc1099f97bb7fa4d71bb55115557ce671e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=95=EB=AF=BC?= Date: Mon, 13 Jul 2026 15:18:01 +0900 Subject: [PATCH 04/10] =?UTF-8?q?docs(knowledge):=20=EC=86=8C=EC=8A=A4=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EA=B6=8C=ED=95=9C=20=EA=B2=BD=EA=B3=84=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ADR-0036-knowledge-runtime-candidate-resolution.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/decisions/ADR-0036-knowledge-runtime-candidate-resolution.md b/docs/decisions/ADR-0036-knowledge-runtime-candidate-resolution.md index ba0384476..950327475 100644 --- a/docs/decisions/ADR-0036-knowledge-runtime-candidate-resolution.md +++ b/docs/decisions/ADR-0036-knowledge-runtime-candidate-resolution.md @@ -107,6 +107,12 @@ freshness/expiry, requester authorization과 저장된 source action이 일치 Missing, inactive, stale, expired, unmapped, ambiguous, unverified, revoked, denied, unknown, mismatched fact는 해당 KB를 제외한다. +Runtime retrieval과 호환되는 materialized source action은 공백과 대소문자를 +정규화한 `read`, `view`, `use`, `retrieve`, `search`다. `NULL`, 빈 값, 쓰기·관리 +동작 또는 알 수 없는 동작은 retrieval 승인을 의미한다고 추론하지 않고 +`source_authorization.operation_unverified`로 제외한다. Provider별 action을 이 +집합으로 매핑하는 책임은 provenance 생산 경계에 있다. + MBA-232 resolver는 connector client, `check_access_batch`, single `check_access`, HTTP client 또는 runtime source-authorization cache를 호출하거나 구현하지 않는다. Live 재확인과 short-lived cache는 별도 source authorization 이슈에서 결정한다. Candidate From fafe04c235b2063faa42954147caddf81b55a4a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=95=EB=AF=BC?= Date: Mon, 13 Jul 2026 15:18:09 +0900 Subject: [PATCH 05/10] =?UTF-8?q?feat(knowledge):=20=EB=9F=B0=ED=83=80?= =?UTF-8?q?=EC=9E=84=20=EA=B6=8C=ED=95=9C=20=EC=9D=BC=EA=B4=84=20=ED=8F=89?= =?UTF-8?q?=EA=B0=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test_knowledge_permission_phase2.py | 2 + .../services/knowledge_permission_service.py | 140 +++++++++++++++++- .../test_knowledge_permission_runtime_bulk.py | 134 +++++++++++++++++ 3 files changed, 273 insertions(+), 3 deletions(-) create mode 100644 apps/shared/tests/services/test_knowledge_permission_runtime_bulk.py diff --git a/apps/gateway/tests/services/test_knowledge_permission_phase2.py b/apps/gateway/tests/services/test_knowledge_permission_phase2.py index c278e4aa2..3b2a46c51 100644 --- a/apps/gateway/tests/services/test_knowledge_permission_phase2.py +++ b/apps/gateway/tests/services/test_knowledge_permission_phase2.py @@ -72,12 +72,14 @@ def _source_provenance( *, source_acl_state="fresh", requester_source_authorization="allowed", + source_permission_action="read", freshness_epoch=1, expires_at=None, ): return SimpleNamespace( source_acl_state=source_acl_state, requester_source_authorization=requester_source_authorization, + source_permission_action=source_permission_action, freshness_epoch=freshness_epoch, freshness_expires_at=expires_at if expires_at is not None diff --git a/apps/shared/services/knowledge_permission_service.py b/apps/shared/services/knowledge_permission_service.py index fedabee24..eecff4207 100644 --- a/apps/shared/services/knowledge_permission_service.py +++ b/apps/shared/services/knowledge_permission_service.py @@ -36,6 +36,13 @@ COLLECTION_PERMISSION_ACTIONS = {"read", "route", "manage", "sync"} SOURCE_ACL_PASS_STATE = "fresh" SOURCE_ACL_FAIL_STATES = {"stale", "unmapped", "ambiguous", "unverified", "revoked"} +SOURCE_RETRIEVAL_PERMISSION_ACTIONS = { + "read", + "view", + "use", + "retrieve", + "search", +} class KnowledgePermissionHelper: @@ -108,10 +115,69 @@ def bulk_evaluate_collection_action( collections: Iterable[KnowledgeCollection], action: str, ) -> dict[uuid.UUID, KnowledgePermissionDecision]: - return { - collection.id: self.evaluate_collection_action(collection, action) - for collection in collections + collection_list = list(collections) + if not collection_list: + return {} + + # Lightweight test/fake helpers historically provide only the single-row + # hook. Production sessions always take the bounded bulk path below. + if self.db is None: + return { + collection.id: self.evaluate_collection_action(collection, action) + for collection in collection_list + } + + if action not in COLLECTION_PERMISSION_ACTIONS: + return { + collection.id: self._denied( + reason_code="permission.invalid_action", + external_reason_code="resource.hidden", + ) + for collection in collection_list + } + + organization_auth_state = self._organization_auth_state() + in_scope_by_id = { + collection.id: self._collection_in_scope(collection) + for collection in collection_list } + allowed_collection_ids: set[uuid.UUID] = set() + if organization_auth_state == ORGANIZATION_AUTH_MEMBER: + scoped_collection_ids = [ + collection.id + for collection in collection_list + if in_scope_by_id[collection.id] + ] + if scoped_collection_ids: + allowed_collection_ids = self._bulk_collection_action_ids( + scoped_collection_ids, + action, + ) + + decisions: dict[uuid.UUID, KnowledgePermissionDecision] = {} + for collection in collection_list: + if not in_scope_by_id[collection.id]: + decisions[collection.id] = self._denied(reason_code="resource.hidden") + elif organization_auth_state == AUTH_STATE_MANAGER: + decisions[collection.id] = self._allowed( + effective_auth_state=AUTH_STATE_MANAGER, + safe_metadata=self._collection_safe_metadata(collection), + ) + elif organization_auth_state != ORGANIZATION_AUTH_MEMBER: + decisions[collection.id] = self._denied(reason_code="resource.hidden") + elif collection.id not in allowed_collection_ids: + decisions[collection.id] = self._denied( + resource_visibility="visible", + reason_code=f"collection_{action}_denied", + external_reason_code="permission.denied", + safe_metadata=self._collection_safe_metadata(collection), + ) + else: + decisions[collection.id] = self._allowed( + effective_auth_state=AUTH_STATE_OPERATOR, + safe_metadata=self._collection_safe_metadata(collection), + ) + return decisions def evaluate_kb_use(self, kb: KnowledgeBase) -> KnowledgePermissionDecision: if not self._kb_in_scope(kb): @@ -368,6 +434,23 @@ def _evaluate_source_authorization( ), ) + source_permission_action = getattr( + provenance, + "source_permission_action", + None, + ) + if ( + not isinstance(source_permission_action, str) + or source_permission_action.strip().lower() + not in SOURCE_RETRIEVAL_PERMISSION_ACTIONS + ): + return self._source_denied( + source_acl_state=SOURCE_ACL_PASS_STATE, + requester_source_authorization="allowed", + freshness_epoch=freshness_epoch, + reason_code="source_authorization.operation_unverified", + ) + return self._allowed( source_acl_state=SOURCE_ACL_PASS_STATE, requester_source_authorization="allowed", @@ -619,6 +702,57 @@ def _collection_has_action( is not None ) + def _bulk_collection_action_ids( + self, + collection_ids: Iterable[uuid.UUID], + action: str, + ) -> set[uuid.UUID]: + bounded_collection_ids = list(dict.fromkeys(collection_ids)) + if not bounded_collection_ids: + return set() + + team_rows = ( + self.db.query( + TeamKnowledgeCollectionPermission.knowledge_collection_id, + ) + .join( + TeamMembership, + TeamMembership.team_id == TeamKnowledgeCollectionPermission.team_id, + ) + .join(Team, Team.id == TeamKnowledgeCollectionPermission.team_id) + .filter( + TeamMembership.user_id == self.user_id, + TeamMembership.grantee_organization_id == self.organization_id, + TeamKnowledgeCollectionPermission.grantee_organization_id + == self.organization_id, + TeamMembership.grantee_organization_id + == TeamKnowledgeCollectionPermission.grantee_organization_id, + Team.organization_id == self.organization_id, + Team.is_active.is_(True), + TeamKnowledgeCollectionPermission.knowledge_collection_id.in_( + bounded_collection_ids + ), + TeamKnowledgeCollectionPermission.permission_action == action, + ) + .all() + ) + direct_rows = ( + self.db.query( + UserKnowledgeCollectionPermission.knowledge_collection_id, + ) + .filter( + UserKnowledgeCollectionPermission.user_id == self.user_id, + UserKnowledgeCollectionPermission.grantee_organization_id + == self.organization_id, + UserKnowledgeCollectionPermission.knowledge_collection_id.in_( + bounded_collection_ids + ), + UserKnowledgeCollectionPermission.permission_action == action, + ) + .all() + ) + return {row[0] for row in [*team_rows, *direct_rows]} + def _active_team_ids(self) -> set[uuid.UUID]: if self._team_ids_cache is not None: return self._team_ids_cache diff --git a/apps/shared/tests/services/test_knowledge_permission_runtime_bulk.py b/apps/shared/tests/services/test_knowledge_permission_runtime_bulk.py new file mode 100644 index 000000000..69b51bb52 --- /dev/null +++ b/apps/shared/tests/services/test_knowledge_permission_runtime_bulk.py @@ -0,0 +1,134 @@ +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from uuid import UUID + +import pytest +from apps.shared.db.models.organization_membership import ORGANIZATION_AUTH_MEMBER +from apps.shared.permissions import AUTH_STATE_MANAGER, AUTH_STATE_OPERATOR +from apps.shared.services.knowledge_permission_service import KnowledgePermissionHelper + +ORG_ID = UUID(int=100) +USER_ID = UUID(int=101) + + +def _collection(value: int): + return SimpleNamespace( + id=UUID(int=value), + organization_id=ORG_ID, + lifecycle_state="active", + sync_state="manual", + is_system_managed=False, + safe_metadata={}, + ) + + +def _source_kb(): + return SimpleNamespace( + id=UUID(int=300), + organization_id=ORG_ID, + lifecycle_state="active", + sync_state="synced", + source_identity_id=UUID(int=301), + ) + + +def _provenance(*, action): + return SimpleNamespace( + source_acl_state="fresh", + requester_source_authorization="allowed", + freshness_epoch=7, + freshness_expires_at=datetime.now(timezone.utc) + timedelta(hours=1), + source_permission_action=action, + ) + + +class _BulkCollectionHelper(KnowledgePermissionHelper): + def __init__(self, *, auth_state=ORGANIZATION_AUTH_MEMBER): + super().__init__(object(), user_id=USER_ID, organization_id=ORG_ID) + self.auth_state = auth_state + self.bulk_calls = [] + self.single_calls = [] + + def _organization_auth_state(self): + return self.auth_state + + def _bulk_collection_action_ids(self, collection_ids, action): + self.bulk_calls.append((tuple(collection_ids), action)) + return {collection_ids[0]} + + def _collection_has_action(self, collection_id, action): + self.single_calls.append((collection_id, action)) + raise AssertionError("bulk evaluation must not call the single-row path") + + +def test_bulk_collection_route_uses_one_bulk_permission_projection(): + collections = [_collection(1), _collection(2)] + helper = _BulkCollectionHelper() + + decisions = helper.bulk_evaluate_collection_action(collections, "route") + + assert decisions[collections[0].id].allowed is True + assert decisions[collections[1].id].allowed is False + assert decisions[collections[1].id].external_reason_code == "permission.denied" + assert helper.bulk_calls == [((collections[0].id, collections[1].id), "route")] + assert helper.single_calls == [] + + +def test_bulk_collection_manager_override_skips_permission_rows(): + collections = [_collection(1), _collection(2)] + helper = _BulkCollectionHelper(auth_state=AUTH_STATE_MANAGER) + + decisions = helper.bulk_evaluate_collection_action(collections, "route") + + assert all(decision.allowed for decision in decisions.values()) + assert helper.bulk_calls == [] + assert helper.single_calls == [] + + +def test_bulk_collection_invalid_action_is_fixed_safe_denial(): + collection = _collection(1) + helper = _BulkCollectionHelper() + + decision = helper.bulk_evaluate_collection_action([collection], "content")[ + collection.id + ] + + assert decision.allowed is False + assert decision.reason_code == "permission.invalid_action" + assert decision.external_reason_code == "resource.hidden" + assert helper.bulk_calls == [] + + +class _SourceActionHelper(KnowledgePermissionHelper): + def __init__(self, provenance): + super().__init__(None, user_id=USER_ID, organization_id=ORG_ID) + self.provenance = provenance + + def _effective_kb_use_auth_state(self, kb): + del kb + return AUTH_STATE_OPERATOR + + def _latest_source_authorization(self, kb): + del kb + return self.provenance + + +@pytest.mark.parametrize("action", ["read", "view", "use", "retrieve", "search"]) +def test_materialized_source_retrieval_action_is_compatible(action): + decision = _SourceActionHelper(_provenance(action=action)).evaluate_kb_use( + _source_kb() + ) + + assert decision.allowed is True + assert decision.freshness_epoch == 7 + + +@pytest.mark.parametrize("action", [None, "", "write", "admin", "delete", "unknown"]) +def test_materialized_source_non_retrieval_action_fails_closed(action): + decision = _SourceActionHelper(_provenance(action=action)).evaluate_kb_use( + _source_kb() + ) + + assert decision.allowed is False + assert decision.reason_code == "source_authorization.operation_unverified" + assert decision.external_reason_code == "resource.hidden" From 5fd67508e26626ad24a2203e573cc8784dbc52d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=95=EB=AF=BC?= Date: Mon, 13 Jul 2026 15:36:55 +0900 Subject: [PATCH 06/10] =?UTF-8?q?docs(knowledge):=20=EB=A0=88=EA=B1=B0?= =?UTF-8?q?=EC=8B=9C=20=EC=A4=80=EB=B9=84=20=EC=83=81=ED=83=9C=EC=99=80=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EA=B3=84=EC=95=BD=20=EC=A0=95=ED=95=A9?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ADR-0036-knowledge-runtime-candidate-resolution.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/decisions/ADR-0036-knowledge-runtime-candidate-resolution.md b/docs/decisions/ADR-0036-knowledge-runtime-candidate-resolution.md index 950327475..ff9185947 100644 --- a/docs/decisions/ADR-0036-knowledge-runtime-candidate-resolution.md +++ b/docs/decisions/ADR-0036-knowledge-runtime-candidate-resolution.md @@ -95,9 +95,10 @@ child KB lifecycle은 독립적으로 평가한다. KB는 active lifecycle이고 `sync_state != "source_deleted"`여야 한다. 기본 readiness는 active `DocumentVersion.status == "ready"`다. 전환기에는 completed document에 연결된 -unversioned retrieval-visible chunk가 있는 legacy KB를 기존 문서 계약대로 허용할 수 -있다. Pre-finalized chunk, non-ready version 또는 document row 존재만으로 ready를 -추정하지 않는다. +unversioned chunk가 있고 `KnowledgeBase.active_document_version_id IS NULL`인 legacy +KB만 현재 retrieval-visible 계약에 따라 허용할 수 있다. Active pointer가 non-ready +version을 가리키는 동안에는 unversioned chunk로 fallback하지 않는다. Pre-finalized +chunk, non-ready version 또는 document row 존재만으로 ready를 추정하지 않는다. ### Materialized source authorization From 688b804a2bd03507468079c2c59b1f5e00106735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=95=EB=AF=BC?= Date: Mon, 13 Jul 2026 15:37:18 +0900 Subject: [PATCH 07/10] =?UTF-8?q?feat(workflow):=20Knowledge=20=ED=9B=84?= =?UTF-8?q?=EB=B3=B4=20=EC=8A=A4=EB=83=85=EC=83=B7=20=EC=96=B4=EB=8C=91?= =?UTF-8?q?=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/knowledge_permission_service.py | 24 +- ...ge_runtime_snapshot_disposable_postgres.py | 342 +++++++++ .../adapters/knowledge_runtime_candidates.py | 649 ++++++++++++++++++ .../composition/runtime_retrieval.py | 33 + ...res_knowledge_runtime_candidate_adapter.py | 574 ++++++++++++++++ ...knowledge_runtime_candidate_composition.py | 78 +++ 6 files changed, 1699 insertions(+), 1 deletion(-) create mode 100644 apps/shared/tests/db/test_knowledge_runtime_snapshot_disposable_postgres.py create mode 100644 apps/workflow_engine/adapters/knowledge_runtime_candidates.py create mode 100644 apps/workflow_engine/composition/runtime_retrieval.py create mode 100644 apps/workflow_engine/tests/adapters/test_postgres_knowledge_runtime_candidate_adapter.py create mode 100644 apps/workflow_engine/tests/test_knowledge_runtime_candidate_composition.py diff --git a/apps/shared/services/knowledge_permission_service.py b/apps/shared/services/knowledge_permission_service.py index eecff4207..ed00a26c6 100644 --- a/apps/shared/services/knowledge_permission_service.py +++ b/apps/shared/services/knowledge_permission_service.py @@ -31,7 +31,7 @@ has_active_organization_membership, ) from sqlalchemy import and_, or_ -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, load_only COLLECTION_PERMISSION_ACTIONS = {"read", "route", "manage", "sync"} SOURCE_ACL_PASS_STATE = "fresh" @@ -474,6 +474,17 @@ def _latest_source_authorization( == self.requester_subject_id, SourceAuthorizationProvenance.status == "active", ) + query = query.options( + load_only( + SourceAuthorizationProvenance.knowledge_base_id, + SourceAuthorizationProvenance.source_identity_id, + SourceAuthorizationProvenance.source_acl_state, + SourceAuthorizationProvenance.requester_source_authorization, + SourceAuthorizationProvenance.source_permission_action, + SourceAuthorizationProvenance.freshness_epoch, + SourceAuthorizationProvenance.freshness_expires_at, + ) + ) if kb.source_identity_id is not None: query = query.filter( SourceAuthorizationProvenance.source_identity_id @@ -628,6 +639,17 @@ def _bulk_latest_source_authorization_by_key( rows = ( self.db.query(SourceAuthorizationProvenance) + .options( + load_only( + SourceAuthorizationProvenance.knowledge_base_id, + SourceAuthorizationProvenance.source_identity_id, + SourceAuthorizationProvenance.source_acl_state, + SourceAuthorizationProvenance.requester_source_authorization, + SourceAuthorizationProvenance.source_permission_action, + SourceAuthorizationProvenance.freshness_epoch, + SourceAuthorizationProvenance.freshness_expires_at, + ) + ) .filter( SourceAuthorizationProvenance.organization_id == self.organization_id, SourceAuthorizationProvenance.knowledge_base_id.in_( diff --git a/apps/shared/tests/db/test_knowledge_runtime_snapshot_disposable_postgres.py b/apps/shared/tests/db/test_knowledge_runtime_snapshot_disposable_postgres.py new file mode 100644 index 000000000..d0ce1888e --- /dev/null +++ b/apps/shared/tests/db/test_knowledge_runtime_snapshot_disposable_postgres.py @@ -0,0 +1,342 @@ +import os +from concurrent.futures import ThreadPoolExecutor +from threading import Event +from uuid import UUID, uuid4 + +import pytest +from apps.shared.domain.knowledge_runtime_candidates import ( + AnonymousPublicAudience, + KnowledgeRuntimeCandidateRequest, +) +from apps.shared.tests.helpers.disposable_postgres import ( + DisposablePostgresConfig, + DisposablePostgresConfigurationError, + quote_disposable_database_name, +) +from apps.workflow_engine.adapters.knowledge_runtime_candidates import ( + KnowledgeRuntimeCandidateSnapshotError, + PostgresKnowledgeRuntimeCandidateSnapshotAdapter, +) +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +RUN_ENV = "NODEASE_RUN_DISPOSABLE_DB_TEST" +DB_PREFIX = "nodease_knowledge_snapshot_test" + + +def _create_schema(engine) -> None: + statements = ( + """ + CREATE TABLE organization ( + id UUID PRIMARY KEY, + is_active BOOLEAN NOT NULL + ) + """, + """ + CREATE TABLE knowledge_collections ( + id UUID PRIMARY KEY, + organization_id UUID NOT NULL, + lifecycle_state VARCHAR(50) NOT NULL, + sync_state VARCHAR(50) NOT NULL, + is_system_managed BOOLEAN NOT NULL, + safe_metadata JSONB NOT NULL + ) + """, + """ + CREATE TABLE knowledge_collection_items ( + id UUID PRIMARY KEY, + organization_id UUID NOT NULL, + collection_id UUID NOT NULL, + knowledge_base_id UUID NOT NULL, + rank INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL + ) + """, + """ + CREATE TABLE knowledge_bases ( + id UUID PRIMARY KEY, + organization_id UUID NOT NULL, + active_document_version_id UUID NULL, + source_identity_id UUID NULL, + sync_state VARCHAR(50) NOT NULL, + lifecycle_state VARCHAR(50) NOT NULL + ) + """, + """ + CREATE TABLE document_versions ( + id UUID PRIMARY KEY, + organization_id UUID NOT NULL, + knowledge_base_id UUID NOT NULL, + status VARCHAR(32) NOT NULL + ) + """, + """ + CREATE TABLE documents ( + id UUID PRIMARY KEY, + knowledge_base_id UUID NOT NULL, + status VARCHAR(50) NOT NULL + ) + """, + """ + CREATE TABLE document_chunks ( + id UUID PRIMARY KEY, + document_id UUID NOT NULL, + document_version_id UUID NULL, + knowledge_base_id UUID NOT NULL + ) + """, + """ + CREATE TABLE snapshot_write_probe ( + id UUID PRIMARY KEY + ) + """, + ) + with engine.begin() as connection: + for statement in statements: + connection.execute(text(statement)) + + +def _seed_ready_public_collection(engine): + organization_id = uuid4() + collection_id = uuid4() + knowledge_base_id = uuid4() + version_id = uuid4() + item_id = uuid4() + with engine.begin() as connection: + connection.execute( + text( + "INSERT INTO organization (id, is_active) " + "VALUES (:organization_id, true)" + ), + {"organization_id": organization_id}, + ) + connection.execute( + text( + "INSERT INTO knowledge_collections " + "(id, organization_id, lifecycle_state, sync_state, " + "is_system_managed, safe_metadata) " + "VALUES (:collection_id, :organization_id, 'active', " + "'manual', false, '{\"visibility\": \"public\"}'::jsonb)" + ), + { + "collection_id": collection_id, + "organization_id": organization_id, + }, + ) + connection.execute( + text( + "INSERT INTO knowledge_bases " + "(id, organization_id, active_document_version_id, " + "source_identity_id, sync_state, lifecycle_state) " + "VALUES (:knowledge_base_id, :organization_id, :version_id, " + "NULL, 'manual', 'active')" + ), + { + "knowledge_base_id": knowledge_base_id, + "organization_id": organization_id, + "version_id": version_id, + }, + ) + connection.execute( + text( + "INSERT INTO document_versions " + "(id, organization_id, knowledge_base_id, status) " + "VALUES (:version_id, :organization_id, " + ":knowledge_base_id, 'ready')" + ), + { + "version_id": version_id, + "organization_id": organization_id, + "knowledge_base_id": knowledge_base_id, + }, + ) + connection.execute( + text( + "INSERT INTO knowledge_collection_items " + "(id, organization_id, collection_id, knowledge_base_id, " + "rank, created_at) " + "VALUES (:item_id, :organization_id, :collection_id, " + ":knowledge_base_id, 0, clock_timestamp())" + ), + { + "item_id": item_id, + "organization_id": organization_id, + "collection_id": collection_id, + "knowledge_base_id": knowledge_base_id, + }, + ) + return organization_id, collection_id, knowledge_base_id + + +@pytest.fixture +def disposable_snapshot_database(): + try: + config = DisposablePostgresConfig.from_environment() + except DisposablePostgresConfigurationError: + raise pytest.fail.Exception( + "disposable PostgreSQL connection settings are not safely configured", + pytrace=False, + ) from None + + database = f"{DB_PREFIX}_{uuid4().hex[:12]}" + quoted_database = quote_disposable_database_name(database, prefix=DB_PREFIX) + admin_engine = create_engine( + config.database_url(config.maintenance_database), + isolation_level="AUTOCOMMIT", + ) + engine = None + database_created = False + try: + with admin_engine.connect() as connection: + connection.execute(text(f"CREATE DATABASE {quoted_database}")) + database_created = True + engine = create_engine(config.database_url(database), pool_size=2) + _create_schema(engine) + yield engine + finally: + if engine is not None: + engine.dispose() + if database_created: + with admin_engine.connect() as connection: + connection.execute( + text( + "SELECT pg_terminate_backend(pid) " + "FROM pg_stat_activity " + "WHERE datname = :database " + "AND pid <> pg_backend_pid()" + ), + {"database": database}, + ) + connection.execute(text(f"DROP DATABASE {quoted_database}")) + admin_engine.dispose() + + +class _PausingSnapshotAdapter(PostgresKnowledgeRuntimeCandidateSnapshotAdapter): + def __init__(self, *, session_factory, snapshot_started, writer_finished): + super().__init__(session_factory=session_factory) + self.snapshot_started = snapshot_started + self.writer_finished = writer_finished + self.isolation_level = None + self.transaction_read_only = None + + def _load_selected_collections( + self, + db, + organization_id, + collection_ids, + ): + self.isolation_level = db.execute( + text("SHOW transaction_isolation") + ).scalar_one() + self.transaction_read_only = db.execute( + text("SHOW transaction_read_only") + ).scalar_one() + self.snapshot_started.set() + if not self.writer_finished.wait(timeout=15): + raise RuntimeError("writer_timeout") + return super()._load_selected_collections( + db, + organization_id, + collection_ids, + ) + + +@pytest.mark.skipif( + os.getenv(RUN_ENV) != "1", + reason=f"set {RUN_ENV}=1 to run disposable Knowledge snapshot evidence", +) +def test_repeatable_read_snapshot_does_not_mix_concurrent_membership_change( + disposable_snapshot_database, +): + engine = disposable_snapshot_database + organization_id, collection_id, knowledge_base_id = ( + _seed_ready_public_collection(engine) + ) + session_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) + snapshot_started = Event() + writer_finished = Event() + adapter = _PausingSnapshotAdapter( + session_factory=session_factory, + snapshot_started=snapshot_started, + writer_finished=writer_finished, + ) + request = KnowledgeRuntimeCandidateRequest( + audience=AnonymousPublicAudience(organization_id=organization_id), + collection_ids=(collection_id,), + ) + + with ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(adapter.load_snapshot, request) + assert snapshot_started.wait(timeout=15) + with engine.begin() as connection: + connection.execute( + text( + "DELETE FROM knowledge_collection_items " + "WHERE organization_id = :organization_id " + "AND collection_id = :collection_id" + ), + { + "organization_id": organization_id, + "collection_id": collection_id, + }, + ) + writer_finished.set() + first_snapshot = future.result(timeout=15) + + assert adapter.isolation_level == "repeatable read" + assert adapter.transaction_read_only == "on" + assert first_snapshot.collection_streams[0].eligible_kb_ids == ( + knowledge_base_id, + ) + + next_snapshot = PostgresKnowledgeRuntimeCandidateSnapshotAdapter( + session_factory=session_factory + ).load_snapshot(request) + assert next_snapshot.collection_streams[0].eligible_kb_ids == () + + probe_id = uuid4() + with engine.begin() as connection: + connection.execute( + text("INSERT INTO snapshot_write_probe (id) VALUES (:probe_id)"), + {"probe_id": probe_id}, + ) + with engine.connect() as connection: + assert connection.execute( + text("SELECT id FROM snapshot_write_probe WHERE id = :probe_id"), + {"probe_id": probe_id}, + ).scalar_one() == probe_id + + +class _WriteAttemptSnapshotAdapter(PostgresKnowledgeRuntimeCandidateSnapshotAdapter): + def _organization_is_active(self, db, organization_id: UUID) -> bool: + del organization_id + db.execute( + text("INSERT INTO snapshot_write_probe (id) VALUES (:probe_id)"), + {"probe_id": uuid4()}, + ) + return True + + +@pytest.mark.skipif( + os.getenv(RUN_ENV) != "1", + reason=f"set {RUN_ENV}=1 to run disposable Knowledge read-only evidence", +) +def test_snapshot_transaction_rejects_write_and_returns_fixed_error( + disposable_snapshot_database, +): + engine = disposable_snapshot_database + session_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) + adapter = _WriteAttemptSnapshotAdapter(session_factory=session_factory) + request = KnowledgeRuntimeCandidateRequest( + audience=AnonymousPublicAudience(organization_id=uuid4()), + ) + + with pytest.raises(KnowledgeRuntimeCandidateSnapshotError) as exc_info: + adapter.load_snapshot(request) + + assert exc_info.value.reason_code == "snapshot_read_failed" + assert exc_info.value.__cause__ is None + with engine.connect() as connection: + assert connection.execute( + text("SELECT count(*) FROM snapshot_write_probe") + ).scalar_one() == 0 diff --git a/apps/workflow_engine/adapters/knowledge_runtime_candidates.py b/apps/workflow_engine/adapters/knowledge_runtime_candidates.py new file mode 100644 index 000000000..cc739e482 --- /dev/null +++ b/apps/workflow_engine/adapters/knowledge_runtime_candidates.py @@ -0,0 +1,649 @@ +"""PostgreSQL projection for runtime Knowledge candidate facts.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from typing import Any +from uuid import UUID + +from sqlalchemy import and_, case, func, select +from sqlalchemy.orm import Session, load_only + +from apps.shared.db.models.knowledge import ( + Document, + DocumentChunk, + DocumentVersion, + KnowledgeBase, + KnowledgeCollection, + KnowledgeCollectionItem, +) +from apps.shared.db.models.organization import Organization +from apps.shared.domain.knowledge_runtime_candidates import ( + AnonymousPublicAudience, + AuthenticatedAudience, + KnowledgeCollectionCandidateStream, + KnowledgeRuntimeCandidateRequest, + KnowledgeRuntimeCandidateSnapshot, +) +from apps.shared.services.knowledge_permission_service import KnowledgePermissionHelper + + +SessionFactory = Callable[[], Session] +MembershipFact = tuple[UUID, UUID] + + +class KnowledgeRuntimeCandidateSnapshotError(RuntimeError): + """Fixed-code adapter failure that never carries a raw database detail.""" + + def __init__(self, reason_code: str) -> None: + self.reason_code = reason_code + super().__init__(reason_code) + + +class PostgresKnowledgeRuntimeCandidateSnapshotAdapter: + """Loads one authorized/readiness projection from a fresh DB snapshot.""" + + def __init__(self, *, session_factory: SessionFactory) -> None: + self._session_factory = session_factory + + def load_snapshot( + self, + request: KnowledgeRuntimeCandidateRequest, + ) -> KnowledgeRuntimeCandidateSnapshot: + if not isinstance(request, KnowledgeRuntimeCandidateRequest): + raise KnowledgeRuntimeCandidateSnapshotError("snapshot_request_invalid") + + try: + db = self._session_factory() + except Exception: + raise KnowledgeRuntimeCandidateSnapshotError( + "snapshot_session_unavailable" + ) from None + + snapshot: KnowledgeRuntimeCandidateSnapshot | None = None + failure_code: str | None = None + try: + if db.in_transaction(): + raise KnowledgeRuntimeCandidateSnapshotError( + "snapshot_session_not_fresh" + ) + bind = db.get_bind() + if getattr(getattr(bind, "dialect", None), "name", None) != "postgresql": + raise KnowledgeRuntimeCandidateSnapshotError( + "snapshot_database_unsupported" + ) + try: + db.connection( + execution_options={ + "isolation_level": "REPEATABLE READ", + "postgresql_readonly": True, + } + ) + except Exception: + raise KnowledgeRuntimeCandidateSnapshotError( + "snapshot_transaction_setup_failed" + ) from None + + with db.no_autoflush: + snapshot = self._load_snapshot_in_transaction(db, request) + except KnowledgeRuntimeCandidateSnapshotError as exc: + failure_code = exc.reason_code + except Exception: + failure_code = "snapshot_read_failed" + finally: + cleanup_failed = False + try: + db.rollback() + except Exception: + cleanup_failed = True + try: + db.close() + except Exception: + cleanup_failed = True + if cleanup_failed and failure_code is None: + failure_code = "snapshot_cleanup_failed" + + if failure_code is not None: + raise KnowledgeRuntimeCandidateSnapshotError(failure_code) from None + if snapshot is None: + raise KnowledgeRuntimeCandidateSnapshotError("snapshot_read_failed") + return snapshot + + def _load_snapshot_in_transaction( + self, + db: Session, + request: KnowledgeRuntimeCandidateRequest, + ) -> KnowledgeRuntimeCandidateSnapshot: + if not self._organization_is_active(db, request.organization_id): + return KnowledgeRuntimeCandidateSnapshot( + policy_excluded_count=( + len(request.direct_kb_ids) + len(request.collection_ids) + ) + ) + + loaded_collections = self._load_selected_collections( + db, + request.organization_id, + request.collection_ids, + ) + in_scope_collections = { + collection_id: collection + for collection_id, collection in loaded_collections.items() + if self._collection_is_in_scope( + collection, + organization_id=request.organization_id, + expected_id=collection_id, + ) + } + configured_collections = [ + in_scope_collections[collection_id] + for collection_id in request.collection_ids + if collection_id in in_scope_collections + ] + + permission_helper: KnowledgePermissionHelper | None = None + if isinstance(request.audience, AuthenticatedAudience): + permission_helper = self._build_permission_helper(db, request.audience) + route_decisions = ( + permission_helper.bulk_evaluate_collection_action( + configured_collections, + "route", + ) + if configured_collections + else {} + ) + allowed_collection_ids = tuple( + collection.id + for collection in configured_collections + if bool(getattr(route_decisions.get(collection.id), "allowed", False)) + ) + elif isinstance(request.audience, AnonymousPublicAudience): + allowed_collection_ids = tuple( + collection.id + for collection in configured_collections + if self._collection_is_public(collection) + ) + else: + raise KnowledgeRuntimeCandidateSnapshotError("snapshot_audience_invalid") + + membership_facts: tuple[MembershipFact, ...] = () + scan_limited = False + malformed_membership_count = 0 + if allowed_collection_ids: + raw_memberships, scan_limited = self._load_fair_collection_memberships( + db, + request.organization_id, + allowed_collection_ids, + request.candidate_scan_cap, + ) + membership_facts, malformed_membership_count = ( + self._normalize_membership_facts( + raw_memberships, + allowed_collection_ids=set(allowed_collection_ids), + ) + ) + + candidate_kb_ids = self._deduplicate_ids( + (*request.direct_kb_ids, *(kb_id for _collection_id, kb_id in membership_facts)) + ) + loaded_kbs = self._load_candidate_kbs( + db, + request.organization_id, + candidate_kb_ids, + ) + in_scope_kbs = { + knowledge_base_id: kb + for knowledge_base_id, kb in loaded_kbs.items() + if self._kb_is_in_scope( + kb, + organization_id=request.organization_id, + expected_id=knowledge_base_id, + ) + } + in_scope_kb_ids = tuple( + knowledge_base_id + for knowledge_base_id in candidate_kb_ids + if knowledge_base_id in in_scope_kbs + ) + ready_kb_ids = self._load_ready_kb_ids( + db, + request.organization_id, + in_scope_kb_ids, + ) + normalized_ready_kb_ids = set(in_scope_kb_ids) & set(ready_kb_ids) + unresolved_kb_ids = tuple( + knowledge_base_id + for knowledge_base_id in in_scope_kb_ids + if knowledge_base_id not in normalized_ready_kb_ids + ) + if unresolved_kb_ids: + normalized_ready_kb_ids.update( + set(unresolved_kb_ids) + & set(self._load_legacy_ready_kb_ids(db, unresolved_kb_ids)) + ) + + if isinstance(request.audience, AuthenticatedAudience): + assert permission_helper is not None + eligible_kb_ids = self._authenticated_eligible_kb_ids( + permission_helper, + in_scope_kbs, + in_scope_kb_ids, + normalized_ready_kb_ids, + ) + else: + eligible_kb_ids = self._anonymous_eligible_kb_ids( + db, + request, + in_scope_kbs, + normalized_ready_kb_ids, + ) + + eligible_direct_kb_ids = tuple( + knowledge_base_id + for knowledge_base_id in request.direct_kb_ids + if knowledge_base_id in eligible_kb_ids + ) + collection_streams = tuple( + KnowledgeCollectionCandidateStream( + collection_id=collection_id, + eligible_kb_ids=tuple( + knowledge_base_id + for fact_collection_id, knowledge_base_id in membership_facts + if fact_collection_id == collection_id + and knowledge_base_id in eligible_kb_ids + ), + ) + for collection_id in allowed_collection_ids + ) + policy_excluded_count = ( + len(request.collection_ids) + - len(allowed_collection_ids) + + sum( + 1 + for knowledge_base_id in request.direct_kb_ids + if knowledge_base_id not in eligible_kb_ids + ) + + sum( + 1 + for _collection_id, knowledge_base_id in membership_facts + if knowledge_base_id not in eligible_kb_ids + ) + + malformed_membership_count + ) + return KnowledgeRuntimeCandidateSnapshot( + eligible_direct_kb_ids=eligible_direct_kb_ids, + collection_streams=collection_streams, + policy_excluded_count=policy_excluded_count, + scan_limited=bool(scan_limited), + ) + + def _authenticated_eligible_kb_ids( + self, + permission_helper: KnowledgePermissionHelper, + kbs_by_id: dict[UUID, KnowledgeBase], + ordered_kb_ids: tuple[UUID, ...], + ready_kb_ids: set[UUID], + ) -> set[UUID]: + ready_kbs = [ + kbs_by_id[knowledge_base_id] + for knowledge_base_id in ordered_kb_ids + if knowledge_base_id in ready_kb_ids + ] + if not ready_kbs: + return set() + decisions = permission_helper.bulk_evaluate_kb_use(ready_kbs) + return { + kb.id + for kb in ready_kbs + if bool(getattr(decisions.get(kb.id), "allowed", False)) + } + + def _anonymous_eligible_kb_ids( + self, + db: Session, + request: KnowledgeRuntimeCandidateRequest, + kbs_by_id: dict[UUID, KnowledgeBase], + ready_kb_ids: set[UUID], + ) -> set[UUID]: + manual_ready_kb_ids = { + knowledge_base_id + for knowledge_base_id, kb in kbs_by_id.items() + if knowledge_base_id in ready_kb_ids + and getattr(kb, "source_identity_id", None) is None + } + direct_manual_ready_ids = tuple( + knowledge_base_id + for knowledge_base_id in request.direct_kb_ids + if knowledge_base_id in manual_ready_kb_ids + ) + public_direct_ids = ( + self._load_public_direct_kb_ids( + db, + request.organization_id, + direct_manual_ready_ids, + ) + if direct_manual_ready_ids + else set() + ) + return manual_ready_kb_ids - set(request.direct_kb_ids) | ( + manual_ready_kb_ids & set(public_direct_ids) + ) + + def _organization_is_active(self, db: Session, organization_id: UUID) -> bool: + return ( + db.execute( + select(Organization.id).where( + Organization.id == organization_id, + Organization.is_active.is_(True), + ) + ).scalar_one_or_none() + is not None + ) + + def _load_selected_collections( + self, + db: Session, + organization_id: UUID, + collection_ids: Iterable[UUID], + ) -> dict[UUID, KnowledgeCollection]: + bounded_ids = self._deduplicate_ids(collection_ids) + if not bounded_ids: + return {} + rows = ( + db.execute( + select(KnowledgeCollection) + .options( + load_only( + KnowledgeCollection.id, + KnowledgeCollection.organization_id, + KnowledgeCollection.lifecycle_state, + KnowledgeCollection.sync_state, + KnowledgeCollection.is_system_managed, + KnowledgeCollection.safe_metadata, + ) + ) + .where( + KnowledgeCollection.id.in_(bounded_ids), + KnowledgeCollection.organization_id == organization_id, + KnowledgeCollection.lifecycle_state == "active", + ) + ) + .scalars() + .all() + ) + return {row.id: row for row in rows} + + def _load_fair_collection_memberships( + self, + db: Session, + organization_id: UUID, + collection_ids: Iterable[UUID], + scan_cap: int, + ) -> tuple[tuple[MembershipFact, ...], bool]: + bounded_collection_ids = self._deduplicate_ids(collection_ids) + if not bounded_collection_ids: + return (), False + + collection_position = func.row_number().over( + partition_by=KnowledgeCollectionItem.collection_id, + order_by=( + KnowledgeCollectionItem.rank.asc(), + KnowledgeCollectionItem.created_at.asc(), + KnowledgeCollectionItem.knowledge_base_id.asc(), + ), + ).label("collection_position") + ranked_items = ( + select( + KnowledgeCollectionItem.collection_id.label("collection_id"), + KnowledgeCollectionItem.knowledge_base_id.label("knowledge_base_id"), + collection_position, + ) + .where( + KnowledgeCollectionItem.organization_id == organization_id, + KnowledgeCollectionItem.collection_id.in_(bounded_collection_ids), + ) + .subquery() + ) + configured_order = case( + { + collection_id: index + for index, collection_id in enumerate(bounded_collection_ids) + }, + value=ranked_items.c.collection_id, + else_=len(bounded_collection_ids), + ) + rows = db.execute( + select( + ranked_items.c.collection_id, + ranked_items.c.knowledge_base_id, + ) + .order_by( + ranked_items.c.collection_position.asc(), + configured_order.asc(), + ranked_items.c.knowledge_base_id.asc(), + ) + .limit(scan_cap + 1) + ).all() + scan_limited = len(rows) > scan_cap + return ( + tuple( + (row.collection_id, row.knowledge_base_id) + for row in rows[:scan_cap] + ), + scan_limited, + ) + + def _load_candidate_kbs( + self, + db: Session, + organization_id: UUID, + knowledge_base_ids: Iterable[UUID], + ) -> dict[UUID, KnowledgeBase]: + bounded_ids = self._deduplicate_ids(knowledge_base_ids) + if not bounded_ids: + return {} + rows = ( + db.execute( + select(KnowledgeBase) + .options( + load_only( + KnowledgeBase.id, + KnowledgeBase.organization_id, + KnowledgeBase.active_document_version_id, + KnowledgeBase.source_identity_id, + KnowledgeBase.sync_state, + KnowledgeBase.lifecycle_state, + ) + ) + .where( + KnowledgeBase.id.in_(bounded_ids), + KnowledgeBase.organization_id == organization_id, + KnowledgeBase.lifecycle_state == "active", + KnowledgeBase.sync_state != "source_deleted", + ) + ) + .scalars() + .all() + ) + return {row.id: row for row in rows} + + def _load_ready_kb_ids( + self, + db: Session, + organization_id: UUID, + knowledge_base_ids: Iterable[UUID], + ) -> set[UUID]: + bounded_ids = self._deduplicate_ids(knowledge_base_ids) + if not bounded_ids: + return set() + return set( + db.execute( + select(KnowledgeBase.id) + .join( + DocumentVersion, + and_( + DocumentVersion.id + == KnowledgeBase.active_document_version_id, + DocumentVersion.knowledge_base_id == KnowledgeBase.id, + DocumentVersion.organization_id + == KnowledgeBase.organization_id, + ), + ) + .where( + KnowledgeBase.id.in_(bounded_ids), + KnowledgeBase.organization_id == organization_id, + DocumentVersion.status == "ready", + ) + ) + .scalars() + .all() + ) + + def _load_legacy_ready_kb_ids( + self, + db: Session, + knowledge_base_ids: Iterable[UUID], + ) -> set[UUID]: + bounded_ids = self._deduplicate_ids(knowledge_base_ids) + if not bounded_ids: + return set() + return set( + db.execute( + select(DocumentChunk.knowledge_base_id) + .join( + Document, + and_( + Document.id == DocumentChunk.document_id, + Document.knowledge_base_id + == DocumentChunk.knowledge_base_id, + ), + ) + .join( + KnowledgeBase, + KnowledgeBase.id == DocumentChunk.knowledge_base_id, + ) + .where( + DocumentChunk.knowledge_base_id.in_(bounded_ids), + KnowledgeBase.active_document_version_id.is_(None), + DocumentChunk.document_version_id.is_(None), + Document.status == "completed", + ) + .distinct() + ) + .scalars() + .all() + ) + + def _load_public_direct_kb_ids( + self, + db: Session, + organization_id: UUID, + knowledge_base_ids: Iterable[UUID], + ) -> set[UUID]: + bounded_ids = self._deduplicate_ids(knowledge_base_ids) + if not bounded_ids: + return set() + return set( + db.execute( + select(KnowledgeCollectionItem.knowledge_base_id) + .join( + KnowledgeCollection, + and_( + KnowledgeCollection.id + == KnowledgeCollectionItem.collection_id, + KnowledgeCollection.organization_id + == KnowledgeCollectionItem.organization_id, + ), + ) + .where( + KnowledgeCollectionItem.organization_id == organization_id, + KnowledgeCollectionItem.knowledge_base_id.in_(bounded_ids), + KnowledgeCollection.organization_id == organization_id, + KnowledgeCollection.lifecycle_state == "active", + KnowledgeCollection.safe_metadata["visibility"].astext + == "public", + ) + .distinct() + ) + .scalars() + .all() + ) + + def _build_permission_helper( + self, + db: Session, + audience: AuthenticatedAudience, + ) -> KnowledgePermissionHelper: + return KnowledgePermissionHelper( + db, + user_id=audience.user_id, + organization_id=audience.organization_id, + ) + + @staticmethod + def _collection_is_in_scope( + collection: Any, + *, + organization_id: UUID, + expected_id: UUID, + ) -> bool: + return ( + collection is not None + and getattr(collection, "id", None) == expected_id + and getattr(collection, "organization_id", None) == organization_id + and getattr(collection, "lifecycle_state", None) == "active" + ) + + @staticmethod + def _collection_is_public(collection: Any) -> bool: + safe_metadata = getattr(collection, "safe_metadata", None) + return ( + isinstance(safe_metadata, dict) + and safe_metadata.get("visibility") == "public" + ) + + @staticmethod + def _kb_is_in_scope( + kb: Any, + *, + organization_id: UUID, + expected_id: UUID, + ) -> bool: + return ( + kb is not None + and getattr(kb, "id", None) == expected_id + and getattr(kb, "organization_id", None) == organization_id + and getattr(kb, "lifecycle_state", None) == "active" + and getattr(kb, "sync_state", None) != "source_deleted" + ) + + @staticmethod + def _normalize_membership_facts( + facts: Iterable[MembershipFact], + *, + allowed_collection_ids: set[UUID], + ) -> tuple[tuple[MembershipFact, ...], int]: + normalized: list[MembershipFact] = [] + malformed_count = 0 + for fact in facts: + if ( + not isinstance(fact, tuple) + or len(fact) != 2 + or not isinstance(fact[0], UUID) + or not isinstance(fact[1], UUID) + or fact[0] not in allowed_collection_ids + ): + malformed_count += 1 + continue + normalized.append(fact) + return tuple(normalized), malformed_count + + @staticmethod + def _deduplicate_ids(values: Iterable[UUID]) -> tuple[UUID, ...]: + return tuple(dict.fromkeys(values)) + + +__all__ = [ + "KnowledgeRuntimeCandidateSnapshotError", + "PostgresKnowledgeRuntimeCandidateSnapshotAdapter", +] diff --git a/apps/workflow_engine/composition/runtime_retrieval.py b/apps/workflow_engine/composition/runtime_retrieval.py new file mode 100644 index 000000000..e19fc768d --- /dev/null +++ b/apps/workflow_engine/composition/runtime_retrieval.py @@ -0,0 +1,33 @@ +"""Composition boundary for Workflow runtime retrieval services.""" + +from __future__ import annotations + +from collections.abc import Callable + +from sqlalchemy.orm import Session + +from apps.workflow_engine.adapters.knowledge_runtime_candidates import ( + PostgresKnowledgeRuntimeCandidateSnapshotAdapter, +) +from apps.workflow_engine.application.runtime_retrieval.knowledge_candidates import ( + KnowledgeRuntimeCandidateResolver, +) + + +def build_knowledge_runtime_candidate_resolver( + *, + session_factory: Callable[[], Session] | None = None, +) -> KnowledgeRuntimeCandidateResolver: + if session_factory is None: + from apps.shared.db.session import SessionLocal + + session_factory = SessionLocal + + return KnowledgeRuntimeCandidateResolver( + snapshot_port=PostgresKnowledgeRuntimeCandidateSnapshotAdapter( + session_factory=session_factory + ) + ) + + +__all__ = ["build_knowledge_runtime_candidate_resolver"] diff --git a/apps/workflow_engine/tests/adapters/test_postgres_knowledge_runtime_candidate_adapter.py b/apps/workflow_engine/tests/adapters/test_postgres_knowledge_runtime_candidate_adapter.py new file mode 100644 index 000000000..ec98a753b --- /dev/null +++ b/apps/workflow_engine/tests/adapters/test_postgres_knowledge_runtime_candidate_adapter.py @@ -0,0 +1,574 @@ +from contextlib import AbstractContextManager +from types import SimpleNamespace +from uuid import UUID + +import pytest +from sqlalchemy.dialects import postgresql +from apps.shared.domain.knowledge_runtime_candidates import ( + AnonymousPublicAudience, + AuthenticatedAudience, + KnowledgeRuntimeCandidateRequest, +) +from apps.workflow_engine.adapters.knowledge_runtime_candidates import ( + PostgresKnowledgeRuntimeCandidateSnapshotAdapter, +) + + +ORG_ID = UUID(int=100) +USER_ID = UUID(int=101) +COLLECTION_A = UUID(int=200) +COLLECTION_B = UUID(int=201) +KB_1 = UUID(int=300) +KB_2 = UUID(int=301) +KB_3 = UUID(int=302) +KB_4 = UUID(int=303) +KB_5 = UUID(int=304) + + +def _collection(collection_id, *, visibility="private", organization_id=ORG_ID): + return SimpleNamespace( + id=collection_id, + organization_id=organization_id, + lifecycle_state="active", + safe_metadata={"visibility": visibility}, + ) + + +def _kb( + knowledge_base_id, + *, + source_managed=False, + organization_id=ORG_ID, + lifecycle_state="active", + sync_state="manual", +): + return SimpleNamespace( + id=knowledge_base_id, + organization_id=organization_id, + lifecycle_state=lifecycle_state, + sync_state=sync_state, + source_identity_id=UUID(int=900) if source_managed else None, + ) + + +class _NoAutoflush(AbstractContextManager): + def __init__(self, session): + self._session = session + + def __enter__(self): + self._session.events.append("no_autoflush.enter") + return self + + def __exit__(self, exc_type, exc_value, traceback): + del exc_type, exc_value, traceback + self._session.events.append("no_autoflush.exit") + return False + + +class _FakeSession: + def __init__(self, *, dialect="postgresql", in_transaction=False): + self.events = [] + self._in_transaction = in_transaction + self._dialect = dialect + self.connection_options = None + + def get_bind(self): + return SimpleNamespace(dialect=SimpleNamespace(name=self._dialect)) + + def in_transaction(self): + return self._in_transaction + + def connection(self, *, execution_options): + self.events.append("connection") + self.connection_options = execution_options + self._in_transaction = True + return object() + + @property + def no_autoflush(self): + return _NoAutoflush(self) + + def rollback(self): + self.events.append("rollback") + self._in_transaction = False + + def close(self): + self.events.append("close") + + +class _CaptureResult: + def __init__(self, rows=()): + self._rows = list(rows) + + def all(self): + return self._rows + + def scalars(self): + return self + + +class _CaptureSession: + def __init__(self, rows=()): + self.rows = rows + self.statement = None + + def execute(self, statement): + self.statement = statement + return _CaptureResult(self.rows) + + +class _PermissionHelper: + def __init__(self, *, routed=(), usable=()): + self.routed = set(routed) + self.usable = set(usable) + self.collection_calls = [] + self.kb_calls = [] + + def bulk_evaluate_collection_action(self, collections, action): + collection_list = list(collections) + self.collection_calls.append( + (tuple(collection.id for collection in collection_list), action) + ) + return { + collection.id: SimpleNamespace(allowed=collection.id in self.routed) + for collection in collection_list + } + + def bulk_evaluate_kb_use(self, kbs): + kb_list = list(kbs) + self.kb_calls.append(tuple(kb.id for kb in kb_list)) + return { + kb.id: SimpleNamespace(allowed=kb.id in self.usable) for kb in kb_list + } + + +class _FixtureAdapter(PostgresKnowledgeRuntimeCandidateSnapshotAdapter): + def __init__( + self, + session, + *, + organization_active=True, + collections=(), + memberships=(), + scan_limited=False, + kbs=(), + ready=(), + legacy_ready=(), + public_direct=(), + permission_helper=None, + ): + super().__init__(session_factory=lambda: session) + self.organization_active = organization_active + self.collections = {collection.id: collection for collection in collections} + self.memberships = tuple(memberships) + self.fixture_scan_limited = scan_limited + self.kbs = {kb.id: kb for kb in kbs} + self.ready = set(ready) + self.legacy_ready = set(legacy_ready) + self.public_direct = set(public_direct) + self.permission_helper = permission_helper + self.read_events = [] + + def _organization_is_active(self, db, organization_id): + del db + self.read_events.append(("organization", organization_id)) + return self.organization_active + + def _load_selected_collections(self, db, organization_id, collection_ids): + del db + self.read_events.append(("collections", tuple(collection_ids))) + return { + collection_id: self.collections[collection_id] + for collection_id in collection_ids + if collection_id in self.collections + and self.collections[collection_id].organization_id == organization_id + } + + def _load_fair_collection_memberships( + self, + db, + organization_id, + collection_ids, + scan_cap, + ): + del db, organization_id + self.read_events.append( + ("memberships", tuple(collection_ids), scan_cap) + ) + collection_id_set = set(collection_ids) + return ( + tuple( + membership + for membership in self.memberships + if membership[0] in collection_id_set + ), + self.fixture_scan_limited, + ) + + def _load_candidate_kbs(self, db, organization_id, knowledge_base_ids): + del db + self.read_events.append(("kbs", tuple(knowledge_base_ids))) + return { + knowledge_base_id: self.kbs[knowledge_base_id] + for knowledge_base_id in knowledge_base_ids + if knowledge_base_id in self.kbs + and self.kbs[knowledge_base_id].organization_id == organization_id + } + + def _load_ready_kb_ids(self, db, organization_id, knowledge_base_ids): + del db, organization_id + self.read_events.append(("ready", tuple(knowledge_base_ids))) + return set(knowledge_base_ids) & self.ready + + def _load_legacy_ready_kb_ids(self, db, knowledge_base_ids): + del db + self.read_events.append(("legacy", tuple(knowledge_base_ids))) + return set(knowledge_base_ids) & self.legacy_ready + + def _load_public_direct_kb_ids( + self, + db, + organization_id, + knowledge_base_ids, + ): + del db, organization_id + self.read_events.append(("public_direct", tuple(knowledge_base_ids))) + return set(knowledge_base_ids) & self.public_direct + + def _build_permission_helper(self, db, audience): + del db, audience + if self.permission_helper is None: + raise AssertionError("anonymous resolution must not construct RBAC helper") + return self.permission_helper + + +def test_snapshot_applies_postgres_repeatable_read_only_before_first_read(): + session = _FakeSession() + adapter = _FixtureAdapter(session, organization_active=False) + request = KnowledgeRuntimeCandidateRequest( + audience=AnonymousPublicAudience(organization_id=ORG_ID), + ) + + snapshot = adapter.load_snapshot(request) + + assert snapshot.eligible_direct_kb_ids == () + assert session.connection_options == { + "isolation_level": "REPEATABLE READ", + "postgresql_readonly": True, + } + assert session.events == [ + "connection", + "no_autoflush.enter", + "no_autoflush.exit", + "rollback", + "close", + ] + assert adapter.read_events == [("organization", ORG_ID)] + + +def test_snapshot_rejects_non_postgres_before_any_read_and_closes_session(): + session = _FakeSession(dialect="sqlite") + adapter = _FixtureAdapter(session) + request = KnowledgeRuntimeCandidateRequest( + audience=AnonymousPublicAudience(organization_id=ORG_ID), + ) + + with pytest.raises(RuntimeError, match="snapshot_database_unsupported"): + adapter.load_snapshot(request) + + assert adapter.read_events == [] + assert session.events == ["rollback", "close"] + + +def test_snapshot_rejects_reused_transaction_before_any_read(): + session = _FakeSession(in_transaction=True) + adapter = _FixtureAdapter(session) + request = KnowledgeRuntimeCandidateRequest( + audience=AnonymousPublicAudience(organization_id=ORG_ID), + ) + + with pytest.raises(RuntimeError, match="snapshot_session_not_fresh"): + adapter.load_snapshot(request) + + assert adapter.read_events == [] + assert "connection" not in session.events + assert session.events[-2:] == ["rollback", "close"] + + +def test_authenticated_snapshot_combines_route_use_and_bulk_readiness(): + session = _FakeSession() + helper = _PermissionHelper( + routed={COLLECTION_A}, + usable={KB_1, KB_3}, + ) + adapter = _FixtureAdapter( + session, + collections=[ + _collection(COLLECTION_A), + _collection(COLLECTION_B), + ], + memberships=[ + (COLLECTION_A, KB_3), + (COLLECTION_A, KB_4), + (COLLECTION_B, KB_5), + ], + kbs=[_kb(KB_1), _kb(KB_2), _kb(KB_3), _kb(KB_4), _kb(KB_5)], + ready={KB_1, KB_3}, + legacy_ready={KB_4}, + permission_helper=helper, + ) + request = KnowledgeRuntimeCandidateRequest( + audience=AuthenticatedAudience( + organization_id=ORG_ID, + user_id=USER_ID, + ), + direct_kb_ids=(KB_1, KB_2), + collection_ids=(COLLECTION_A, COLLECTION_B), + ) + + snapshot = adapter.load_snapshot(request) + + assert snapshot.eligible_direct_kb_ids == (KB_1,) + assert [stream.collection_id for stream in snapshot.collection_streams] == [ + COLLECTION_A + ] + assert snapshot.collection_streams[0].eligible_kb_ids == (KB_3,) + assert snapshot.policy_excluded_count == 3 + assert helper.collection_calls == [ + ((COLLECTION_A, COLLECTION_B), "route") + ] + assert helper.kb_calls == [(KB_1, KB_3, KB_4)] + assert ("memberships", (COLLECTION_A,), 5000) in adapter.read_events + assert ("legacy", (KB_2, KB_4)) in adapter.read_events + + +def test_authenticated_direct_kb_never_requires_collection_route(): + session = _FakeSession() + helper = _PermissionHelper(usable={KB_1}) + adapter = _FixtureAdapter( + session, + kbs=[_kb(KB_1)], + ready={KB_1}, + permission_helper=helper, + ) + request = KnowledgeRuntimeCandidateRequest( + audience=AuthenticatedAudience( + organization_id=ORG_ID, + user_id=USER_ID, + ), + direct_kb_ids=(KB_1,), + ) + + snapshot = adapter.load_snapshot(request) + + assert snapshot.eligible_direct_kb_ids == (KB_1,) + assert helper.collection_calls == [] + assert not any(event[0] == "memberships" for event in adapter.read_events) + + +def test_anonymous_snapshot_uses_public_membership_without_rbac_helper(): + session = _FakeSession() + adapter = _FixtureAdapter( + session, + collections=[ + _collection(COLLECTION_A, visibility="public"), + _collection(COLLECTION_B, visibility="private"), + ], + memberships=[ + (COLLECTION_A, KB_3), + (COLLECTION_A, KB_4), + (COLLECTION_B, KB_5), + ], + kbs=[ + _kb(KB_1), + _kb(KB_2), + _kb(KB_3), + _kb(KB_4, source_managed=True), + _kb(KB_5, source_managed=True), + ], + ready={KB_1, KB_2, KB_3, KB_4, KB_5}, + public_direct={KB_1}, + ) + request = KnowledgeRuntimeCandidateRequest( + audience=AnonymousPublicAudience(organization_id=ORG_ID), + direct_kb_ids=(KB_1, KB_2, KB_5), + collection_ids=(COLLECTION_A, COLLECTION_B), + ) + + snapshot = adapter.load_snapshot(request) + + assert snapshot.eligible_direct_kb_ids == (KB_1,) + assert len(snapshot.collection_streams) == 1 + assert snapshot.collection_streams[0].collection_id == COLLECTION_A + assert snapshot.collection_streams[0].eligible_kb_ids == (KB_3,) + assert snapshot.policy_excluded_count == 4 + assert ("public_direct", (KB_1, KB_2)) in adapter.read_events + + +@pytest.mark.parametrize( + "metadata", + [None, {}, {"visibility": "private"}, {"visibility": "PUBLIC"}, "public"], +) +def test_anonymous_collection_visibility_is_exact_and_fail_closed(metadata): + session = _FakeSession() + collection = _collection(COLLECTION_A) + collection.safe_metadata = metadata + adapter = _FixtureAdapter( + session, + collections=[collection], + memberships=[(COLLECTION_A, KB_1)], + kbs=[_kb(KB_1)], + ready={KB_1}, + ) + request = KnowledgeRuntimeCandidateRequest( + audience=AnonymousPublicAudience(organization_id=ORG_ID), + collection_ids=(COLLECTION_A,), + ) + + snapshot = adapter.load_snapshot(request) + + assert snapshot.collection_streams == () + assert snapshot.policy_excluded_count == 1 + assert not any(event[0] == "memberships" for event in adapter.read_events) + + +def test_snapshot_excludes_cross_org_archived_and_source_deleted_facts(): + session = _FakeSession() + helper = _PermissionHelper( + routed={COLLECTION_A, COLLECTION_B}, + usable={KB_1, KB_2, KB_3}, + ) + archived = _collection(COLLECTION_A) + archived.lifecycle_state = "archived" + adapter = _FixtureAdapter( + session, + collections=[archived, _collection(COLLECTION_B, organization_id=UUID(int=999))], + kbs=[ + _kb(KB_1, lifecycle_state="archived"), + _kb(KB_2, sync_state="source_deleted"), + _kb(KB_3, organization_id=UUID(int=999)), + ], + ready={KB_1, KB_2, KB_3}, + permission_helper=helper, + ) + request = KnowledgeRuntimeCandidateRequest( + audience=AuthenticatedAudience(ORG_ID, USER_ID), + direct_kb_ids=(KB_1, KB_2, KB_3), + collection_ids=(COLLECTION_A, COLLECTION_B), + ) + + snapshot = adapter.load_snapshot(request) + + assert snapshot.eligible_direct_kb_ids == () + assert snapshot.collection_streams == () + assert snapshot.policy_excluded_count == 5 + assert helper.collection_calls == [] + assert helper.kb_calls == [] + + +def test_scan_limit_is_preserved_as_safe_snapshot_signal(): + session = _FakeSession() + helper = _PermissionHelper(routed={COLLECTION_A}, usable={KB_1}) + adapter = _FixtureAdapter( + session, + collections=[_collection(COLLECTION_A)], + memberships=[(COLLECTION_A, KB_1)], + scan_limited=True, + kbs=[_kb(KB_1)], + ready={KB_1}, + permission_helper=helper, + ) + request = KnowledgeRuntimeCandidateRequest( + audience=AuthenticatedAudience(ORG_ID, USER_ID), + collection_ids=(COLLECTION_A,), + candidate_scan_cap=20, + ) + + snapshot = adapter.load_snapshot(request) + + assert snapshot.scan_limited is True + assert ("memberships", (COLLECTION_A,), 20) in adapter.read_events + + +def test_fair_membership_query_is_windowed_ordered_and_bounded(): + rows = [ + SimpleNamespace(collection_id=COLLECTION_A, knowledge_base_id=KB_1), + SimpleNamespace(collection_id=COLLECTION_B, knowledge_base_id=KB_2), + SimpleNamespace(collection_id=COLLECTION_A, knowledge_base_id=KB_3), + SimpleNamespace(collection_id=COLLECTION_B, knowledge_base_id=KB_4), + ] + db = _CaptureSession(rows) + adapter = PostgresKnowledgeRuntimeCandidateSnapshotAdapter( + session_factory=lambda: None + ) + + facts, scan_limited = adapter._load_fair_collection_memberships( + db, + ORG_ID, + (COLLECTION_A, COLLECTION_B), + 3, + ) + + assert facts == ( + (COLLECTION_A, KB_1), + (COLLECTION_B, KB_2), + (COLLECTION_A, KB_3), + ) + assert scan_limited is True + sql = str(db.statement.compile(dialect=postgresql.dialect())).lower() + assert "row_number() over (partition by" in sql + assert "order by anon_1.collection_position asc, case" in sql + assert "limit" in sql + + +def test_legacy_readiness_query_matches_retrieval_visible_null_pointer_rule(): + db = _CaptureSession() + adapter = PostgresKnowledgeRuntimeCandidateSnapshotAdapter( + session_factory=lambda: None + ) + + assert adapter._load_legacy_ready_kb_ids(db, (KB_1,)) == set() + + sql = str(db.statement.compile(dialect=postgresql.dialect())).lower() + assert "knowledge_bases.active_document_version_id is null" in sql + assert "document_chunks.document_version_id is null" in sql + assert "documents.status" in sql + + +def test_snapshot_queries_do_not_select_raw_collection_or_kb_text_fields(): + db = _CaptureSession() + adapter = PostgresKnowledgeRuntimeCandidateSnapshotAdapter( + session_factory=lambda: None + ) + + adapter._load_selected_collections(db, ORG_ID, (COLLECTION_A,)) + collection_sql = str( + db.statement.compile(dialect=postgresql.dialect()) + ).lower() + assert "knowledge_collections.name" not in collection_sql + assert "knowledge_collections.description" not in collection_sql + assert "source_connector_ref" not in collection_sql + + adapter._load_candidate_kbs(db, ORG_ID, (KB_1,)) + kb_sql = str(db.statement.compile(dialect=postgresql.dialect())).lower() + assert "knowledge_bases.name" not in kb_sql + assert "knowledge_bases.description" not in kb_sql + assert "knowledge_bases.safe_metadata" not in kb_sql + + +def test_session_factory_failure_does_not_expose_partial_snapshot(): + def fail_session_factory(): + raise RuntimeError("synthetic-sensitive-connection-detail") + + adapter = PostgresKnowledgeRuntimeCandidateSnapshotAdapter( + session_factory=fail_session_factory + ) + request = KnowledgeRuntimeCandidateRequest( + audience=AnonymousPublicAudience(organization_id=ORG_ID), + ) + + with pytest.raises(RuntimeError, match="snapshot_session_unavailable") as exc_info: + adapter.load_snapshot(request) + + assert "synthetic-sensitive-connection-detail" not in str(exc_info.value) + assert exc_info.value.__cause__ is None diff --git a/apps/workflow_engine/tests/test_knowledge_runtime_candidate_composition.py b/apps/workflow_engine/tests/test_knowledge_runtime_candidate_composition.py new file mode 100644 index 000000000..f961f5dc1 --- /dev/null +++ b/apps/workflow_engine/tests/test_knowledge_runtime_candidate_composition.py @@ -0,0 +1,78 @@ +import ast +from pathlib import Path + +from apps.workflow_engine.adapters.knowledge_runtime_candidates import ( + PostgresKnowledgeRuntimeCandidateSnapshotAdapter, +) +from apps.workflow_engine.application.runtime_retrieval.knowledge_candidates import ( + KnowledgeRuntimeCandidateResolver, +) +from apps.workflow_engine.composition.runtime_retrieval import ( + build_knowledge_runtime_candidate_resolver, +) + + +def test_composition_builds_runtime_resolver_without_opening_session(): + calls = [] + + def session_factory(): + calls.append("opened") + raise AssertionError("composition must not open a snapshot eagerly") + + resolver = build_knowledge_runtime_candidate_resolver( + session_factory=session_factory + ) + + assert isinstance(resolver, KnowledgeRuntimeCandidateResolver) + assert isinstance( + resolver._snapshot_port, + PostgresKnowledgeRuntimeCandidateSnapshotAdapter, + ) + assert resolver._snapshot_port._session_factory is session_factory + assert calls == [] + + +def _imported_modules(path: Path) -> set[str]: + tree = ast.parse(path.read_text(encoding="utf-8")) + modules = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + modules.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + modules.add(node.module) + return modules + + +def test_runtime_candidate_layers_never_import_gateway(): + root = Path(__file__).resolve().parents[3] + paths = ( + root / "apps/shared/domain/knowledge_runtime_candidates.py", + root + / "apps/workflow_engine/application/runtime_retrieval/knowledge_candidates.py", + root / "apps/workflow_engine/adapters/knowledge_runtime_candidates.py", + root / "apps/workflow_engine/composition/runtime_retrieval.py", + ) + + for path in paths: + assert not any( + module == "apps.gateway" or module.startswith("apps.gateway.") + for module in _imported_modules(path) + ), path + + +def test_pure_candidate_policy_has_no_framework_or_runtime_imports(): + root = Path(__file__).resolve().parents[3] + policy_path = root / "apps/shared/domain/knowledge_runtime_candidates.py" + forbidden_roots = { + "celery", + "fastapi", + "sqlalchemy", + "apps.gateway", + "apps.workflow_engine", + } + + for module in _imported_modules(policy_path): + assert not any( + module == forbidden or module.startswith(f"{forbidden}.") + for forbidden in forbidden_roots + ), module From cb3d5259941dde295080f1f49ca0a740ed21ebbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=95=EB=AF=BC?= Date: Mon, 13 Jul 2026 15:41:03 +0900 Subject: [PATCH 08/10] =?UTF-8?q?test(knowledge):=20=EB=9F=B0=ED=83=80?= =?UTF-8?q?=EC=9E=84=20=EC=8B=A4=ED=8C=A8=20=EC=97=A3=EC=A7=80=20=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test_knowledge_permission_runtime_bulk.py | 10 +- ...res_knowledge_runtime_candidate_adapter.py | 103 +++++++++++++++++- 2 files changed, 110 insertions(+), 3 deletions(-) diff --git a/apps/shared/tests/services/test_knowledge_permission_runtime_bulk.py b/apps/shared/tests/services/test_knowledge_permission_runtime_bulk.py index 69b51bb52..7610b0449 100644 --- a/apps/shared/tests/services/test_knowledge_permission_runtime_bulk.py +++ b/apps/shared/tests/services/test_knowledge_permission_runtime_bulk.py @@ -113,7 +113,10 @@ def _latest_source_authorization(self, kb): return self.provenance -@pytest.mark.parametrize("action", ["read", "view", "use", "retrieve", "search"]) +@pytest.mark.parametrize( + "action", + ["read", "view", "use", "retrieve", "search", " READ ", "Search"], +) def test_materialized_source_retrieval_action_is_compatible(action): decision = _SourceActionHelper(_provenance(action=action)).evaluate_kb_use( _source_kb() @@ -123,7 +126,10 @@ def test_materialized_source_retrieval_action_is_compatible(action): assert decision.freshness_epoch == 7 -@pytest.mark.parametrize("action", [None, "", "write", "admin", "delete", "unknown"]) +@pytest.mark.parametrize( + "action", + [None, "", "write", "admin", "delete", "unknown", 42], +) def test_materialized_source_non_retrieval_action_fails_closed(action): decision = _SourceActionHelper(_provenance(action=action)).evaluate_kb_use( _source_kb() diff --git a/apps/workflow_engine/tests/adapters/test_postgres_knowledge_runtime_candidate_adapter.py b/apps/workflow_engine/tests/adapters/test_postgres_knowledge_runtime_candidate_adapter.py index ec98a753b..67e665525 100644 --- a/apps/workflow_engine/tests/adapters/test_postgres_knowledge_runtime_candidate_adapter.py +++ b/apps/workflow_engine/tests/adapters/test_postgres_knowledge_runtime_candidate_adapter.py @@ -66,10 +66,21 @@ def __exit__(self, exc_type, exc_value, traceback): class _FakeSession: - def __init__(self, *, dialect="postgresql", in_transaction=False): + def __init__( + self, + *, + dialect="postgresql", + in_transaction=False, + connection_error=False, + rollback_error=False, + close_error=False, + ): self.events = [] self._in_transaction = in_transaction self._dialect = dialect + self._connection_error = connection_error + self._rollback_error = rollback_error + self._close_error = close_error self.connection_options = None def get_bind(self): @@ -80,6 +91,8 @@ def in_transaction(self): def connection(self, *, execution_options): self.events.append("connection") + if self._connection_error: + raise RuntimeError("internal-db-setup-detail") self.connection_options = execution_options self._in_transaction = True return object() @@ -90,10 +103,14 @@ def no_autoflush(self): def rollback(self): self.events.append("rollback") + if self._rollback_error: + raise RuntimeError("internal-db-rollback-detail") self._in_transaction = False def close(self): self.events.append("close") + if self._close_error: + raise RuntimeError("internal-db-close-detail") class _CaptureResult: @@ -247,11 +264,14 @@ def test_snapshot_applies_postgres_repeatable_read_only_before_first_read(): adapter = _FixtureAdapter(session, organization_active=False) request = KnowledgeRuntimeCandidateRequest( audience=AnonymousPublicAudience(organization_id=ORG_ID), + direct_kb_ids=(KB_1,), + collection_ids=(COLLECTION_A,), ) snapshot = adapter.load_snapshot(request) assert snapshot.eligible_direct_kb_ids == () + assert snapshot.policy_excluded_count == 2 assert session.connection_options == { "isolation_level": "REPEATABLE READ", "postgresql_readonly": True, @@ -295,6 +315,61 @@ def test_snapshot_rejects_reused_transaction_before_any_read(): assert session.events[-2:] == ["rollback", "close"] +def test_snapshot_transaction_setup_failure_is_fixed_and_raw_free(): + session = _FakeSession(connection_error=True) + adapter = _FixtureAdapter(session) + request = KnowledgeRuntimeCandidateRequest( + audience=AnonymousPublicAudience(organization_id=ORG_ID), + ) + + with pytest.raises(RuntimeError, match="snapshot_transaction_setup_failed") as exc_info: + adapter.load_snapshot(request) + + assert "internal-db-setup-detail" not in str(exc_info.value) + assert exc_info.value.__cause__ is None + assert adapter.read_events == [] + assert session.events == ["connection", "rollback", "close"] + + +class _FailingReadAdapter(_FixtureAdapter): + def _organization_is_active(self, db, organization_id): + del db, organization_id + raise RuntimeError("internal-db-read-detail") + + +def test_snapshot_read_failure_discards_partial_state_and_is_raw_free(): + session = _FakeSession() + adapter = _FailingReadAdapter(session) + request = KnowledgeRuntimeCandidateRequest( + audience=AnonymousPublicAudience(organization_id=ORG_ID), + ) + + with pytest.raises(RuntimeError, match="snapshot_read_failed") as exc_info: + adapter.load_snapshot(request) + + assert "internal-db-read-detail" not in str(exc_info.value) + assert exc_info.value.__cause__ is None + assert session.events[-2:] == ["rollback", "close"] + + +@pytest.mark.parametrize("failure", ["rollback", "close"]) +def test_snapshot_cleanup_failure_cannot_return_precleanup_result(failure): + session = _FakeSession( + rollback_error=failure == "rollback", + close_error=failure == "close", + ) + adapter = _FixtureAdapter(session, organization_active=False) + request = KnowledgeRuntimeCandidateRequest( + audience=AnonymousPublicAudience(organization_id=ORG_ID), + ) + + with pytest.raises(RuntimeError, match="snapshot_cleanup_failed") as exc_info: + adapter.load_snapshot(request) + + assert "internal-db" not in str(exc_info.value) + assert session.events[-2:] == ["rollback", "close"] + + def test_authenticated_snapshot_combines_route_use_and_bulk_readiness(): session = _FakeSession() helper = _PermissionHelper( @@ -490,6 +565,32 @@ def test_scan_limit_is_preserved_as_safe_snapshot_signal(): assert ("memberships", (COLLECTION_A,), 20) in adapter.read_events +def test_malformed_membership_is_fail_closed_without_identifier_projection(): + session = _FakeSession() + helper = _PermissionHelper(routed={COLLECTION_A}, usable={KB_1}) + adapter = _FixtureAdapter( + session, + collections=[_collection(COLLECTION_A)], + memberships=[ + (COLLECTION_A, KB_1), + (COLLECTION_A, "not-a-uuid"), + (COLLECTION_B, KB_2), + ], + kbs=[_kb(KB_1), _kb(KB_2)], + ready={KB_1, KB_2}, + permission_helper=helper, + ) + request = KnowledgeRuntimeCandidateRequest( + audience=AuthenticatedAudience(ORG_ID, USER_ID), + collection_ids=(COLLECTION_A,), + ) + + snapshot = adapter.load_snapshot(request) + + assert snapshot.collection_streams[0].eligible_kb_ids == (KB_1,) + assert snapshot.policy_excluded_count == 1 + + def test_fair_membership_query_is_windowed_ordered_and_bounded(): rows = [ SimpleNamespace(collection_id=COLLECTION_A, knowledge_base_id=KB_1), From a89590add0edea5048ec3c7f88475887d6549e01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=95=EB=AF=BC?= Date: Mon, 13 Jul 2026 17:41:48 +0900 Subject: [PATCH 09/10] =?UTF-8?q?fix(knowledge):=20=EB=9F=B0=ED=83=80?= =?UTF-8?q?=EC=9E=84=20=EA=B6=8C=ED=95=9C=20=EC=8A=A4=EB=83=85=EC=83=B7=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=20=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/knowledge_permission_service.py | 12 +- .../test_knowledge_runtime_candidates.py | 1 - .../test_knowledge_permission_runtime_bulk.py | 37 ++++++- .../adapters/knowledge_runtime_candidates.py | 104 ++++++++++++++---- ...res_knowledge_runtime_candidate_adapter.py | 54 ++++++++- ...-knowledge-runtime-candidate-resolution.md | 18 ++- docs/features/knowledge/api_spec.md | 3 + docs/features/knowledge/component_spec.md | 2 +- docs/features/knowledge/requirements.md | 4 +- 9 files changed, 200 insertions(+), 35 deletions(-) diff --git a/apps/shared/services/knowledge_permission_service.py b/apps/shared/services/knowledge_permission_service.py index ed00a26c6..ea21295c6 100644 --- a/apps/shared/services/knowledge_permission_service.py +++ b/apps/shared/services/knowledge_permission_service.py @@ -61,12 +61,22 @@ def __init__( organization_id: uuid.UUID, requester_subject_type: str = "user", requester_subject_id: uuid.UUID | None = None, + evaluation_time: datetime | None = None, ) -> None: + if evaluation_time is not None: + if ( + not isinstance(evaluation_time, datetime) + or evaluation_time.tzinfo is None + or evaluation_time.utcoffset() is None + ): + raise ValueError("knowledge_permission_evaluation_time_invalid") + evaluation_time = evaluation_time.astimezone(timezone.utc) self.db = db self.user_id = user_id self.organization_id = organization_id self.requester_subject_type = requester_subject_type self.requester_subject_id = requester_subject_id or user_id + self._evaluation_time = evaluation_time self._team_ids_cache: set[uuid.UUID] | None = None self._bulk_manual_auth_state_by_kb_id: dict[uuid.UUID, str] | None = None self._bulk_source_policy_allowed_kb_ids: set[uuid.UUID] | None = None @@ -840,7 +850,7 @@ def _is_expired(self, expires_at: datetime | None) -> bool: return expires_at <= self._now() def _now(self) -> datetime: - return datetime.now(timezone.utc) + return self._evaluation_time or datetime.now(timezone.utc) def _allowed( self, diff --git a/apps/shared/tests/domain/test_knowledge_runtime_candidates.py b/apps/shared/tests/domain/test_knowledge_runtime_candidates.py index c7ae7c736..facb65909 100644 --- a/apps/shared/tests/domain/test_knowledge_runtime_candidates.py +++ b/apps/shared/tests/domain/test_knowledge_runtime_candidates.py @@ -2,7 +2,6 @@ from uuid import UUID import pytest - from apps.shared.domain.knowledge_runtime_candidates import ( AnonymousPublicAudience, AuthenticatedAudience, diff --git a/apps/shared/tests/services/test_knowledge_permission_runtime_bulk.py b/apps/shared/tests/services/test_knowledge_permission_runtime_bulk.py index 7610b0449..589ae4c49 100644 --- a/apps/shared/tests/services/test_knowledge_permission_runtime_bulk.py +++ b/apps/shared/tests/services/test_knowledge_permission_runtime_bulk.py @@ -100,8 +100,13 @@ def test_bulk_collection_invalid_action_is_fixed_safe_denial(): class _SourceActionHelper(KnowledgePermissionHelper): - def __init__(self, provenance): - super().__init__(None, user_id=USER_ID, organization_id=ORG_ID) + def __init__(self, provenance, *, evaluation_time=None): + super().__init__( + None, + user_id=USER_ID, + organization_id=ORG_ID, + evaluation_time=evaluation_time, + ) self.provenance = provenance def _effective_kb_use_auth_state(self, kb): @@ -138,3 +143,31 @@ def test_materialized_source_non_retrieval_action_fails_closed(action): assert decision.allowed is False assert decision.reason_code == "source_authorization.operation_unverified" assert decision.external_reason_code == "resource.hidden" + + +def test_source_expiry_uses_one_injected_invocation_time(): + evaluation_time = datetime(2030, 1, 1, tzinfo=timezone.utc) + provenance = _provenance(action="read") + provenance.freshness_expires_at = evaluation_time + timedelta(seconds=1) + helper = _SourceActionHelper( + provenance, + evaluation_time=evaluation_time, + ) + + decision = helper.evaluate_kb_use(_source_kb()) + + assert decision.allowed is True + assert helper._now() == evaluation_time + + +def test_permission_evaluation_time_requires_timezone(): + with pytest.raises( + ValueError, + match="knowledge_permission_evaluation_time_invalid", + ): + KnowledgePermissionHelper( + None, + user_id=USER_ID, + organization_id=ORG_ID, + evaluation_time=datetime(2030, 1, 1), + ) diff --git a/apps/workflow_engine/adapters/knowledge_runtime_candidates.py b/apps/workflow_engine/adapters/knowledge_runtime_candidates.py index cc739e482..0b01e6ae1 100644 --- a/apps/workflow_engine/adapters/knowledge_runtime_candidates.py +++ b/apps/workflow_engine/adapters/knowledge_runtime_candidates.py @@ -3,10 +3,11 @@ from __future__ import annotations from collections.abc import Callable, Iterable +from datetime import datetime, timezone from typing import Any from uuid import UUID -from sqlalchemy import and_, case, func, select +from sqlalchemy import Integer, and_, column, func, select, true, values from sqlalchemy.orm import Session, load_only from apps.shared.db.models.knowledge import ( @@ -143,7 +144,12 @@ def _load_snapshot_in_transaction( permission_helper: KnowledgePermissionHelper | None = None if isinstance(request.audience, AuthenticatedAudience): - permission_helper = self._build_permission_helper(db, request.audience) + evaluation_time = self._load_policy_evaluation_time(db) + permission_helper = self._build_permission_helper( + db, + request.audience, + evaluation_time=evaluation_time, + ) route_decisions = ( permission_helper.bulk_evaluate_collection_action( configured_collections, @@ -384,33 +390,72 @@ def _load_fair_collection_memberships( if not bounded_collection_ids: return (), False - collection_position = func.row_number().over( - partition_by=KnowledgeCollectionItem.collection_id, - order_by=( + configured_collections = values( + column( + "collection_id", + KnowledgeCollectionItem.collection_id.type, + ), + column("configured_order", Integer()), + name="selected_collections", + ).data( + [ + (collection_id, configured_order) + for configured_order, collection_id in enumerate( + bounded_collection_ids + ) + ] + ) + bounded_collection_items = ( + select( + KnowledgeCollectionItem.collection_id.label("collection_id"), + KnowledgeCollectionItem.knowledge_base_id.label( + "knowledge_base_id" + ), + KnowledgeCollectionItem.rank.label("item_rank"), + KnowledgeCollectionItem.created_at.label("item_created_at"), + ) + .where( + KnowledgeCollectionItem.organization_id == organization_id, + KnowledgeCollectionItem.collection_id + == configured_collections.c.collection_id, + ) + .order_by( KnowledgeCollectionItem.rank.asc(), KnowledgeCollectionItem.created_at.asc(), KnowledgeCollectionItem.knowledge_base_id.asc(), + ) + .limit(scan_cap + 1) + .lateral("bounded_collection_items") + ) + bounded_memberships = ( + select( + bounded_collection_items.c.collection_id, + bounded_collection_items.c.knowledge_base_id, + bounded_collection_items.c.item_rank, + bounded_collection_items.c.item_created_at, + configured_collections.c.configured_order, + ) + .select_from(configured_collections) + .join(bounded_collection_items, true()) + .cte("bounded_memberships") + ) + collection_position = func.row_number().over( + partition_by=bounded_memberships.c.collection_id, + order_by=( + bounded_memberships.c.item_rank.asc(), + bounded_memberships.c.item_created_at.asc(), + bounded_memberships.c.knowledge_base_id.asc(), ), ).label("collection_position") ranked_items = ( select( - KnowledgeCollectionItem.collection_id.label("collection_id"), - KnowledgeCollectionItem.knowledge_base_id.label("knowledge_base_id"), + bounded_memberships.c.collection_id, + bounded_memberships.c.knowledge_base_id, + bounded_memberships.c.configured_order, collection_position, ) - .where( - KnowledgeCollectionItem.organization_id == organization_id, - KnowledgeCollectionItem.collection_id.in_(bounded_collection_ids), - ) - .subquery() - ) - configured_order = case( - { - collection_id: index - for index, collection_id in enumerate(bounded_collection_ids) - }, - value=ranked_items.c.collection_id, - else_=len(bounded_collection_ids), + .select_from(bounded_memberships) + .cte("ranked_memberships") ) rows = db.execute( select( @@ -419,7 +464,7 @@ def _load_fair_collection_memberships( ) .order_by( ranked_items.c.collection_position.asc(), - configured_order.asc(), + ranked_items.c.configured_order.asc(), ranked_items.c.knowledge_base_id.asc(), ) .limit(scan_cap + 1) @@ -499,6 +544,20 @@ def _load_ready_kb_ids( .all() ) + def _load_policy_evaluation_time(self, db: Session) -> datetime: + evaluation_time = db.execute( + select(func.transaction_timestamp()) + ).scalar_one() + if ( + not isinstance(evaluation_time, datetime) + or evaluation_time.tzinfo is None + or evaluation_time.utcoffset() is None + ): + raise KnowledgeRuntimeCandidateSnapshotError( + "snapshot_evaluation_time_invalid" + ) + return evaluation_time.astimezone(timezone.utc) + def _load_legacy_ready_kb_ids( self, db: Session, @@ -573,11 +632,14 @@ def _build_permission_helper( self, db: Session, audience: AuthenticatedAudience, + *, + evaluation_time: datetime, ) -> KnowledgePermissionHelper: return KnowledgePermissionHelper( db, user_id=audience.user_id, organization_id=audience.organization_id, + evaluation_time=evaluation_time, ) @staticmethod diff --git a/apps/workflow_engine/tests/adapters/test_postgres_knowledge_runtime_candidate_adapter.py b/apps/workflow_engine/tests/adapters/test_postgres_knowledge_runtime_candidate_adapter.py index 67e665525..f941eccc7 100644 --- a/apps/workflow_engine/tests/adapters/test_postgres_knowledge_runtime_candidate_adapter.py +++ b/apps/workflow_engine/tests/adapters/test_postgres_knowledge_runtime_candidate_adapter.py @@ -1,4 +1,5 @@ from contextlib import AbstractContextManager +from datetime import datetime, timezone from types import SimpleNamespace from uuid import UUID @@ -10,6 +11,7 @@ KnowledgeRuntimeCandidateRequest, ) from apps.workflow_engine.adapters.knowledge_runtime_candidates import ( + KnowledgeRuntimeCandidateSnapshotError, PostgresKnowledgeRuntimeCandidateSnapshotAdapter, ) @@ -123,6 +125,11 @@ def all(self): def scalars(self): return self + def scalar_one(self): + if len(self._rows) != 1: + raise AssertionError("expected exactly one captured scalar") + return self._rows[0] + class _CaptureSession: def __init__(self, rows=()): @@ -184,6 +191,7 @@ def __init__( self.legacy_ready = set(legacy_ready) self.public_direct = set(public_direct) self.permission_helper = permission_helper + self.evaluation_time = datetime(2030, 1, 2, tzinfo=timezone.utc) self.read_events = [] def _organization_is_active(self, db, organization_id): @@ -252,8 +260,14 @@ def _load_public_direct_kb_ids( self.read_events.append(("public_direct", tuple(knowledge_base_ids))) return set(knowledge_base_ids) & self.public_direct - def _build_permission_helper(self, db, audience): + def _load_policy_evaluation_time(self, db): + del db + self.read_events.append(("evaluation_time", self.evaluation_time)) + return self.evaluation_time + + def _build_permission_helper(self, db, audience, *, evaluation_time): del db, audience + self.read_events.append(("permission_helper", evaluation_time)) if self.permission_helper is None: raise AssertionError("anonymous resolution must not construct RBAC helper") return self.permission_helper @@ -413,6 +427,8 @@ def test_authenticated_snapshot_combines_route_use_and_bulk_readiness(): ((COLLECTION_A, COLLECTION_B), "route") ] assert helper.kb_calls == [(KB_1, KB_3, KB_4)] + assert ("evaluation_time", adapter.evaluation_time) in adapter.read_events + assert ("permission_helper", adapter.evaluation_time) in adapter.read_events assert ("memberships", (COLLECTION_A,), 5000) in adapter.read_events assert ("legacy", (KB_2, KB_4)) in adapter.read_events @@ -617,9 +633,12 @@ def test_fair_membership_query_is_windowed_ordered_and_bounded(): ) assert scan_limited is True sql = str(db.statement.compile(dialect=postgresql.dialect())).lower() - assert "row_number() over (partition by" in sql - assert "order by anon_1.collection_position asc, case" in sql - assert "limit" in sql + assert "join lateral" in sql + assert "values" in sql + assert "bounded_memberships" in sql + assert "row_number() over (partition by bounded_memberships.collection_id" in sql + assert sql.count("limit") >= 2 + assert sql.index("limit") < sql.index("row_number() over") def test_legacy_readiness_query_matches_retrieval_visible_null_pointer_rule(): @@ -636,6 +655,33 @@ def test_legacy_readiness_query_matches_retrieval_visible_null_pointer_rule(): assert "documents.status" in sql +def test_policy_evaluation_time_uses_one_timezone_aware_transaction_timestamp(): + evaluation_time = datetime(2030, 1, 2, tzinfo=timezone.utc) + db = _CaptureSession([evaluation_time]) + adapter = PostgresKnowledgeRuntimeCandidateSnapshotAdapter( + session_factory=lambda: None + ) + + loaded = adapter._load_policy_evaluation_time(db) + + assert loaded == evaluation_time + sql = str(db.statement.compile(dialect=postgresql.dialect())).lower() + assert "transaction_timestamp()" in sql + + +def test_policy_evaluation_time_rejects_naive_database_value(): + db = _CaptureSession([datetime(2030, 1, 2)]) + adapter = PostgresKnowledgeRuntimeCandidateSnapshotAdapter( + session_factory=lambda: None + ) + + with pytest.raises( + KnowledgeRuntimeCandidateSnapshotError, + match="snapshot_evaluation_time_invalid", + ): + adapter._load_policy_evaluation_time(db) + + def test_snapshot_queries_do_not_select_raw_collection_or_kb_text_fields(): db = _CaptureSession() adapter = PostgresKnowledgeRuntimeCandidateSnapshotAdapter( diff --git a/docs/decisions/ADR-0036-knowledge-runtime-candidate-resolution.md b/docs/decisions/ADR-0036-knowledge-runtime-candidate-resolution.md index ff9185947..b9d6e462a 100644 --- a/docs/decisions/ADR-0036-knowledge-runtime-candidate-resolution.md +++ b/docs/decisions/ADR-0036-knowledge-runtime-candidate-resolution.md @@ -130,8 +130,13 @@ ID 또는 permission 결과도 invocation 사이에 cache하지 않는다. 5. Duplicate를 만나면 해당 slot을 소비하지 않고 traversal을 계속한다. Adapter의 membership scan도 선택한 첫 Collection이 나머지를 굶기지 않도록 fair하고 -bounded해야 한다. Budget 또는 scan cap 도달은 성공 결과에 fixed safe warning과 -bucketed summary를 붙이는 동작이며 authorization partial failure가 아니다. +bounded해야 한다. Global window 뒤에만 `LIMIT`을 두지 않고, configured Collection +`VALUES` relation과 ordered LATERAL subquery로 각 Collection을 `scan_cap + 1` 이하로 +먼저 제한한 뒤 bounded intermediate relation에만 round-robin window를 적용한다. 이 +구현은 기존 ordering을 유지하고 DB migration 없이 window 입력을 최대 +`selected_collection_count * (scan_cap + 1)`로 제한한다. Budget 또는 scan cap 도달은 +성공 결과에 fixed safe warning과 bucketed summary를 붙이는 동작이며 authorization +partial failure가 아니다. ### PostgreSQL invocation snapshot @@ -143,7 +148,14 @@ Session-wide isolation 변경을 pooled connection에 남기지 않는다. Collection, membership, lifecycle/readiness, permission, materialized source provenance query는 같은 snapshot을 사용한다. 중간에 commit된 변경은 다음 resolver invocation부터 반영한다. Fake session test는 이 동시성 계약의 증명이 아니며 disposable -PostgreSQL two-transaction test를 둔다. +PostgreSQL two-transaction test를 둔다. Source-policy grant와 materialized provenance의 +expiry는 KB마다 wall clock을 다시 읽지 않고 같은 transaction에서 한 번 읽은 timezone-aware +`transaction_timestamp()`를 permission helper에 주입해 invocation 전체에서 재사용한다. + +Disposable PostgreSQL evidence는 membership뿐 아니라 Collection `route`, KB `use`, +organization membership, source provenance, Collection lifecycle와 KB lifecycle 변경을 +포함한다. 관련 runtime resolver 경로가 바뀌는 pull request와 `dev` push에서 전용 +path-scoped workflow가 이 evidence를 실행한다. ### Result와 failure diff --git a/docs/features/knowledge/api_spec.md b/docs/features/knowledge/api_spec.md index 89ed2c689..6f4add3ee 100644 --- a/docs/features/knowledge/api_spec.md +++ b/docs/features/knowledge/api_spec.md @@ -122,6 +122,9 @@ Resolver adapter는 invocation마다 fresh PostgreSQL transaction을 열고 첫 lifecycle/readiness/permission/materialized provenance는 같은 snapshot에서 읽고 candidate 또는 authorization 결과를 invocation 사이에 cache하지 않는다. Live connector `check_access*`와 runtime source authorization cache는 MBA-232에서 호출하지 않는다. +Membership은 configured Collection별 ordered LATERAL cap을 먼저 적용한 bounded +intermediate relation에서 round-robin ranking한다. Source-policy/provenance expiry는 같은 +transaction에서 한 번 읽은 `transaction_timestamp()`를 전체 invocation에 재사용한다. ### KB Permission Endpoints diff --git a/docs/features/knowledge/component_spec.md b/docs/features/knowledge/component_spec.md index 167e21bfa..61b0bed89 100644 --- a/docs/features/knowledge/component_spec.md +++ b/docs/features/knowledge/component_spec.md @@ -49,7 +49,7 @@ MBA-105 구현 baseline, 운영 기본값, permission helper output, active vers | --- | --- | --- | | Shared pure contract/policy | explicit audience/request/snapshot/result, direct-first/round-robin/dedupe/budget, safe bucket | SQLAlchemy, FastAPI, Celery, Gateway/Workflow concrete import | | Workflow Engine application use case/port | request validation, snapshot port 1회 호출, pure policy 적용, whole-resolution failure mapping | SQL query, Gateway response schema, provider/retrieval side effect | -| PostgreSQL outbound adapter | fresh `REPEATABLE READ, READ ONLY` transaction, selected Collection/membership/readiness/permission/materialized provenance bulk projection | organization-wide discovery, live connector/source call, cross-invocation cache | +| PostgreSQL outbound adapter | fresh `REPEATABLE READ, READ ONLY` transaction, selected Collection별 pre-window LATERAL cap, fixed transaction evaluation time, membership/readiness/permission/materialized provenance bulk projection | organization-wide discovery, live connector/source call, cross-invocation cache | | Workflow Engine composition | session factory, adapter와 use case 조립 | LLM node business policy와 graph parsing | Gateway의 기존 `KnowledgeCandidateResolver`는 Builder recommendation/deployment diff --git a/docs/features/knowledge/requirements.md b/docs/features/knowledge/requirements.md index 453579d4d..fab99f0df 100644 --- a/docs/features/knowledge/requirements.md +++ b/docs/features/knowledge/requirements.md @@ -123,8 +123,8 @@ Workflow canvas에는 독립형 RAG 실행 노드를 도입하지 않는다. Kno - FR-085 (Conversation Memory Target Integration): V1 Memory dependency는 optional 의미를 지원하지 않는다. Knowledge evidence가 answer에 영향을 주면 해당 dependency는 모두 필수이며 하나라도 current authorization을 잃으면 derived entry 전체를 제외해야 한다. - FR-086 (MBA-232): Workflow runtime candidate request는 server-owned canonical organization과 `AuthenticatedAudience(organization_id, user_id)` 또는 `AnonymousPublicAudience(organization_id)` 중 하나, configured direct KB ID, 명시 selected Collection ID와 server candidate budget을 사용한다. Optional user에서 owner/builder/deployment owner/credential principal/service account로 fallback하지 않는다. - FR-087 (MBA-232): Runtime은 명시 selected Collection만 해석한다. Collection ID 생략과 빈 목록은 모두 Collection stream 0개이며 organization-wide discovery/fallback을 수행하지 않는다. Direct KB는 Collection route 없이 KB `use`와 applicable source gate를 통과하고, Collection child는 Collection `route`와 독립적인 child KB `use`/source gate를 모두 통과해야 한다. -- FR-088 (MBA-232): Runtime candidate는 direct configured order를 먼저 유지하고 남은 budget을 selected Collection configured order의 round-robin으로 채운다. Collection 내부 tie-break는 item rank, item created time, KB UUID이며 canonical KB UUID로 dedupe하고 첫 provenance를 보존한다. 초기 budget은 최대 20 unique KB이고 budget/scan cap 도달은 fixed safe warning을 가진 성공 결과다. -- FR-089 (MBA-232): Runtime resolver PostgreSQL adapter는 invocation마다 fresh transaction을 열고 첫 query 전에 `REPEATABLE READ, READ ONLY`를 적용한다. Collection, membership, lifecycle/readiness, permission과 materialized source provenance는 같은 snapshot을 사용하고 candidate/authorization 결과를 invocation 사이에 cache하지 않는다. +- FR-088 (MBA-232): Runtime candidate는 direct configured order를 먼저 유지하고 남은 budget을 selected Collection configured order의 round-robin으로 채운다. Collection 내부 tie-break는 item rank, item created time, KB UUID이며 canonical KB UUID로 dedupe하고 첫 provenance를 보존한다. 초기 budget은 최대 20 unique KB이고 budget/scan cap 도달은 fixed safe warning을 가진 성공 결과다. Membership query는 configured Collection별 ordered LATERAL cap을 window ranking 전에 적용해 window 입력을 bounded하게 유지한다. +- FR-089 (MBA-232): Runtime resolver PostgreSQL adapter는 invocation마다 fresh transaction을 열고 첫 query 전에 `REPEATABLE READ, READ ONLY`를 적용한다. Collection, membership, lifecycle/readiness, permission과 materialized source provenance는 같은 snapshot을 사용하고 candidate/authorization 결과를 invocation 사이에 cache하지 않는다. Expiry 판단은 transaction의 timezone-aware evaluation timestamp 하나를 전체 permission helper 호출에 재사용한다. - FR-090 (MBA-232): Authenticated source-managed KB는 active/fresh/matching materialized `SourceAuthorizationProvenance`가 필요하다. Missing, inactive, stale, expired, unmapped, ambiguous, unverified, revoked, denied, unknown 또는 organization/requester/KB/source mismatch는 fail-closed다. MBA-232는 connector client, `check_access_batch`, single `check_access` 또는 runtime source cache를 호출하지 않는다. - FR-091 (MBA-232): Anonymous public audience는 normal Team/User/domain permission을 소비하지 않는다. Selected Collection child는 active public Collection membership, direct KB는 하나 이상의 active public Collection membership이 필요하다. Public exposure primitive가 없는 현재 schema에서 source-managed KB는 public Collection membership이나 authenticated provenance와 무관하게 모두 제외한다. - FR-092 (MBA-232): `KnowledgeCollectionItem`은 lifecycle object가 아니다. Present row는 linked, unlink/missing은 membership 없음이며 Collection과 child KB lifecycle/readiness를 독립적으로 평가한다. From 391370f609962455fc25a8abc5622c678bb63ff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=95=EB=AF=BC?= Date: Mon, 13 Jul 2026 17:41:58 +0900 Subject: [PATCH 10/10] =?UTF-8?q?test(knowledge):=20PostgreSQL=20=EB=9F=B0?= =?UTF-8?q?=ED=83=80=EC=9E=84=20=EA=B6=8C=ED=95=9C=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test-knowledge-runtime-postgres.yml | 84 +++ ...ge_runtime_snapshot_disposable_postgres.py | 527 ++++++++++++++++++ docs/features/knowledge/test_cases.md | 5 +- 3 files changed, 614 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/test-knowledge-runtime-postgres.yml diff --git a/.github/workflows/test-knowledge-runtime-postgres.yml b/.github/workflows/test-knowledge-runtime-postgres.yml new file mode 100644 index 000000000..681addac2 --- /dev/null +++ b/.github/workflows/test-knowledge-runtime-postgres.yml @@ -0,0 +1,84 @@ +name: Test Knowledge Runtime PostgreSQL + +on: + pull_request: + paths: + - "apps/shared/domain/knowledge_runtime_candidates.py" + - "apps/shared/services/knowledge_permission_service.py" + - "apps/shared/tests/db/test_knowledge_runtime_snapshot_disposable_postgres.py" + - "apps/shared/tests/domain/test_knowledge_runtime_candidates.py" + - "apps/shared/tests/services/test_knowledge_permission_runtime_bulk.py" + - "apps/workflow_engine/adapters/knowledge_runtime_candidates.py" + - "apps/workflow_engine/application/runtime_retrieval/**" + - "apps/workflow_engine/composition/runtime_retrieval.py" + - "apps/workflow_engine/tests/adapters/test_postgres_knowledge_runtime_candidate_adapter.py" + - ".github/workflows/test-knowledge-runtime-postgres.yml" + push: + branches: + - dev + paths: + - "apps/shared/domain/knowledge_runtime_candidates.py" + - "apps/shared/services/knowledge_permission_service.py" + - "apps/shared/tests/db/test_knowledge_runtime_snapshot_disposable_postgres.py" + - "apps/shared/tests/domain/test_knowledge_runtime_candidates.py" + - "apps/shared/tests/services/test_knowledge_permission_runtime_bulk.py" + - "apps/workflow_engine/adapters/knowledge_runtime_candidates.py" + - "apps/workflow_engine/application/runtime_retrieval/**" + - "apps/workflow_engine/composition/runtime_retrieval.py" + - "apps/workflow_engine/tests/adapters/test_postgres_knowledge_runtime_candidate_adapter.py" + - ".github/workflows/test-knowledge-runtime-postgres.yml" + +permissions: + contents: read + +jobs: + knowledge-runtime-postgres: + runs-on: ubuntu-latest + timeout-minutes: 20 + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + env: + DB_HOST: 127.0.0.1 + DB_PORT: "5432" + DB_USER: postgres + DB_PASSWORD: postgres + NODEASE_DISPOSABLE_DB_MAINTENANCE_DB: postgres + NODEASE_RUN_DISPOSABLE_DB_TEST: "1" + PYTHONPATH: ${{ github.workspace }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install test dependencies + run: | + uv sync --project apps/workflow_engine --extra dev --frozen + uv pip install --python apps/workflow_engine/.venv/bin/python -e apps/shared + + - name: Run runtime snapshot and SQL contract evidence + run: >- + apps/workflow_engine/.venv/bin/python -m pytest + apps/shared/tests/db/test_knowledge_runtime_snapshot_disposable_postgres.py + apps/workflow_engine/tests/adapters/test_postgres_knowledge_runtime_candidate_adapter.py + -q diff --git a/apps/shared/tests/db/test_knowledge_runtime_snapshot_disposable_postgres.py b/apps/shared/tests/db/test_knowledge_runtime_snapshot_disposable_postgres.py index d0ce1888e..4afbd090f 100644 --- a/apps/shared/tests/db/test_knowledge_runtime_snapshot_disposable_postgres.py +++ b/apps/shared/tests/db/test_knowledge_runtime_snapshot_disposable_postgres.py @@ -6,6 +6,7 @@ import pytest from apps.shared.domain.knowledge_runtime_candidates import ( AnonymousPublicAudience, + AuthenticatedAudience, KnowledgeRuntimeCandidateRequest, ) from apps.shared.tests.helpers.disposable_postgres import ( @@ -26,13 +27,68 @@ def _create_schema(engine) -> None: statements = ( + """ + CREATE TABLE users ( + id UUID PRIMARY KEY, + email VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + password VARCHAR(255) NULL, + social_provider VARCHAR(50) NOT NULL DEFAULT 'test', + social_id VARCHAR(255) NULL, + avatar_url VARCHAR(255) NULL, + deactivated_at TIMESTAMPTZ NULL, + last_login_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """, """ CREATE TABLE organization ( id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL DEFAULT 'test organization', + options JSONB NOT NULL DEFAULT '{}'::jsonb, + flags BIGINT NOT NULL DEFAULT 0, + created_by UUID NULL, + managed_by UUID NULL, + is_active BOOLEAN NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deactivated_at TIMESTAMPTZ NULL + ) + """, + """ + CREATE TABLE organization_memberships ( + id UUID PRIMARY KEY, + organization_id UUID NOT NULL, + user_id UUID NOT NULL, + membership_state VARCHAR(50) NOT NULL, + organization_auth_state VARCHAR(50) NOT NULL, + invited_by UUID NULL, + invited_at TIMESTAMPTZ NULL, + accepted_at TIMESTAMPTZ NULL, + removed_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + options JSONB NOT NULL DEFAULT '{}'::jsonb, + flags BIGINT NOT NULL DEFAULT 0 + ) + """, + """ + CREATE TABLE teams ( + id UUID PRIMARY KEY, + organization_id UUID NOT NULL, is_active BOOLEAN NOT NULL ) """, """ + CREATE TABLE team_memberships ( + id UUID PRIMARY KEY, + grantee_organization_id UUID NOT NULL, + team_id UUID NOT NULL, + user_id UUID NOT NULL + ) + """, + """ CREATE TABLE knowledge_collections ( id UUID PRIMARY KEY, organization_id UUID NOT NULL, @@ -86,6 +142,67 @@ def _create_schema(engine) -> None: ) """, """ + CREATE TABLE team_knowledge_collection_permissions ( + knowledge_collection_id UUID NOT NULL, + team_id UUID NOT NULL, + grantee_organization_id UUID NOT NULL, + permission_action VARCHAR(32) NOT NULL + ) + """, + """ + CREATE TABLE user_knowledge_collection_permissions ( + knowledge_collection_id UUID NOT NULL, + user_id UUID NOT NULL, + grantee_organization_id UUID NOT NULL, + permission_action VARCHAR(32) NOT NULL + ) + """, + """ + CREATE TABLE team_knowledge_permissions ( + knowledge_base_id UUID NOT NULL, + team_id UUID NOT NULL, + grantee_organization_id UUID NOT NULL, + auth_state VARCHAR(50) NOT NULL + ) + """, + """ + CREATE TABLE user_knowledge_permissions ( + knowledge_base_id UUID NOT NULL, + user_id UUID NOT NULL, + grantee_organization_id UUID NOT NULL, + auth_state VARCHAR(50) NOT NULL + ) + """, + """ + CREATE TABLE source_policy_kb_use_grants ( + knowledge_base_id UUID NOT NULL, + source_identity_id UUID NULL, + organization_id UUID NOT NULL, + permission_action VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL, + expires_at TIMESTAMPTZ NULL, + subject_type VARCHAR(32) NOT NULL, + subject_id UUID NOT NULL + ) + """, + """ + CREATE TABLE source_authorization_provenance ( + id UUID PRIMARY KEY, + organization_id UUID NOT NULL, + knowledge_base_id UUID NOT NULL, + source_identity_id UUID NULL, + requester_subject_type VARCHAR(32) NOT NULL, + requester_subject_id UUID NOT NULL, + source_acl_state VARCHAR(32) NOT NULL, + requester_source_authorization VARCHAR(32) NOT NULL, + source_permission_action VARCHAR(64) NULL, + freshness_epoch BIGINT NOT NULL, + freshness_expires_at TIMESTAMPTZ NULL, + status VARCHAR(32) NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """, + """ CREATE TABLE snapshot_write_probe ( id UUID PRIMARY KEY ) @@ -168,6 +285,313 @@ def _seed_ready_public_collection(engine): return organization_id, collection_id, knowledge_base_id +def _seed_two_public_collections(engine): + organization_id = uuid4() + collection_ids = (uuid4(), uuid4()) + knowledge_base_ids_by_collection = [] + with engine.begin() as connection: + connection.execute( + text( + "INSERT INTO organization (id, is_active) " + "VALUES (:organization_id, true)" + ), + {"organization_id": organization_id}, + ) + for collection_id in collection_ids: + connection.execute( + text( + "INSERT INTO knowledge_collections " + "(id, organization_id, lifecycle_state, sync_state, " + "is_system_managed, safe_metadata) " + "VALUES (:collection_id, :organization_id, 'active', " + "'manual', false, " + "'{\"visibility\": \"public\"}'::jsonb)" + ), + { + "collection_id": collection_id, + "organization_id": organization_id, + }, + ) + collection_kb_ids = [] + # More than scan_cap + 1 in the bounded-scan test so the per- + # Collection LATERAL limit is exercised by real PostgreSQL. + for rank in range(6): + knowledge_base_id = uuid4() + version_id = uuid4() + collection_kb_ids.append(knowledge_base_id) + connection.execute( + text( + "INSERT INTO knowledge_bases " + "(id, organization_id, active_document_version_id, " + "source_identity_id, sync_state, lifecycle_state) " + "VALUES (:knowledge_base_id, :organization_id, " + ":version_id, NULL, 'manual', 'active')" + ), + { + "knowledge_base_id": knowledge_base_id, + "organization_id": organization_id, + "version_id": version_id, + }, + ) + connection.execute( + text( + "INSERT INTO document_versions " + "(id, organization_id, knowledge_base_id, status) " + "VALUES (:version_id, :organization_id, " + ":knowledge_base_id, 'ready')" + ), + { + "version_id": version_id, + "organization_id": organization_id, + "knowledge_base_id": knowledge_base_id, + }, + ) + connection.execute( + text( + "INSERT INTO knowledge_collection_items " + "(id, organization_id, collection_id, " + "knowledge_base_id, rank, created_at) " + "VALUES (:item_id, :organization_id, " + ":collection_id, :knowledge_base_id, :rank, " + "clock_timestamp())" + ), + { + "item_id": uuid4(), + "organization_id": organization_id, + "collection_id": collection_id, + "knowledge_base_id": knowledge_base_id, + "rank": rank, + }, + ) + knowledge_base_ids_by_collection.append(tuple(collection_kb_ids)) + return ( + organization_id, + collection_ids, + tuple(knowledge_base_ids_by_collection), + ) + + +def _seed_authenticated_collection(engine, *, source_managed: bool): + owner_id = uuid4() + requester_id = uuid4() + organization_id = uuid4() + membership_id = uuid4() + collection_id = uuid4() + knowledge_base_id = uuid4() + version_id = uuid4() + item_id = uuid4() + source_identity_id = uuid4() if source_managed else None + with engine.begin() as connection: + connection.execute( + text( + "INSERT INTO users " + "(id, email, name, social_provider) VALUES " + "(:owner_id, :owner_email, 'owner', 'test'), " + "(:requester_id, :requester_email, 'requester', 'test')" + ), + { + "owner_id": owner_id, + "owner_email": f"owner-{owner_id.hex}@test.invalid", + "requester_id": requester_id, + "requester_email": f"requester-{requester_id.hex}@test.invalid", + }, + ) + connection.execute( + text( + "INSERT INTO organization " + "(id, created_by, is_active) " + "VALUES (:organization_id, :owner_id, true)" + ), + { + "organization_id": organization_id, + "owner_id": owner_id, + }, + ) + connection.execute( + text( + "INSERT INTO organization_memberships " + "(id, organization_id, user_id, membership_state, " + "organization_auth_state) " + "VALUES (:membership_id, :organization_id, :requester_id, " + "'active', 'member')" + ), + { + "membership_id": membership_id, + "organization_id": organization_id, + "requester_id": requester_id, + }, + ) + connection.execute( + text( + "INSERT INTO knowledge_collections " + "(id, organization_id, lifecycle_state, sync_state, " + "is_system_managed, safe_metadata) " + "VALUES (:collection_id, :organization_id, 'active', " + "'manual', false, '{\"visibility\": \"private\"}'::jsonb)" + ), + { + "collection_id": collection_id, + "organization_id": organization_id, + }, + ) + connection.execute( + text( + "INSERT INTO knowledge_bases " + "(id, organization_id, active_document_version_id, " + "source_identity_id, sync_state, lifecycle_state) " + "VALUES (:knowledge_base_id, :organization_id, :version_id, " + ":source_identity_id, 'manual', 'active')" + ), + { + "knowledge_base_id": knowledge_base_id, + "organization_id": organization_id, + "version_id": version_id, + "source_identity_id": source_identity_id, + }, + ) + connection.execute( + text( + "INSERT INTO document_versions " + "(id, organization_id, knowledge_base_id, status) " + "VALUES (:version_id, :organization_id, " + ":knowledge_base_id, 'ready')" + ), + { + "version_id": version_id, + "organization_id": organization_id, + "knowledge_base_id": knowledge_base_id, + }, + ) + connection.execute( + text( + "INSERT INTO knowledge_collection_items " + "(id, organization_id, collection_id, knowledge_base_id, " + "rank, created_at) " + "VALUES (:item_id, :organization_id, :collection_id, " + ":knowledge_base_id, 0, clock_timestamp())" + ), + { + "item_id": item_id, + "organization_id": organization_id, + "collection_id": collection_id, + "knowledge_base_id": knowledge_base_id, + }, + ) + connection.execute( + text( + "INSERT INTO user_knowledge_collection_permissions " + "(knowledge_collection_id, user_id, " + "grantee_organization_id, permission_action) " + "VALUES (:collection_id, :requester_id, " + ":organization_id, 'route')" + ), + { + "collection_id": collection_id, + "requester_id": requester_id, + "organization_id": organization_id, + }, + ) + connection.execute( + text( + "INSERT INTO user_knowledge_permissions " + "(knowledge_base_id, user_id, grantee_organization_id, " + "auth_state) VALUES (:knowledge_base_id, :requester_id, " + ":organization_id, 'operator')" + ), + { + "knowledge_base_id": knowledge_base_id, + "requester_id": requester_id, + "organization_id": organization_id, + }, + ) + if source_identity_id is not None: + connection.execute( + text( + "INSERT INTO source_authorization_provenance " + "(id, organization_id, knowledge_base_id, " + "source_identity_id, requester_subject_type, " + "requester_subject_id, source_acl_state, " + "requester_source_authorization, " + "source_permission_action, freshness_epoch, " + "freshness_expires_at, status) VALUES " + "(:provenance_id, :organization_id, " + ":knowledge_base_id, :source_identity_id, 'user', " + ":requester_id, 'fresh', 'allowed', 'read', 1, " + "clock_timestamp() + interval '1 hour', 'active')" + ), + { + "provenance_id": uuid4(), + "organization_id": organization_id, + "knowledge_base_id": knowledge_base_id, + "source_identity_id": source_identity_id, + "requester_id": requester_id, + }, + ) + return { + "organization_id": organization_id, + "requester_id": requester_id, + "collection_id": collection_id, + "knowledge_base_id": knowledge_base_id, + } + + +def _apply_authenticated_mutation(connection, mutation, seeded): + params = { + "organization_id": seeded["organization_id"], + "requester_id": seeded["requester_id"], + "collection_id": seeded["collection_id"], + "knowledge_base_id": seeded["knowledge_base_id"], + } + statements = { + "collection_route": ( + "DELETE FROM user_knowledge_collection_permissions " + "WHERE grantee_organization_id = :organization_id " + "AND user_id = :requester_id " + "AND knowledge_collection_id = :collection_id" + ), + "kb_use": ( + "DELETE FROM user_knowledge_permissions " + "WHERE grantee_organization_id = :organization_id " + "AND user_id = :requester_id " + "AND knowledge_base_id = :knowledge_base_id" + ), + "organization_membership": ( + "UPDATE organization_memberships " + "SET membership_state = 'removed', removed_at = clock_timestamp() " + "WHERE organization_id = :organization_id " + "AND user_id = :requester_id" + ), + "source_provenance": ( + "UPDATE source_authorization_provenance " + "SET source_acl_state = 'revoked', " + "requester_source_authorization = 'denied', " + "freshness_epoch = freshness_epoch + 1, " + "updated_at = clock_timestamp() " + "WHERE organization_id = :organization_id " + "AND requester_subject_id = :requester_id " + "AND knowledge_base_id = :knowledge_base_id" + ), + "collection_lifecycle": ( + "UPDATE knowledge_collections SET lifecycle_state = 'archived' " + "WHERE organization_id = :organization_id " + "AND id = :collection_id" + ), + "kb_lifecycle": ( + "UPDATE knowledge_bases SET lifecycle_state = 'archived' " + "WHERE organization_id = :organization_id " + "AND id = :knowledge_base_id" + ), + } + connection.execute(text(statements[mutation]), params) + + +def _snapshot_contains(snapshot, knowledge_base_id): + return knowledge_base_id in snapshot.eligible_direct_kb_ids or any( + knowledge_base_id in stream.eligible_kb_ids + for stream in snapshot.collection_streams + ) + + @pytest.fixture def disposable_snapshot_database(): try: @@ -218,6 +642,11 @@ def __init__(self, *, session_factory, snapshot_started, writer_finished): self.writer_finished = writer_finished self.isolation_level = None self.transaction_read_only = None + self.evaluation_time = None + + def _load_policy_evaluation_time(self, db): + self.evaluation_time = super()._load_policy_evaluation_time(db) + return self.evaluation_time def _load_selected_collections( self, @@ -307,6 +736,104 @@ def test_repeatable_read_snapshot_does_not_mix_concurrent_membership_change( ).scalar_one() == probe_id +@pytest.mark.skipif( + os.getenv(RUN_ENV) != "1", + reason=f"set {RUN_ENV}=1 to run disposable Knowledge bounded scan evidence", +) +def test_lateral_membership_scan_is_bounded_and_fair_in_postgres( + disposable_snapshot_database, +): + engine = disposable_snapshot_database + organization_id, collection_ids, kb_ids_by_collection = ( + _seed_two_public_collections(engine) + ) + session_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) + request = KnowledgeRuntimeCandidateRequest( + audience=AnonymousPublicAudience(organization_id=organization_id), + collection_ids=collection_ids, + candidate_budget=4, + candidate_scan_cap=4, + ) + + snapshot = PostgresKnowledgeRuntimeCandidateSnapshotAdapter( + session_factory=session_factory + ).load_snapshot(request) + + assert snapshot.scan_limited is True + assert tuple(stream.collection_id for stream in snapshot.collection_streams) == ( + collection_ids + ) + assert tuple( + stream.eligible_kb_ids for stream in snapshot.collection_streams + ) == ( + kb_ids_by_collection[0][:2], + kb_ids_by_collection[1][:2], + ) + + +@pytest.mark.parametrize( + ("mutation", "source_managed"), + [ + ("collection_route", False), + ("kb_use", False), + ("organization_membership", False), + ("source_provenance", True), + ("collection_lifecycle", False), + ("kb_lifecycle", False), + ], +) +@pytest.mark.skipif( + os.getenv(RUN_ENV) != "1", + reason=f"set {RUN_ENV}=1 to run disposable Knowledge authorization evidence", +) +def test_authenticated_snapshot_keeps_one_revision_and_next_call_sees_mutation( + disposable_snapshot_database, + mutation, + source_managed, +): + engine = disposable_snapshot_database + seeded = _seed_authenticated_collection( + engine, + source_managed=source_managed, + ) + session_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) + snapshot_started = Event() + writer_finished = Event() + adapter = _PausingSnapshotAdapter( + session_factory=session_factory, + snapshot_started=snapshot_started, + writer_finished=writer_finished, + ) + request = KnowledgeRuntimeCandidateRequest( + audience=AuthenticatedAudience( + organization_id=seeded["organization_id"], + user_id=seeded["requester_id"], + ), + collection_ids=(seeded["collection_id"],), + ) + + with ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(adapter.load_snapshot, request) + assert snapshot_started.wait(timeout=15) + try: + with engine.begin() as connection: + _apply_authenticated_mutation(connection, mutation, seeded) + finally: + writer_finished.set() + first_snapshot = future.result(timeout=15) + + assert adapter.isolation_level == "repeatable read" + assert adapter.transaction_read_only == "on" + assert adapter.evaluation_time is not None + assert adapter.evaluation_time.utcoffset() is not None + assert _snapshot_contains(first_snapshot, seeded["knowledge_base_id"]) + + next_snapshot = PostgresKnowledgeRuntimeCandidateSnapshotAdapter( + session_factory=session_factory + ).load_snapshot(request) + assert not _snapshot_contains(next_snapshot, seeded["knowledge_base_id"]) + + class _WriteAttemptSnapshotAdapter(PostgresKnowledgeRuntimeCandidateSnapshotAdapter): def _organization_is_active(self, db, organization_id: UUID) -> bool: del organization_id diff --git a/docs/features/knowledge/test_cases.md b/docs/features/knowledge/test_cases.md index c51c66838..e4a029147 100644 --- a/docs/features/knowledge/test_cases.md +++ b/docs/features/knowledge/test_cases.md @@ -75,10 +75,11 @@ Status: Draft - Authenticated source-managed KB는 active/fresh/unexpired/matching materialized `SourceAuthorizationProvenance`가 필요하다. Missing/inactive/stale/unmapped/ambiguous/unverified/revoked/denied/unknown/expired/organization-requester-KB-source mismatch는 fail-closed다. - MBA-232 adapter는 connector client, HTTP client, `check_access_batch`, single `check_access`, runtime source authorization cache를 0회 호출한다. - Anonymous selected Collection child는 active public Collection의 active/ready manual KB만 허용한다. Direct manual KB도 하나 이상의 active public Collection membership이 필요하다. Source public exposure primitive가 없는 동안 source-managed KB는 public membership과 authenticated provenance가 있어도 모두 제외한다. -- PostgreSQL adapter는 fresh transaction의 첫 query 전에 `REPEATABLE READ, READ ONLY`를 적용한다. Two-transaction test에서 resolver 시작 뒤 membership/route/use/provenance/lifecycle 변경이 commit되어도 current invocation은 한 snapshot만 보고 다음 invocation이 변경을 본다. +- PostgreSQL adapter는 fresh transaction의 첫 query 전에 `REPEATABLE READ, READ ONLY`를 적용한다. Path-scoped PostgreSQL CI의 two-transaction test에서 resolver 시작 뒤 membership/Collection route/KB use/organization membership/source provenance/Collection lifecycle/KB lifecycle 변경이 commit되어도 current invocation은 한 snapshot만 보고 다음 invocation이 변경을 본다. +- Source-policy/provenance expiry는 timezone-aware PostgreSQL transaction timestamp 하나로 전체 invocation을 평가한다. KB 순회 중 wall clock이 만료 경계를 지나도 같은 invocation에서 서로 다른 evaluation time을 사용하면 테스트 실패다. - Snapshot/repository/authorization infrastructure exception은 fixed safe retryable whole-resolution failure다. 이미 평가한 candidate partial set, raw SQL/exception, identifier, source metadata, exact count를 반환하거나 retrieval/provider mock을 호출하면 테스트 실패다. - Candidate 0개는 `safe_no_result`, budget 제한은 successful warning이며 downstream partial retrieval failure와 구분한다. -- Query count는 candidate/Collection 수에 비례하는 N+1이 아니고 selected 20 Collections/5,000 membership fixture에서도 scan/memory/result가 bounded하고 fair해야 한다. +- Query count는 candidate/Collection 수에 비례하는 N+1이 아니고 selected 20 Collections/5,000 membership fixture에서도 scan/memory/result가 bounded하고 fair해야 한다. Membership SQL은 Collection별 LATERAL cap을 global window보다 먼저 적용하고 outer `LIMIT`만으로 boundedness를 주장하지 않는다. - Shared pure policy는 SQLAlchemy/FastAPI/Celery/Gateway/Workflow Engine concrete package를 import하지 않고 Workflow Engine runtime retrieval production code는 `apps.gateway.*`를 import하지 않는다. ## Knowledge Base API Tests