- **Multi-corpus retrieval: `CorpusGroup` + `search_across_corpora` (issue #2056**, = proposal 0002 Phase 4 / #1444 Phase B**)**. A single orchestrator agent can now fan a semantic query out across N corpora and synthesize a unified answer with per-corpus citations.
  - `CorpusGroup` model (`opencontractserver/corpuses/models.py`): a named bundle of corpora (`corpora` M2M, unique auto-generated `slug`, optional `default_agent` FK binding an orchestrator `AgentConfiguration`), with full django-guardian object-level permissions (`CorpusGroupUserObjectPermission` / `CorpusGroupGroupObjectPermission`) and the standard `BaseOCModel` visibility surface. Registered in Django admin.
  - `CorpusGroupService` (`opencontractserver/corpuses/services/corpus_groups.py`): canonical service-layer entry point — IDOR-safe lookup by pk or slug, group CRUD, and `get_group_corpora_visible_to_user`, which resolves the group's `corpora` M2M at **call time** (never a config-time snapshot) intersected with `Corpus.objects.visible_to_user`, so membership changes apply on the next query and a private corpus inside a shared group is never searched for a user who cannot READ it.
  - `asearch_across_corpora` agent tool (`opencontractserver/llms/tools/core_tools/multi_corpus.py`, registry name `search_across_corpora`): searches each visible member corpus with its own `CoreAnnotationVectorStore` / `preferred_embedder`, returns hits **grouped per corpus** (scores from different embedding spaces are never merged) with per-hit `annotation_id` / `document_id` / `page` metadata for citations. Per-corpus failures are isolated (issue #820 contract); per-call corpus count, per-corpus `k`, and per-hit snippet length are bounded via `opencontractserver/constants/tools.py`.
  - Orchestrator wiring over the existing `ws/agent-chat/` transport (no new transport): `UnifiedAgentConsumer._initialize_agent` now threads an **explicitly-selected** agent's (`?agent_id=...`) `system_instructions` and `available_tools` into the agent factory (slug-resolved default agents intentionally keep the factory's context-derived prompt), and `build_delegation_tool` threads `available_tools` into @mention sub-agents — closing the gap where `AgentConfiguration.available_tools` only took effect for Celery corpus actions.
  - GraphQL surface: `corpusGroups` / `corpusGroup` queries and `createCorpusGroup` / `updateCorpusGroup` / `deleteCorpusGroup` mutations (`config/graphql/corpus_group_mutations.py`), all routed through the service layer; `CorpusGroupType.corpora` lists only viewer-visible members. Malformed global ids return the uniform "Malformed id argument" / not-found envelopes — `_decode_pks` treats an empty decoded pk as malformed, since `graphql_relay.from_global_id` never raises on garbage input (it partition-decodes to an empty pk, which previously leaked a raw Django field error through the create mutation). Review hardening: `CorpusGroupType.default_agent` is gated per-viewer (`resolve_default_agent` via `AgentConfigurationService.get_agent_by_id`) so a private/corpus-scoped agent never leaks its `system_instructions` through a shared group; non-numeric decoded pks fall through to the uniform "Corpora not found" failure instead of a raw int-coercion error; explicit-slug collisions return a friendly "already exists" message via a savepoint-scoped IntegrityError catch; the update mutation now distinguishes a malformed TARGET id (IDOR-uniform not-found) from malformed ARGUMENT ids ("Malformed id argument", matching create).
  - Tests: `opencontractserver/tests/test_corpus_groups.py` (service visibility, call-time membership, tool IDOR + fault isolation + clamps, GraphQL permission boundaries), `CorpusGroupAuthorizationInvariantsTestCase` in `test_authorization_invariants.py`, plus consumer/delegation threading tests.
