- **Relationship privacy recursion implemented (closes the Phase-C deferral from issue #1655).** `RelationshipManager.user_can` and `RelationshipManager.visible_to_user` (`opencontractserver/shared/Managers.py`) now enforce `created_by_analysis` / `created_by_extract` source permissions exactly like annotations: non-creators need the requested permission on the source Analysis/Extract in addition to the doc+corpus MIN. Previously a user with doc+corpus grants could see and write analysis-/extract-private relationships through the manager surfaces. The recursion logic is shared with `AnnotationManager` via the new module-level `_source_privacy_recursion_passes`. Parity pinned by new fixtures/tests in `opencontractserver/tests/permissioning/test_authorization_invariants.py` (`RelationshipAuthorizationInvariantsTestCase` — the old `test_no_privacy_widening_via_created_by_analysis` sentinel that documented the deferral is replaced by real privacy invariants) and `opencontractserver/tests/permissioning/test_relationship_privacy_scoping.py`.
- **Extracts locked down to never be anonymous-visible.** `ExtractService.get_visible_extracts` / `check_extract_permission` (`opencontractserver/extracts/services/extract_service.py`) previously exposed `is_public` extracts in public corpora to anonymous callers (drift copied from the analysis service, reachable through the un-gated `extracts` GraphQL resolver), contradicting the documented "Extract — never" anonymous contract. A new `ExtractManager` (`opencontractserver/extracts/models.py`) denies anonymous users on both `visible_to_user` and `user_can` so the rule holds at the manager layer too (e.g. agent tools listing extracts). User-visible consequence: anonymous viewers of a public corpus now see `totalExtracts: 0` in `corpusStats` (the resolver's counts are per-viewer by contract). Two legacy tests that pinned the old semantic were updated to the agreed behavior: `test_query_optimizer_methods.py::test_get_visible_extracts_anonymous_only_public` (now `..._anonymous_sees_nothing`) and `test_stats.py::test_public_corpus_stats_query` (anonymous branch). New coverage: `opencontractserver/tests/permissioning/test_extract_anonymous_lockdown.py`.
- **Group-granted analysis/extract permissions now unlock private rows in list queries.** The `created_by_*` privacy gates in `AnnotationQuerySet.visible_to_user` (`opencontractserver/shared/QuerySets.py`), `AnnotationService.get_document_annotations` / `get_corpus_annotations`, and `RelationshipService.get_document_relationships` consulted only the USER object-permission tables, so a viewer whose source grant arrived via a Django group passed `user_can` (which resolves group grants by default) but never saw the rows in lists — a filter/check parity drift. All gates now route through the new shared builders in `opencontractserver/utils/source_visibility.py`, which join the group object-permission tables; the exclusion shape itself is also consolidated there as `apply_source_privacy_gate(qs, user)` (one home instead of six call-site copies), and the gate carries the same authenticated creator exemption as the queryset disjunct — without it the service listings were the odd surface out for relationships (both manager surfaces honoured a creator who lost source access while the document view hid the row). Two more drifts in the same parity class were closed in the builders: (a) `Extract.is_public` — the inline gates omitted the flag for extracts (while honouring it for analyses) even though `user_can`'s recursion grants READ on public sources; `visible_extracts_for` now includes `is_public` on the authenticated branch (no practical exposure today: no user-facing flow sets the flag); (b) grant shape — the inline gates matched ANY guardian row on the source, so an `update_analysis`-only grantee saw private rows in lists while failing `user_can(READ)` (which resolves the read codename); the builders now filter `permission__codename="read_analysis"` / `"read_extract"`. Tests: `test_source_visibility_group_grants.py` + new invariant fixtures incl. `test_public_extract_source_passes_both_surfaces` and `test_update_only_source_grant_does_not_unlock_lists`.
- **Anonymous privacy leak in corpus-wide annotation listing fixed.** `AnnotationService.get_corpus_annotations` applied the `created_by_*` privacy exclusion only to authenticated users — anonymous viewers of a public corpus could see analysis-/extract-private annotations on public documents. The exclusion now applies unconditionally (anonymous resolves to public-analyses-only / no extracts via `source_visibility`). Regression pins: `opencontractserver/tests/permissioning/test_privacy_gate_regressions.py`.
- **Privacy gate added to three aggregate/listing surfaces that skipped it** (2026-06 audit review rounds): `AnnotationService.get_label_distribution_for_corpus` (powers the user-facing `corpusIntelligenceAggregates` label panel — leaked label names/counts of private-source annotations; now takes a REQUIRED keyword-only `user` (omission is a `TypeError`, never a silent under-count)), `RelationshipService.get_relationship_summary` (leaked counts of private relationships into the document-type summary), and `RelationshipService.get_corpus_relationships` (the MCP corpus listing exposed private relationships; its corpus-as-gate document scope is retained deliberately per issue #1682 — MCP is a pipeline-facing caller — and now documented inline). Tests: `test_privacy_gate_regressions.py`.
- **`ExtractService.get_visible_extracts` filter/check parity with `check_extract_permission`** (pre-existing drift tightened while hardening the file): the listing consulted only USER-level guardian rows (any codename) on the extract leg and a `codename__contains="read"` substring match on the corpus leg, while the single-object check resolves both through `user_can` (group grants + exact read codename). The listing now joins `ExtractGroupObjectPermission` / `CorpusGroupObjectPermission` and matches `read_extract` / `read_corpus` exactly; the sibling `AnalysisService.get_visible_analyses` carried the identical drift (user-table-only Exists + `codename__contains="read"` corpus leg) and received the same fix. Tests: `ExtractListingGrantParityTestCase` in `test_extract_anonymous_lockdown.py` and `test_analysis_listing_grant_parity.py`.
