diff --git a/apps/gateway/tests/api/test_deployment_permissions.py b/apps/gateway/tests/api/test_deployment_permissions.py index 9f35a90ba..3d99de645 100644 --- a/apps/gateway/tests/api/test_deployment_permissions.py +++ b/apps/gateway/tests/api/test_deployment_permissions.py @@ -484,6 +484,51 @@ async def run_service(**kwargs): assert captured["request_id"] == "req-1" +@pytest.mark.parametrize( + ("field", "value"), + [ + ("user_id", str(uuid.uuid4())), + ("organization_id", str(uuid.uuid4())), + ( + "execution_subject", + {"type": "user", "id": str(uuid.uuid4())}, + ), + ], +) +def test_authenticated_run_rejects_top_level_execution_context_override( + monkeypatch, + field, + value, +): + current_user = SimpleNamespace(id=uuid.uuid4()) + test_app = FastAPI() + test_app.include_router(deployment_endpoint.router, prefix="/deployments") + test_app.dependency_overrides[deployment_endpoint.get_db] = lambda: object() + test_app.dependency_overrides[deployment_endpoint.get_current_user] = ( + lambda: current_user + ) + test_app.dependency_overrides[get_deployment_runtime_policy] = ( + lambda: DEFAULT_DEPLOYMENT_RUNTIME_POLICY + ) + + async def fail_run_service(**_kwargs): + raise AssertionError("invalid request must not reach deployment execution") + + monkeypatch.setattr( + deployment_endpoint.DeploymentService, + "run_authenticated_deployment", + fail_run_service, + ) + + response = TestClient(test_app).post( + f"/deployments/{uuid.uuid4()}/run", + json={"inputs": {"question": "safe"}, field: value}, + ) + + assert response.status_code == 422 + assert response.json()["detail"][0]["type"] == "extra_forbidden" + + def test_run_authenticated_deployment_forwards_middleware_request_id( monkeypatch, ): diff --git a/apps/gateway/tests/services/test_chatbot_deployment_run.py b/apps/gateway/tests/services/test_chatbot_deployment_run.py index c703bcdf4..501966cda 100644 --- a/apps/gateway/tests/services/test_chatbot_deployment_run.py +++ b/apps/gateway/tests/services/test_chatbot_deployment_run.py @@ -315,6 +315,8 @@ def test_authenticated_run_uses_current_user_execution_subject( app_row, deployment_row = _deployed_app(deployment_type) current_user_id = uuid4() client_conversation_id = str(uuid4()) + forged_user_id = str(uuid4()) + forged_organization_id = str(uuid4()) db = _Db(rows=[app_row, deployment_row]) budget_calls = [] evaluated_surfaces = [] @@ -341,7 +343,12 @@ def capture_budget_call(db, **kwargs): db, deployment_row.id, current_user_id, - {"question": "안녕"}, + { + "question": "안녕", + "user_id": forged_user_id, + "organization_id": forged_organization_id, + "execution_subject": {"type": "user", "id": forged_user_id}, + }, monkeypatch, client_conversation_id=client_conversation_id, ) @@ -357,7 +364,13 @@ def capture_budget_call(db, **kwargs): assert ctx["memory_mode"] is True assert ctx["conversation_id"].startswith("auth:v1:") assert client_conversation_id not in ctx["conversation_id"] - assert sent_inputs == {"question": "안녕"} + assert sent_inputs == { + "question": "안녕", + "user_id": forged_user_id, + "organization_id": forged_organization_id, + "execution_subject": {"type": "user", "id": forged_user_id}, + } + assert ctx["organization_id"] == str(app_row.organization_id) assert budget_calls == [ { "workflow_id": app_row.workflow_id, 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 1439eb519..3c1264606 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 @@ -1,3 +1,4 @@ +import json import os from concurrent.futures import ThreadPoolExecutor from threading import Event @@ -18,7 +19,21 @@ KnowledgeRuntimeCandidateSnapshotError, PostgresKnowledgeRuntimeCandidateSnapshotAdapter, ) +from apps.workflow_engine.application.runtime_retrieval.knowledge_candidates import ( + KnowledgeRuntimeCandidateResolver, +) +from apps.workflow_engine.workflow.nodes.llm.entities import ( + KnowledgeBaseRef, + KnowledgeCollectionRef, + LLMNodeData, +) +from apps.workflow_engine.workflow.nodes.llm.llm_node import ( + RAG_NO_EVIDENCE_MESSAGE, + LLMNode, + WorkflowRAGFanoutResult, +) from sqlalchemy import create_engine, text +from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import sessionmaker RUN_ENV = "NODEASE_RUN_DISPOSABLE_DB_TEST" @@ -536,6 +551,275 @@ def _seed_authenticated_collection(engine, *, source_managed: bool): } +def _seed_authenticated_user_matrix(engine): + organization_ids = {"a": uuid4(), "b": uuid4()} + user_ids = { + "dev_a": uuid4(), + "planning_a": uuid4(), + "direct_a": uuid4(), + "none_a": uuid4(), + "dev_b": uuid4(), + } + team_ids = {"dev_a": uuid4(), "planning_a": uuid4(), "dev_b": uuid4()} + collection_ids = {"department_a": uuid4(), "department_b": uuid4()} + knowledge_base_ids = { + "common_a": uuid4(), + "dev_a": uuid4(), + "planning_a": uuid4(), + "direct_a": uuid4(), + "hidden_a": uuid4(), + "common_b": uuid4(), + } + + with engine.begin() as connection: + connection.execute( + text( + "INSERT INTO users (id, email, name, social_provider) " + "VALUES (:id, :email, :name, 'test')" + ), + [ + { + "id": user_id, + "email": f"{alias}-{user_id.hex}@test.invalid", + "name": alias, + } + for alias, user_id in user_ids.items() + ], + ) + connection.execute( + text( + "INSERT INTO organization (id, is_active) " + "VALUES (:id, true)" + ), + [{"id": organization_id} for organization_id in organization_ids.values()], + ) + connection.execute( + text( + "INSERT INTO organization_memberships " + "(id, organization_id, user_id, membership_state, " + "organization_auth_state) VALUES " + "(:id, :organization_id, :user_id, 'active', 'member')" + ), + [ + { + "id": uuid4(), + "organization_id": organization_ids["a"], + "user_id": user_ids[alias], + } + for alias in ("dev_a", "planning_a", "direct_a", "none_a") + ] + + [ + { + "id": uuid4(), + "organization_id": organization_ids["b"], + "user_id": user_ids["dev_b"], + } + ], + ) + connection.execute( + text( + "INSERT INTO teams (id, organization_id, is_active) " + "VALUES (:id, :organization_id, true)" + ), + [ + { + "id": team_ids["dev_a"], + "organization_id": organization_ids["a"], + }, + { + "id": team_ids["planning_a"], + "organization_id": organization_ids["a"], + }, + { + "id": team_ids["dev_b"], + "organization_id": organization_ids["b"], + }, + ], + ) + connection.execute( + text( + "INSERT INTO team_memberships " + "(id, grantee_organization_id, team_id, user_id) " + "VALUES (:id, :organization_id, :team_id, :user_id)" + ), + [ + { + "id": uuid4(), + "organization_id": organization_ids["a"], + "team_id": team_ids["dev_a"], + "user_id": user_ids["dev_a"], + }, + { + "id": uuid4(), + "organization_id": organization_ids["a"], + "team_id": team_ids["planning_a"], + "user_id": user_ids["planning_a"], + }, + { + "id": uuid4(), + "organization_id": organization_ids["b"], + "team_id": team_ids["dev_b"], + "user_id": user_ids["dev_b"], + }, + ], + ) + connection.execute( + text( + "INSERT INTO knowledge_collections " + "(id, organization_id, lifecycle_state, sync_state, " + "is_system_managed, safe_metadata) VALUES " + "(:id, :organization_id, 'active', 'manual', false, " + "'{\"visibility\": \"private\"}'::jsonb)" + ), + [ + { + "id": collection_ids["department_a"], + "organization_id": organization_ids["a"], + }, + { + "id": collection_ids["department_b"], + "organization_id": organization_ids["b"], + }, + ], + ) + + version_ids = {alias: uuid4() for alias in knowledge_base_ids} + connection.execute( + text( + "INSERT INTO knowledge_bases " + "(id, organization_id, active_document_version_id, " + "source_identity_id, sync_state, lifecycle_state) VALUES " + "(:id, :organization_id, :version_id, NULL, 'manual', 'active')" + ), + [ + { + "id": knowledge_base_id, + "organization_id": ( + organization_ids["b"] + if alias.endswith("_b") + else organization_ids["a"] + ), + "version_id": version_ids[alias], + } + for alias, knowledge_base_id in knowledge_base_ids.items() + ], + ) + connection.execute( + text( + "INSERT INTO document_versions " + "(id, organization_id, knowledge_base_id, status) " + "VALUES (:id, :organization_id, :knowledge_base_id, 'ready')" + ), + [ + { + "id": version_ids[alias], + "organization_id": ( + organization_ids["b"] + if alias.endswith("_b") + else organization_ids["a"] + ), + "knowledge_base_id": knowledge_base_id, + } + for alias, knowledge_base_id in knowledge_base_ids.items() + ], + ) + connection.execute( + text( + "INSERT INTO knowledge_collection_items " + "(id, organization_id, collection_id, knowledge_base_id, " + "rank, created_at) VALUES " + "(:id, :organization_id, :collection_id, :knowledge_base_id, " + ":rank, clock_timestamp())" + ), + [ + { + "id": uuid4(), + "organization_id": organization_ids["a"], + "collection_id": collection_ids["department_a"], + "knowledge_base_id": knowledge_base_ids[alias], + "rank": rank, + } + for rank, alias in enumerate( + ("common_a", "dev_a", "planning_a", "hidden_a") + ) + ] + + [ + { + "id": uuid4(), + "organization_id": organization_ids["b"], + "collection_id": collection_ids["department_b"], + "knowledge_base_id": knowledge_base_ids["common_b"], + "rank": 0, + } + ], + ) + connection.execute( + text( + "INSERT INTO team_knowledge_collection_permissions " + "(knowledge_collection_id, team_id, grantee_organization_id, " + "permission_action) VALUES " + "(:collection_id, :team_id, :organization_id, 'route')" + ), + [ + { + "collection_id": collection_ids["department_a"], + "team_id": team_ids["dev_a"], + "organization_id": organization_ids["a"], + }, + { + "collection_id": collection_ids["department_a"], + "team_id": team_ids["planning_a"], + "organization_id": organization_ids["a"], + }, + { + "collection_id": collection_ids["department_b"], + "team_id": team_ids["dev_b"], + "organization_id": organization_ids["b"], + }, + ], + ) + connection.execute( + text( + "INSERT INTO team_knowledge_permissions " + "(knowledge_base_id, team_id, grantee_organization_id, auth_state) " + "VALUES (:knowledge_base_id, :team_id, :organization_id, 'operator')" + ), + [ + { + "knowledge_base_id": knowledge_base_ids[kb_alias], + "team_id": team_ids[team_alias], + "organization_id": organization_ids[organization_alias], + } + for team_alias, organization_alias, kb_alias in ( + ("dev_a", "a", "common_a"), + ("dev_a", "a", "dev_a"), + ("planning_a", "a", "common_a"), + ("planning_a", "a", "planning_a"), + ("dev_b", "b", "common_b"), + ) + ], + ) + connection.execute( + text( + "INSERT INTO user_knowledge_permissions " + "(knowledge_base_id, user_id, grantee_organization_id, auth_state) " + "VALUES (:knowledge_base_id, :user_id, :organization_id, 'operator')" + ), + { + "knowledge_base_id": knowledge_base_ids["direct_a"], + "user_id": user_ids["direct_a"], + "organization_id": organization_ids["a"], + }, + ) + + return { + "organizations": organization_ids, + "users": user_ids, + "collections": collection_ids, + "knowledge_bases": knowledge_base_ids, + } + + def _apply_authenticated_mutation(connection, mutation, seeded): params = { "organization_id": seeded["organization_id"], @@ -612,28 +896,219 @@ def disposable_snapshot_database(): 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) + 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) + except SQLAlchemyError: + raise pytest.fail.Exception( + "disposable PostgreSQL setup failed", + pytrace=False, + ) from None 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()" + try: + 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}")) + except SQLAlchemyError: + raise pytest.fail.Exception( + "disposable PostgreSQL cleanup failed", + pytrace=False, + ) from None + finally: + admin_engine.dispose() + + +class _ProviderMustNotRun: + def __init__(self) -> None: + self.calls = 0 + + def invoke_sync(self, **_kwargs): + self.calls += 1 + raise AssertionError("LLM provider must not run without usable evidence") + + +@pytest.mark.skipif( + os.getenv(RUN_ENV) != "1", + reason=f"set {RUN_ENV}=1 to run disposable Knowledge user matrix evidence", +) +def test_internal_chatbot_user_matrix_reaches_llm_node_with_authorized_candidates_only( + disposable_snapshot_database, + monkeypatch, +): + engine = disposable_snapshot_database + seeded = _seed_authenticated_user_matrix(engine) + session_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) + resolver = KnowledgeRuntimeCandidateResolver( + snapshot_port=PostgresKnowledgeRuntimeCandidateSnapshotAdapter( + session_factory=session_factory + ) + ) + organizations = seeded["organizations"] + users = seeded["users"] + collections = seeded["collections"] + kbs = seeded["knowledge_bases"] + direct_kb_ids = (kbs["direct_a"], kbs["hidden_a"]) + collection_ids = (collections["department_a"], collections["department_b"]) + + matrix = ( + ( + "dev_a", + organizations["a"], + ((kbs["common_a"], "collection"), (kbs["dev_a"], "collection")), + ), + ( + "planning_a", + organizations["a"], + ( + (kbs["common_a"], "collection"), + (kbs["planning_a"], "collection"), + ), + ), + ("direct_a", organizations["a"], ((kbs["direct_a"], "direct"),)), + ("none_a", organizations["a"], ()), + ("dev_b", organizations["a"], ()), + ("dev_b", organizations["b"], ((kbs["common_b"], "collection"),)), + ) + + for user_alias, organization_id, expected in matrix: + resolution = resolver.resolve( + KnowledgeRuntimeCandidateRequest( + audience=AuthenticatedAudience( + organization_id=organization_id, + user_id=users[user_alias], + ), + direct_kb_ids=direct_kb_ids, + collection_ids=collection_ids, + ) + ) + + assert tuple( + (candidate.knowledge_base_id, candidate.provenance.kind) + for candidate in resolution.candidates + ) == expected + assert resolution.policy_excluded_count_bucket in { + "0", + "1", + "2-10", + "11-100", + "100+", + } + assert str(kbs["hidden_a"]) not in repr(resolution) + + node_cases = ( + ("dev_a", (kbs["common_a"], kbs["dev_a"])), + ("planning_a", (kbs["common_a"], kbs["planning_a"])), + ("none_a", ()), + ) + for user_alias, expected_kb_ids in node_cases: + provider = _ProviderMustNotRun() + node = LLMNode( + "llm-mba-238", + LLMNodeData( + title="LLM", + provider="openai", + model_id="gpt-4o", + user_prompt="policy question", + knowledgeBases=[ + KnowledgeBaseRef(id=str(kbs["direct_a"]), name="Direct"), + KnowledgeBaseRef( + id=str(kbs["hidden_a"]), + name="DENIED_SENTINEL_HIDDEN", ), - {"database": database}, + ], + knowledgeCollections=[ + KnowledgeCollectionRef( + id=str(collections["department_a"]), + safeLabel="Department A", + ), + KnowledgeCollectionRef( + id=str(collections["department_b"]), + safeLabel="DENIED_SENTINEL_CROSS_ORG", + ), + ], + ), + execution_context={ + "user_id": str(users[user_alias]), + "organization_id": str(organizations["a"]), + "execution_subject": { + "type": "user", + "id": str(users[user_alias]), + }, + "db": object(), + }, + ) + node.bind_knowledge_runtime_candidate_resolver(resolver) + node._client_override = provider # noqa: SLF001 + precompute_calls = [] + fanout_calls = [] + + if expected_kb_ids: + monkeypatch.setattr( + node, + "_precompute_rag_query_vectors_by_kb", + lambda *args, **kwargs: precompute_calls.append( + tuple(kwargs["knowledge_base_ids"]) ) - connection.execute(text(f"DROP DATABASE {quoted_database}")) - admin_engine.dispose() + or ({}, 0, False), + ) + + def capture_fanout(**kwargs): + fanout_calls.append(tuple(kwargs["knowledge_base_ids"])) + return WorkflowRAGFanoutResult(results=[], failed_count=0) + + monkeypatch.setattr(node, "_run_rag_retrieval_fanout", capture_fanout) + else: + monkeypatch.setattr( + node, + "_precompute_rag_query_vectors_by_kb", + lambda *args, **kwargs: pytest.fail( + "embedding must not run for zero candidates" + ), + ) + monkeypatch.setattr( + node, + "_run_rag_retrieval_fanout", + lambda **kwargs: pytest.fail( + "retrieval must not run for zero candidates" + ), + ) + + result = node._run({}) # noqa: SLF001 + + assert provider.calls == 0 + assert result["text"] == RAG_NO_EVIDENCE_MESSAGE + assert fanout_calls == ( + [tuple(str(kb_id) for kb_id in expected_kb_ids)] + if expected_kb_ids + else [] + ) + assert precompute_calls == fanout_calls + + public_projection = json.dumps( + {"result": result, "trace": node._trace_payloads}, # noqa: SLF001 + ensure_ascii=False, + default=str, + ) + assert "DENIED_SENTINEL" not in public_projection + assert all( + str(resource_id) not in public_projection + for resource_id in (*kbs.values(), *collections.values()) + ) class _PausingSnapshotAdapter(PostgresKnowledgeRuntimeCandidateSnapshotAdapter): diff --git a/docs/features/chatbot-deployment/test_cases.md b/docs/features/chatbot-deployment/test_cases.md index e665559ab..016465968 100644 --- a/docs/features/chatbot-deployment/test_cases.md +++ b/docs/features/chatbot-deployment/test_cases.md @@ -1,6 +1,7 @@ # Chatbot Deployment Test Cases Status: Draft +Verified Against: `origin/dev @ 32fb602f` ## Unit Tests @@ -72,6 +73,18 @@ Status: Draft - 공개 실행은 무인증 표면이므로 private Knowledge/RAG 후보를 anonymous public-only 경계 밖으로 확장하지 않는다. - 공개 챗봇 활성화 preflight는 client-supplied audience hint로 우회할 수 없다. +## MBA-238 Internal Chatbot Subject Integrity Tests + +- 인증 실행 request body의 top-level `user_id`, `organization_id`, `execution_subject`와 기타 + unknown field는 schema validation에서 거부하고 실행 service를 호출하지 않는다. +- Gateway가 dispatch하는 subject는 현재 로그인 사용자여야 하며 deployment creator, + workflow owner 또는 request input으로 대체되지 않는다. Organization은 server가 조회한 + deployment app organization을 사용한다. +- `X-Organization-Id`가 app organization과 다르면 workflow permission check와 dispatch 전에 + resource-hiding 응답으로 차단한다. +- Gateway subject dispatch의 기존 service/API 테스트를 재사용하고, Knowledge 후보 차이는 + production PostgreSQL resolver와 Workflow LLM node 통합 테스트에서 검증한다. + ## Target Runtime Boundary Tests - Public route에 valid login cookie가 있어도 execution principal은 anonymous public audience이며 private KB/Memory를 사용하지 않는다. diff --git a/docs/features/knowledge/test_cases.md b/docs/features/knowledge/test_cases.md index 127db34c7..43587afdf 100644 --- a/docs/features/knowledge/test_cases.md +++ b/docs/features/knowledge/test_cases.md @@ -1,6 +1,7 @@ # Knowledge Test Cases Status: Draft +Verified Against: `origin/dev @ 32fb602f` 이 문서는 현재 RAG 동작과 목표 KB 통합 모델에 필요한 테스트 범위를 함께 기록한다. MBA-105 목표 모델 테스트는 [ADR-0017](../../decisions/ADR-0017-knowledge-integration-provisional-implementation-baseline.md)과 [implementation_baseline.md](implementation_baseline.md)의 임시 baseline을 기준으로 구현 blocker가 된다. ## Unit Tests @@ -149,6 +150,25 @@ Status: Draft rollback은 Client/Gateway write 중지와 drain 뒤 Worker를 되돌린다. 구 Worker가 Collection graph를 소비할 수 있는 상태에서는 rollout/rollback acceptance가 실패다. +## MBA-238 Internal Chatbot User Permission Integration Tests + +- 실제 PostgreSQL production candidate adapter에서 같은 organization의 Dev Team 사용자, + Planning Team 사용자, user-direct `operator` 사용자와 Knowledge 권한이 없는 사용자를 + 한 fixture로 비교한다. 각 사용자는 자신의 team-bound 또는 user-direct KB `use`와 + 선택한 Collection `route`를 모두 충족한 후보만 얻어야 한다. +- 다른 organization의 사용자와 동명 리소스는 target organization 후보에 포함되지 않는다. + 이 격리는 in-memory query fake가 아니라 실제 organization, membership, permission + predicate로 검증한다. +- Runtime resolver가 반환하지 않은 KB는 embedding 또는 vector retrieval 입력에 전달되지 + 않는다. 최종 후보가 0개이면 embedding, retrieval, LLM provider를 모두 건너뛰고 표준 + no-evidence 응답을 반환한다. +- Collection 유래 evidence와 권한 필터 관측값에는 child KB/Collection identity, raw content, + 정확한 denied count가 없어야 한다. 허용/거부 synthetic marker는 응답, citation, + trace summary와 audit projection 전체에서 검사한다. +- 기존 MBA-232/MBA-233/MBA-241 테스트가 snapshot, bounded scan, resolver failure, + citation redaction을 이미 검증하면 해당 테스트를 acceptance evidence로 재사용하고 같은 + 단위 테스트를 MBA-238 이름으로 복제하지 않는다. + ## Knowledge Base API Tests - KB create는 blank name을 DB insert 전에 거부하고 safe validation reason code만 반환한다. diff --git a/docs/features/workflow/test_cases.md b/docs/features/workflow/test_cases.md index bee794700..aed1f4999 100644 --- a/docs/features/workflow/test_cases.md +++ b/docs/features/workflow/test_cases.md @@ -1,7 +1,7 @@ # Workflow Test Cases Status: Draft -Verified Against: `feature/mba-219 @ 5b1cf366` +Verified Against: `origin/dev @ 32fb602f` ## Test File Mapping @@ -474,6 +474,21 @@ Frontend 공통 그래프 검증은 catalog v2의 incoming/outgoing 금지 정 structure를 노출하지 않는다. Runtime trace는 routing mode와 count/limit/failure safe summary만 허용한다. +## MBA-238 Authenticated Subject To RAG Integration Tests + +- 인증 내부 챗봇의 serialized `execution_context.execution_subject`는 LLM node에서 + `AuthenticatedAudience`로 해석되고, organization과 user ID가 production candidate + resolver 요청까지 그대로 유지되어야 한다. +- 같은 graph를 서로 다른 로그인 사용자가 실행하면 resolver가 각 사용자의 PostgreSQL + team/user permission으로 후보를 다시 계산해야 한다. workflow owner, deployment creator, + credential principal 또는 직전 실행 사용자의 후보를 fallback/cache하면 테스트 실패다. +- LLM node의 retrieval fan-out은 resolver가 확정한 canonical 후보만 받아야 한다. 권한 없는 + direct KB와 Collection child는 vector search 전에 제외되고 provider prompt, citation, + trace와 audit에 나타나지 않아야 한다. +- candidate 0 경로의 provider 미호출, resolver 1회 호출, Collection child identity redaction은 + 기존 LLM node regression을 재사용하고, MBA-238에서는 production PostgreSQL adapter와 + 연결된 사용자별 통합 경로만 추가한다. + ## API Tests - 로그인 LLM node의 RAG 옵션 실행 요청은 Knowledge service에 `execution_subject=current_user`를 전달한다.