Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
fd068fb
WIP: strawberry core framework + generated schema skeleton (SDL parit…
claude Jul 8, 2026
4c8da8e
WIP: swap graphene->strawberry modules, view, settings, test harness …
claude Jul 8, 2026
1046dd2
WIP: fix non-django type constructors, port og_metadata_queries (19/1…
claude Jul 8, 2026
fad3ec0
WIP: port JWT auth mutations (tokenAuth/verifyToken/refreshToken) + b…
claude Jul 8, 2026
b33dfa2
WIP: prewarm extension port, requirements swap, remove dead api-key m…
claude Jul 8, 2026
9c21f53
WIP: docs + changelog fragment for strawberry migration
claude Jul 8, 2026
6beef07
WIP: wave-1 agent ports (types modules) — interrupted by session limi…
claude Jul 8, 2026
a072bed
Document intentional empty-except handlers in core (code-quality bot)
claude Jul 8, 2026
acb5f2b
WIP: wave-2 agent ports (document/annotation/corpus/social/extract qu…
claude Jul 8, 2026
61fc9d0
WIP: wave-3 partial agent ports + is_type_of fix in core/relay
claude Jul 8, 2026
415d38a
Port discover_queries + search_queries resolvers (16 discover tests g…
claude Jul 8, 2026
f496aae
Fix search resolver names + rewire test_mention_permissions to module…
claude Jul 8, 2026
5915687
Fix relay node resolution to match graphene-django default get_node s…
claude Jul 8, 2026
b634c1e
Rewire test harness: graphql_schema alias, format_validation_error im…
claude Jul 8, 2026
e26308a
Fix test harness: execute_sync variable_values kwarg, DocumentPath.in…
claude Jul 8, 2026
52e175e
Rewire test harness: document_path caching helper + user_privacy shar…
claude Jul 8, 2026
5dd866d
Add graphene-compat resolve_<field> aliases on strawberry types for d…
claude Jul 8, 2026
3c6c44c
Preserve md_description resolver + version_history attr-access in tests
claude Jul 8, 2026
12ba9e1
Lint cleanup: autoflake unused imports, isort/black, flake8 opt-out o…
claude Jul 8, 2026
ae655d7
Rewire remaining deleted-module imports: prefetch test + ratelimit di…
claude Jul 8, 2026
c406b56
Docs: strawberry migration architecture guide + updated schema-genera…
claude Jul 8, 2026
2c63e6d
Compat Client: accept graphene's context= alias for context_value=
claude Jul 8, 2026
76656f2
Address code-quality bot: remove dead core/ids.py + node_doc, documen…
claude Jul 8, 2026
6a118bc
Remove codegen scaffolding (_port_manifest.json with sandbox paths)
claude Jul 8, 2026
2a1bbf1
Fix IDOR regression: corpus(id:) uncached like graphene OpenContracts…
claude Jul 8, 2026
b8f3c63
Make CI mypy green: type-check config/graphql, re-baseline vacuously-…
claude Jul 8, 2026
310ec9d
Fix E2E createCorpus 500 (restore deleted test embedder) + green the …
claude Jul 8, 2026
99202ae
Fix IDOR: singular by-ID node queries lost permission filtering
claude Jul 8, 2026
d67e59f
Fix follow-up regressions from the strawberry migration + a pre-exist…
JSv4 Jul 9, 2026
5c445aa
Add deterministic HTS/ruling-citation enrichment for CROSS-style cust…
JSv4 Jul 10, 2026
2e54f61
Restore permission-filtered FK traversal in strawberry GraphQL layer
claude Jul 12, 2026
535c42b
Merge pull request #2152 from Open-Source-Legal/claude/pr-2139-strawb…
JSv4 Jul 12, 2026
c48d466
Merge main into strawberry migration: port CorpusGroup GraphQL surface
JSv4 Jul 14, 2026
d065636
Fix CorpusGroup.corpora shape + regenerate golden SDL, re-baseline mypy
JSv4 Jul 14, 2026
2cc543c
Fix pytest CI failure: restore per-request cache for CorpusType FK tr…
JSv4 Jul 18, 2026
af039bd
Pre-merge: port doc_parse queue routing and bulk-upload status normal…
JSv4 Jul 18, 2026
549ef09
Merge remote-tracking branch 'origin/main' into claude/graphene-straw…
JSv4 Jul 18, 2026
10f9ca6
Reformat: black line-wrap in document_queries.py
JSv4 Jul 18, 2026
8224476
Address review: collapse repetitive schema.py extra-types wiring into…
JSv4 Jul 18, 2026
d7894be
Add coverage tests for config/graphql/document_mutations.py
JSv4 Jul 19, 2026
33b93d9
Add coverage tests for corpus_queries.py and core/permissions.py
JSv4 Jul 19, 2026
7c9fba6
Apply black formatting to core/permissions.py coverage tests
JSv4 Jul 19, 2026
f2e461f
Add coverage tests for action_queries, agent_types, core/relay, core/…
JSv4 Jul 19, 2026
f79484e
Add coverage tests for config/graphql/conversation_types.py
JSv4 Jul 19, 2026
d8ef1f5
Add coverage tests for conversation_mutations.py and corpus_folder_mu…
JSv4 Jul 19, 2026
280ce9c
Add changelog fragment for the codecov patch-coverage fixes
JSv4 Jul 19, 2026
c64c6e9
Remove standalone customs ruling citation service (superseded, unregi…
JSv4 Jul 19, 2026
165600f
Add changelog fragment for the standalone customs service removal
JSv4 Jul 19, 2026
88e1ef1
Add rate limiting to tokenAuth and updateMe mutations
JSv4 Jul 19, 2026
50ab661
Fix can_publish_model_type/can_publish key mismatch in resolve_my_per…
JSv4 Jul 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -218,5 +218,7 @@ demo/import_zips/
# Local-only governance-graph demo (kept on disk, excluded from PRs)
/demo/

# Scratch data for ad-hoc local pilots/extractions (not part of any PR)
# Scratch data for ad-hoc local pilots/extractions (not part of any PR),
# incl. the CROSS-rulings bulk ingest pilot (large binaries materialized
# from an external corpus, never meant to be committed)
.pilot_data/
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ repos:
- daphne==4.2.1
- celery==5.6.3
- pgvector==0.4.2
- graphene-django==3.2.3
- strawberry-graphql==0.320.3
- django-graphql-jwt==0.4.0
- pydantic==2.13.3
- psycopg2-binary==2.9.11
Expand Down
22 changes: 12 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,17 @@ docker compose -f production.yml up

### Backend Architecture

**Stack**: Django 4.x + GraphQL (Graphene) + PostgreSQL + pgvector + Celery
**Stack**: Django 4.x + GraphQL (strawberry-graphql) + PostgreSQL + pgvector + Celery

**Key Patterns**:

1. **GraphQL Schema Organization**:
- `config/graphql/graphene_types.py` - All GraphQL type definitions
- `config/graphql/queries.py` - Query resolvers
- `config/graphql/*_mutations.py` - Mutation files (organized by feature)
- `config/graphql/schema.py` - Schema composition
1. **GraphQL Schema Organization** (strawberry; migrated from graphene — query shapes pinned):
- `config/graphql/core/` - shared runtime: relay global IDs/`Node`, connection factories + graphene-parity pagination, FilterSet arg mapping, scalars, permission resolvers, DRF mutation bases, auth decorators
- `config/graphql/*_types.py` - GraphQL type definitions (organized by feature)
- `config/graphql/*_queries.py` / `*_mutations.py` - Query/Mutation fields + resolvers (each module exports `QUERY_FIELDS` / `MUTATION_FIELDS`)
- `config/graphql/schema.py` - Schema composition + validation rules
- `config/graphql/schema.graphql` - **golden SDL contract**; `opencontractserver/tests/test_schema_parity.py` fails on ANY shape drift. Regenerate it deliberately when changing the API surface (command in that test's docstring).
- `config/graphql/views.py` - HTTP view; per-request auth (JWT/Auth0/API-key) happens in `get_context` via `AUTHENTICATION_BACKENDS` (no per-resolver auth middleware)

2. **Permission System** (CRITICAL - see `docs/permissioning/consolidated_permissioning_guide.md`):
- **Annotations & Relationships**: NO individual permissions - inherited from document + corpus
Expand All @@ -120,11 +122,11 @@ docker compose -f production.yml up
- For corpus-scoped document access (the most common pattern), prefer `CorpusDocumentService.get_corpus_documents(user, corpus)` over composing `visible_to_user` filters by hand — the service is the canonical entry point and prevents IDOR-prone copy-paste fusions of `corpus.get_documents()` + `Document.objects.visible_to_user(user)`.
- **Corpus document access — two deliberate semantics (issue #1682)**: `CorpusDocumentService.get_corpus_documents(user, corpus)` is **corpus-as-gate** — corpus READ unlocks *every* document with an active path in that corpus. It is the documented default for pipeline-facing callers (MCP, badge/analysis tasks) that legitimately operate over a whole readable corpus and never return or persist verbatim document content to the caller. `CorpusDocumentService.get_corpus_documents_visible_to_user(user, corpus)` enforces **`MIN(document_permission, corpus_permission)`** — a private document inside a public (or merely shared) corpus stays hidden from users who lack document-level READ. User-facing surfaces that must not leak private documents (e.g. the GraphQL `CorpusType.documents` resolver) MUST use the `_visible_to_user` variant. **Authority enrichment is `_visible_to_user`, not corpus-as-gate** (PR #2084): `EnrichmentService.discover`/`scan`/`apply` (`opencontractserver/enrichment/services/enrichment_service.py::_load`) return verbatim excerpts and persist `Annotation`/`CorpusReference` rows derived from document text, so they load through the MIN variant and surface a `documents_excluded_by_visibility` count (+ a WARNING) when the caller's `creator_id` lacks per-document READ on some documents. Choose the method by caller intent; never silently swap one semantic for the other.

3. **AnnotatePermissionsForReadMixin**:
- Most GraphQL types inherit this mixin (see `config/graphql/permissioning/permission_annotator/mixins.py`)
- Adds `my_permissions`, `is_published`, `object_shared_with` fields
3. **Permission-annotation fields** (`myPermissions`, `isPublished`, `objectSharedWith`):
- Resolved by `config/graphql/core/permissions.py` (port of the graphene-era `AnnotatePermissionsForReadMixin`); most GraphQL types expose them
- Requires model to have guardian permission tables (`{model}userobjectpermission_set`)
- Notifications use simple ownership model and DON'T use this mixin
- Per-request model-permission maps are memoised on `info.context.permission_annotations`
- Notifications use simple ownership model and DON'T expose these fields

4. **Django Signal Handlers**:
- Automatic notification creation on model changes (see `opencontractserver/notifications/signals.py`)
Expand Down
2 changes: 2 additions & 0 deletions changelog.d/2139-codecov-patch-coverage.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- Closed the codecov patch-coverage gate on PR #2139 (81.70% vs. an auto target of 89.40% backend / 83.19% overall) by adding real tests for previously-untested strawberry-ported GraphQL resolver logic across 10 files: `config/graphql/action_queries.py`, `agent_types.py`, `conversation_types.py`, `conversation_mutations.py`, `corpus_queries.py`, `corpus_folder_mutations.py`, `document_mutations.py`, `core/relay.py`, `core/permissions.py`, `core/scalars.py`. New test files (`opencontractserver/tests/test_coverage_*.py`) cover connection-field filter args, FK-visibility wiring, mention resolution, mutation auth/visibility/error branches, and the custom scalar parse/coerce paths — verified line-by-line against a full-suite coverage baseline to confirm real closure (not synthetic tests written just to move a percentage). 600 of the 616 originally-uncovered lines across these files are now covered; the remaining 16 (in `conversation_mutations.py`) are proven-unreachable exception handlers under the current `graphql-relay` pin (`unbase64()` swallows malformed-base64 exceptions internally instead of raising, and `get_for_user_or_none()` already swallows `DoesNotExist`), confirmed byte-identical to the pre-migration graphene code — a pre-existing dead-code gap, not a regression, left for a follow-up cleanup decision rather than faked coverage.
- Found (not fixed, correctly out of scope for a parity-preserving migration): `config/graphql/core/permissions.py::resolve_my_permissions` reads `model_permissions.get("can_publish_model_type", ...)` but the helper that builds that dict returns the flag under `"can_publish"` — a key-name mismatch that makes the `publish_corpus` permission-flag branch permanently dead. Confirmed pre-existing on `main`'s graphene code (predates this PR) via `git show 226a14ebd`.
28 changes: 28 additions & 0 deletions changelog.d/2139-corpus-fk-cache.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
- `config/graphql/core/relay.py` / `config/graphql/corpus_types.py`: fixed a
CI-caught regression where `annotation.corpus` (and every other FK/relay-FK
field pointing at `CorpusType` — `corpus.parent`, `corpus.memoryDocument`,
`Analysis.analyzedCorpus`, etc.) lost its per-request cache, so each access
re-fired the recursive `WITH __rank_table` CTE that `Corpus`'s
`with_tree_fields=True` `TreeNode` registration emits
(`opencontractserver/tests/test_doc_annotations_prefetch_n_plus_one.py::test_corpus_tree_cte_does_not_scale_with_document_count`
failed 8 CTEs for 4 docs vs. the ≤4 ceiling for 1 doc).
- Root cause: `resolve_visible_fk` (FK traversal) and `get_node_from_global_id`
(the singular `corpus(id:)` / relay `node(id:)` lookup) both read the same
`TypeRegistryEntry.get_node` slot. `CorpusType` deliberately registers no
`get_node` there, because caching it would leak a stale `Corpus` object
across `corpus(id:)` calls that reuse one request context while
permissions change mid-test (`opencontractserver/tests/permissioning/test_permissioning.py`
drives several `corpus(id:)` queries through one shared `graphene_client`
while provisioning/deprovisioning perms between them). That correctly
fixed the top-level query, but silently starved the unrelated FK-traversal
call site of caching too.
- Fix: added a second, independent hook — `TypeRegistryEntry.get_node_for_fk`
/ `register_type(..., get_node_for_fk=...)` — consulted only by
`resolve_visible_fk`, never by `get_node_from_global_id`. `CorpusType` now
registers its existing cached `_get_node_CorpusType` there, restoring the
per-request `_corpus_node_cache` for FK access while leaving the top-level
`corpus(id:)` query exactly as uncached as before. No schema/SDL change
(resolver-internal); verified against the full guard suite
(`test_doc_annotations_prefetch_n_plus_one`, `test_mentions`,
`test_singular_node_idor`, `test_fk_visibility_traversal`,
`test_schema_parity`, `permissioning/test_permissioning`) — 51 passed.
1 change: 1 addition & 0 deletions changelog.d/2139-fix-can-publish-key-mismatch.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `config/graphql/core/permissions.py::resolve_my_permissions`: fixed a key-name mismatch that permanently dead-ended the `publish_{model_name}` permission grant — the resolver read `model_permissions.get("can_publish_model_type", False)`, but `get_permissions_for_user_on_model_in_app` (`config/graphql/permissioning/permission_annotator/middleware.py`) has only ever returned the flag under `"can_publish"`. Predates the strawberry migration (same typo in the graphene-era `AnnotatePermissionsForReadMixin`, `config/graphql/permissioning/permission_annotator/mixins.py`) — found via a codecov patch-coverage pass on PR #2139 and fixed here since that's the first place the branch has real test coverage (`opencontractserver/tests/test_coverage_core_permissions.py::ResolveMyPermissionsCoverageTestCase::test_real_global_publish_permission_sets_publish_permission`, a real global `publish_corpus` Django permission grant, not a mock).
31 changes: 31 additions & 0 deletions changelog.d/2139-fk-traversal-idor.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
- Closed a systematic cross-boundary information-disclosure regression in the
graphene→strawberry migration: **singular to-one FK object fields lost their
per-row visibility filter.** graphene-django auto-converted a FK whose target
type overrode `get_queryset` into a permission-filtered resolver
(`convert_field_to_djangomodel` → `target.get_node`), so an invisible FK
target resolved to `null`. The strawberry port declared these as plain
getattr fields, leaking the target row's fields across a permission boundary.
Added `config/graphql/core/relay.py::resolve_visible_fk` (applies the target
type's registered `get_node`/`get_queryset` hook) and routed the affected
**nullable** FK fields through it, restoring the graphene contract. Fields
fixed include the confirmed-exploitable cross-boundary edges —
`AnnotationType.corpus`, `RelationshipType.corpus`/`document`,
`CorpusReferenceType.targetDocument`/`targetCorpus`/`targetAnnotation`,
`ConversationType.chatWithCorpus`/`chatWithDocument`,
`MessageType.sourceDocument`, `DocumentType.parent`/`sourceDocument`,
`CorpusType.parent`/`memoryDocument` — plus the lower-severity theoretical
edges on `agent_types`/`extract_types`/`social_types`/`user_types`/
`research_types`/`document_types` for consistency.
- `config/graphql/document_types.py::_get_queryset_DocumentPathType`: the
**non-null** `DocumentPathType.document` FK cannot resolve to `null`, so its
leak (DocumentPath membership is corpus-as-gate — a public/shared corpus
lists paths for its *private* documents too) is closed at the list level by
adding a `document_id__in=<visible documents>` MIN(document, corpus) filter,
the same semantic `CorpusType.documents` uses (issue #1682).
- FK edges whose source-row visibility already implies READ on the target
(e.g. `NoteType.corpus`/`document`, `DocumentRelationshipType.*`, corpus-gated
`*.corpus`, same-parent `parent`) are left as fast plain fields — filtering
them is a no-op that only adds per-row queries.
- Regression coverage: `opencontractserver/tests/test_fk_visibility_traversal.py`
pins both branches of `resolve_visible_fk`, the DocumentPath MIN filter, and an
end-to-end schema query (`CorpusType.parent` → `null` for a non-owner).
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Removed the standalone, unregistered `opencontractserver/enrichment/services/customs_ruling_citation_service.py` (added by commit `5c445aa27` on this branch, never wired into `opencontractserver/enrichment/services/__init__.py`'s `__all__`/auto-discovery), its `opencontractserver/corpuses/management/commands/enrich_customs_rulings.py` management command, and their test file (`opencontractserver/tests/test_customs_ruling_citation_service.py`). Superseded by the grammar-tier customs/trade detection already shipped on `main` (`opencontractserver/enrichment/grammars.py`, `opencontractserver/enrichment/data/authority_mappings.yaml`'s `htsus` prefix), which runs through the standard `EnrichmentService.apply` path and every existing trigger instead of a bespoke service. Keeping both would have shipped two competing, unregistered implementations of the same feature.
34 changes: 34 additions & 0 deletions changelog.d/2139-review-followups.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
- Review follow-ups to the graphene→strawberry migration:
- `config/graphql/core/mutations.py`: `drf_mutation`/`drf_deletion` now pass
`group="mutate"` to `graphql_ratelimit`. The decorator was applied to a
`lambda`, so it derived the rate-limit cache group from `func.__name__`
(`"<lambda>"`), splitting the ~9 DRF-routed mutations (`updateAnnotation`,
`deleteNote`, `createCorpus`, `updateCorpus`, `deleteCorpusAction`,
`updateDocument`, `deleteDocument`, `deleteExport`, `deleteExtract`) into a
separate write bucket and roughly doubling a user's combined write budget.
They now share the single `"mutate"` fixed-window counter, matching the
graphene baseline and every hand-ported mutation.
- `config/graphql/core/relay.py::get_node_from_global_id`: the `entry.get_node`
hook path is now wrapped in the same `(ValueError, TypeError, OverflowError)`
guard the default queryset path already had, so a malformed pk reaching an
`int(pk)` hook (e.g. `researchReport(id: base64("ResearchReportType:xyz"))`)
returns the unified IDOR-safe not-found instead of surfacing a raw
`ValueError`.
- `opencontractserver/tests/test_security_hardening.py`: added
`TestServedSchemaExecutesValidationRules`, which drives a too-deep query and
an introspection query through `schema.execute_sync` (the real
`AddValidationRules` extension path). The pre-existing depth/introspection
tests only call graphql-core's `validate(..., validation_rules)` against the
exported list, so they would keep passing even if `AddValidationRules` were
dropped from the schema's `extensions` (depth-limiting / prod
introspection-blocking silently disabled on the endpoint). This closes that
coverage gap.
- `config/settings/base.py`: removed the dead `ALLOW_GRAPHQL_DEBUG` setting —
its only consumer, `graphene_django.debug.DjangoDebugMiddleware`, was deleted
with the graphene stack.
- Corrected the stale relay/`MessageType` note in
`config/graphql/core/relay.py` and `docs/architecture/graphql_strawberry_migration.md`,
which described singular node resolution as unfiltered-by-pk for
`MessageType`/`chatMessage`; the migration's own IDOR fix registered a
permission-aware `get_node` hook, and the doc now documents the
`resolve_visible_fk` FK-traversal path as well.
7 changes: 7 additions & 0 deletions changelog.d/2139-schema-extra-types-dry.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
- `config/graphql/schema.py`: collapsed the ~44 hand-copied
`_extra_types += [v for v in vars(_module).values() if hasattr(v,
"__strawberry_definition__")]` blocks (one per ported module, ~190 lines) into
a single loop over an explicit `_extra_type_modules` list, per review
feedback on PR #2139. Behavior-identical (same modules, same iteration order,
same filter) — confirmed by `test_schema_parity.py`'s golden-SDL diff, which
still passes with zero drift.
16 changes: 16 additions & 0 deletions changelog.d/2139-strawberry-migration-ci.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
- Follow-up fixes to the graphene→strawberry migration surfaced by CI:
- Restored `opencontractserver/pipeline/embedders/test_embedder.py`, which was
deleted as collateral in an earlier migration commit while
`config/settings/test.py` still names it as the default embedder. The pytest
suite masked the breakage (a session-autouse fixture disconnects the
document post-save signals), but the live `frontend-e2e` `runserver` fires
`calculate_embedding_for_doc_text` eagerly on the structural `Readme.CAML`
document that corpus creation seeds, so `createCorpus` returned HTTP 500 and
the corpus/routing/threads E2E specs failed.
- `.pre-commit-config.yaml`: the `mypy` hook's `additional_dependencies` now
ship `strawberry-graphql==0.320.3` (replacing the removed
`graphene-django==3.2.3`) so the hook's isolated env can import the
`strawberry.ext.mypy_plugin` referenced by `mypy.ini`.
- Applied the repo's standard `pyupgrade`/`black`/`isort` formatting to the
generated strawberry schema modules and the migration-touched test files so
the `pre-commit` linter job passes; schema-shape parity is unchanged.
Loading