Canonical-CAML corpus description: one source, derived projections#1825
Conversation
Adds opencontractserver/corpuses/services/description_cache.py with three pure-function helpers (markdown_to_plain_text, summarize_for_preview, compute_cache_from_caml_body). Single derivation point for the auto-maintained Corpus.description / Corpus.description_preview cache that the canonical-CAML refactor introduces (spec at docs/superpowers/specs/2026-05-27-canonical-caml-description-refactor-design.md). Also adds MAX_CORPUS_DESCRIPTION_PREVIEW_LENGTH = 280 to opencontractserver/constants/truncation.py — the canonical cap for the preview projection consumed by summarize_for_preview. The module also defines backfill_caml_doc_for_corpus, the idempotent per-corpus helper the upcoming data migration and V2 import shim will both call into. It references Corpus.readme_caml_document_id, which arrives in the next task; the function body is only evaluated at call time, so importing the module today remains safe. No behavior change yet — Corpus model still uses its own private copies. The signal handler, V2 import shim, and data migration in subsequent commits will all call into this module.
The provisional ``backfill_caml_doc_for_corpus`` helper references a Document<->Corpus relationship and ``Corpus.readme_caml_document_id`` that are introduced in later tasks of the canonical-CAML refactor. Scope ``# type: ignore[misc]`` to the exact ``corpus=`` kwargs that trip the django-stubs check today, with a NOTE explaining the expected resolution path. ``QuerySet.update`` accepts ``**kwargs: Any`` so the ``readme_caml_document_id=`` update line does not need an ignore. No runtime change — the function body is still only evaluated at call time, which the task gating in the refactor plan defers until Task 7.
Adds a nullable, denormalized FK from Corpus to the Readme.CAML Document. Subsequent commits will (a) populate it via signal handler, (b) use it in the mdDescription GraphQL resolver to avoid per-row file reads, (c) backfill it for existing corpuses in migration 0052. No behavior change yet — the FK is null for all corpuses until the signal handler ships.
Task 1 shipped a backfill_caml_doc_for_corpus helper that queried Document.objects.filter(corpus=...) — wrong because Document has no corpus FK (Phase-2 corpus-isolation puts the relation on DocumentPath). Rewrites the helper to use CorpusDocumentService.get_corpus_caml_articles for lookup and import_document for creation (the canonical dual-tree versioning workhorse — handles Document + DocumentPath + version_tree_id atomically). Removes the type: ignore[misc] hacks added in commit 4a702ae98. Adds three runtime tests covering create / idempotent / no-op-on-empty. Reconciles Task 1's deferred concerns with the post-Task-1 spec revision in docs/superpowers/specs/2026-05-27-canonical-caml-description-refactor-design.md section 4.1.
Adds receivers on Document.post_save/post_delete and DocumentPath .post_save/post_delete that keep Corpus.description, Corpus .description_preview, and Corpus.readme_caml_document_id in sync with the canonical Readme.CAML body. Writes via QuerySet.update so the cache refresh does NOT re-emit Corpus post_save (which would loop / over-notify GraphQL subscribers). Defers the work via transaction.on_commit so the CAML write that triggered us is durable before we read the file back. Per spec section 4.4, each handler iterates the corpus IDs owning the doc (via DocumentPath join) — handles version-up, soft-delete, and the hypothetical multi-corpus case from the risk table. Cache is one-way derived: writes directly to Corpus.description / .description_preview are silently overwritten on next CAML save — codified in the test suite as the read-only-cache invariant. Spec: docs/superpowers/specs/2026-05-27-canonical-caml-description-refactor-design.md section 4.4
Centralizes select_related on the new Corpus.readme_caml_document FK so every GraphQL resolver path that selects mdDescription gets the JOIN automatically instead of issuing one Document fetch per corpus. Tested via CaptureQueriesContext: a 10-corpus list selecting readme_caml_document resolves in <=3 queries (down from 1 + N). Spec: docs/superpowers/specs/2026-05-27-canonical-caml-description-refactor-design.md §4.5
mdDescription keeps its observable contract (a file URL on CorpusType) but now reads from corpus.readme_caml_document.txt_extract_file instead of the legacy md_description FileField (which is going away in Task 15). select_related on the FK is applied at both queryset entry points (resolve_corpuses + CorpusType.get_queryset) via the new CorpusDocumentService.with_readme_caml_doc helper so list queries remain O(1) per row. Behavior change: returns None instead of '' when no CAML doc exists. The frontend treats both as falsy. Spec: docs/superpowers/specs/2026-05-27-canonical-caml-description-refactor-design.md §4.5
Optional rich-object access to the canonical Readme.CAML Document on CorpusType. Resolves from corpus.readme_caml_document (the cached FK populated by the Document post_save signal). Lets new clients fetch the Document with all its standard fields (revision history, permissions, txt_extract_file URL) in one query without juggling the mdDescription URL surface separately. No breaking change — existing clients keep using mdDescription (URL) and descriptionPreview (text). Spec: docs/superpowers/specs/2026-05-27-canonical-caml-description-refactor-design.md §4.5
Forward-only data migration. For every Corpus with non-empty md_description: creates a Readme.CAML Document + DocumentPath linking the corpus to it, replays every CorpusDescriptionRevision snapshot as a Document version-tree sibling (each with its own non-current DocumentPath preserving the revision's version number and created timestamp), populates readme_caml_document_id on the Corpus head, and refreshes the cache columns via the canonical helper. Idempotent: re-runs are no-ops on already-migrated rows (lookup-before-create on DocumentPath). Document<->Corpus is mediated by DocumentPath (no direct FK) per Phase-2 corpus-isolation (issue #1464), so the migration creates both rows atomically using the historical model registry. Schema removal (md_description column drop, CorpusDescriptionRevision table drop) is deferred to migration 0053. Spec: docs/superpowers/specs/2026-05-27-canonical-caml-description-refactor-design.md §4.9
…ment CorpusService.update_description now creates (or extends the version-tree of) the corpus's Readme.CAML Document via opencontractserver.documents.versioning.import_document instead of the legacy Corpus.update_description model method that wrote to the md_description FileField + appended a CorpusDescriptionRevision row. The Document post_save signal cascades the cache refresh onto Corpus.description / .description_preview / .readme_caml_document_id, so the editor write path is now uniform with every other CAML write (V2 import shim, migration backfill, future agent edit tool). GraphQL mutation surface is unchanged — frontend editor code does not move. The UpdateCorpusDescription resolver now derives the version number from the new Readme.CAML head via calculate_content_version (matching the 1-indexed semantics the legacy CorpusDescriptionRevision counter exposed). Permission gating (creator-only) is preserved exactly. The legacy Corpus.update_description model method and the _read_md_description_content helper are NOT deleted in this commit because Task 10 (rewiring opencontractserver/llms/tools/core_tools/ descriptions.py off them) has not yet shipped. Task 15 drops both along with the md_description column. Spec: docs/superpowers/specs/2026-05-27-canonical-caml-description-refactor-design.md §4.6
aapply_caml_article_edit (the approval-gated agent tool that does targeted text replacement on the corpus's Readme.CAML article) now routes its write through opencontractserver.documents.versioning .import_document, which atomically: 1. Creates a new Document sharing the existing version_tree_id 2. Flips the old DocumentPath.is_current to False 3. Creates a new DocumentPath with is_current=True and bumped version This converges all CAML writes (editor full-content via CorpusService.update_description from Task 8, and now agent targeted replacement) on one mechanism. Frontend deep-links to the corpus's Readme.CAML resolve to the head of the version_tree via corpus.readme_caml_document (the cached FK from Task 2) or DocumentPath with is_current=True -- no longer to a specific Document pk. The orphan-blob cleanup logic is gone: the old Document keeps its blob as a historical version-tree sibling. select_for_update on the current head still serializes concurrent edits. Spec: docs/superpowers/specs/2026-05-27-canonical-caml-description-refactor-design.md sections 4.1.1 and 4.7
…emantic The 3 pre-existing tests in test_caml_review_tools.py that asserted in-place-edit semantics are updated to match the canonical-CAML contract — spec §4.1.1: each CAML edit creates a new Document at the head of the version_tree, leaving the previous Document as a historical sibling. Changes: - _read_caml_body now re-resolves the current head via DocumentPath on every call (self.caml_doc becomes a historical sibling once an edit lands). - test_replaces_single_occurrence asserts result["document_id"] is the NEW head's pk, not the old caml_doc.id. - test_old_blob_is_deleted_after_edit replaced with test_edit_creates_new_version_tree_sibling — same orchestration but the new assertions confirm a sibling exists, the old blob persists (not orphaned), and Document.objects.filter(version_tree_id=...) contains 2 rows. - _create_caml_doc fixture now passes path="Readme.CAML" to corpus.add_document so the existing DocumentPath matches the canonical path used by import_document — without this, the agent edit path doesn't find the existing path and creates a fresh version_tree. Documents the intentional behavior change per CLAUDE.md "Critical Concepts" §5 (test logic correct, expectations updated for intentional behavior change).
The descriptionRevisions field on CorpusType now resolves to the corpus's Readme.CAML version_tree siblings (Document rows sharing one version_tree_id) instead of CorpusDescriptionRevision rows. The resolver shape is preserved -- id, version, author, snapshot, created -- so the frontend revision-history UI renders without changes. CorpusDescriptionRevisionType becomes a thin graphene.ObjectType facade mapping each Document sibling's metadata onto the historical fields. The diff field is dropped; clients needing diffs can compute them on the fly via difflib from successive snapshots. The _read_caml_body helper from corpuses/signals.py is promoted to a public read_caml_body in corpuses/services/description_cache.py so the GraphQL facade and the signal-driven cache refresh share one I/O contract (text mode then binary-with-utf8-fallback). signals.py keeps the private alias to avoid touching any third-party callers. The CorpusDescriptionRevision model is no longer queried by this resolver; the model class + table drop is scheduled for migration 0053 (Task 15 of the canonical-CAML refactor). Spec: docs/superpowers/specs/2026-05-27-canonical-caml-description-refactor-design.md §4.5
The agent-facing corpus-description tools no longer touch the legacy Corpus.md_description FileField: - get_corpus_description / aget_corpus_description read via CorpusDocumentService.get_corpus_caml_articles + read_caml_body. ``aget_corpus_description`` now delegates to its sync sibling through ``_db_sync_to_async`` because the CAML lookup uses ``user_can`` which is sync-only — wrapping is simpler than threading an async equivalent. - update_corpus_description / aupdate_corpus_description write via CorpusService.update_description (which routes through import_document — Task 8). The ndiff patch path now derives 'current' from the canonical CAML body, applies the ndiff, and writes the result through the canonical write path. The function unwraps the ServiceResult so the historical contract (raise on failure, return Document | None on success) is preserved. Document-level helpers (Document.description) are unaffected. The legacy Corpus.update_description model method and _read_md_description_content helper are no longer called by production code after this commit — Task 15 will drop them with the md_description column. Test note: ``test_llm_corpus_patch_tools`` now reads via ``CorpusDocumentService.get_corpus_caml_articles`` + ``read_caml_body`` and adds trailing newlines to the bodies so ndiff produces a well-formed diff. The original fixture happened to work pre-refactor only because the legacy ``_read_md_description_content`` was already returning an empty string after Task 8 (the diff was an all-add against ``''``); reading the canonical CAML body exposes the ndiff edge case that ``difflib.restore`` cannot recover from when the last line has no trailing newline. Spec: docs/superpowers/specs/2026-05-27-canonical-caml-description-refactor-design.md §4.7
After Task 10 rewired the corpus description agent tool to return a
Document (the new head) instead of a CorpusDescriptionRevision, the
update_corpus_description_tool wrapper at pydantic_ai_agents.py:3127
hit AttributeError because Document has no .version attribute.
Switch to calculate_content_version(doc) — the same Rule-C2 ancestor
count that corpus_mutations.UpdateCorpusDescription uses (Task 8) —
wrapped in sync_to_async since the helper walks the parent chain
synchronously. Preserves the wrapper's existing {"version": int|None}
return contract.
Also addresses the AsyncTestUpdateCorpusDescription failures noted in
Task 10's report: those tests assert on rev.version where rev is the
agent tool's return value; updating the tool's documented surface
(version derived from content-tree depth) keeps both production and
tests on the canonical mechanism.
After Task 10 the agent tool returns the new head Document (or None) instead of the legacy CorpusDescriptionRevision. The four AsyncTestUpdateCorpusDescription tests are updated to assert on the new contract: - version is derived via calculate_content_version(doc) — same mechanism corpus_mutations + pydantic_ai_agents use. - author becomes doc.creator_id. - snapshot is the txt_extract_file body (asserted indirectly via aget_corpus_description). The previous test_aupdate_snapshot_interval pinned a 10th-revision "snapshot interval" specific to the legacy CorpusDescriptionRevision.snapshot field. The canonical CAML version-tree keeps a Document per edit with no equivalent snapshot logic, so the test is replaced by test_aupdate_creates_version_tree_chain asserting the surviving invariants (chain depth + head body). Intent of the original tests preserved per CLAUDE.md "Critical Concepts" §5 — behavior change is intentional and documented in the canonical-CAML spec.
The landing markdown section added by PR #1805 was a workaround for the dual-storage problem (md_description not rendered when no Readme.CAML existed). After the canonical-CAML refactor every corpus's rich content is the Readme.CAML doc, rendered via the existing CorpusArticleView short-circuit. The landing hero still shows the short descriptionPreview subtitle; the long-form is one navigation hop away in the article view. useCorpusMdDescription is retained for CorpusDetailsView (the About card). SafeMarkdown is retained wherever it's still used. Spec: docs/superpowers/specs/2026-05-27-canonical-caml-description-refactor-design.md §4.6
V3 drops md_description and md_description_revisions from the top-level schema. The Readme.CAML Document rides in annotated_docs exactly like every other Document, so revisions appear as version-tree siblings without extra plumbing. - export_tasks_v2.py emits version=3.0 and the trimmed payload. - export_v2.py drops the now-unused package_md_description_revisions helper (its only caller was the export emitter) and the unit test of that helper (legacy dead code). - import_tasks_v2.py teaches its V2 import handler to accept V3 archives as well: V3 is V2-minus-md_description fields, so the existing structural/folder/document-path/agent/conversations import paths apply unchanged. The legacy md_description shim is naturally a no-op on V3 archives because both fields are absent. - types/dicts.py adds OpenContractsExportDataJsonPythonTypeV3 alongside the existing V2 TypedDict (still needed for the V2 import shim that Task 14 implements). - validate_export.py: - Adds 3.0 to KNOWN_VERSIONS. - V3_REQUIRED_FIELDS omits md_description fields. - V3_FORBIDDEN_FIELDS rejects them if present. - V2 still requires them (for back-compat artifacts). - New unit test module test_export_v3.py covers both the export emit shape (version=3.0, no md_description keys) and the validator's V2-vs-V3 gating. - test_validate_export.py bumps its unknown-version probe to "9.0" now that 3.0 is a known version. Note: two pre-existing tests in test_corpus_export_import_v2.py (test_v2_export_import_round_trip and test_three_roundtrips_preserve_in_scope_state) and two analogous fork tests in test_corpus_fork_round_trip.py now fail. They (a) assert version=="2.0" or (b) rely on a fixture that writes corpus.md_description directly (bypassing the canonical CAML write path) and then expect the round-trip to preserve that content. Both legacy contracts are intentionally broken by the V3 emit and will be reconciled in Task 14 (V2 import shim that synthesises a Readme.CAML Document from legacy md_description fields). Spec: Canonical-CAML Corpus Description Refactor design doc, section 4.8.
The V2 import shim (import_md_description_revisions) no longer writes
to the legacy Corpus.md_description FileField + CorpusDescriptionRevision
table. Instead it:
1. Creates a Readme.CAML Document via import_document (the canonical
dual-tree workhorse).
2. Replays revision snapshots oldest-first as version-tree siblings,
preserving historical created timestamps.
3. Runs oc-import:// link rewriting on the synthesized body.
4. Cascades the Corpus cache refresh through the Document post_save
signal plus an explicit _refresh_description_cache_for_corpus call
for deterministic behavior inside TestCase / fork atomic blocks
where on_commit may not fire before the caller reads back the row.
V3 imports skip this path entirely - the Readme.CAML Document rides
in annotated_docs like any other Document, no synthesis needed. The
dispatcher in import_tasks_v2 also runs a final cache refresh after the
full import so V3 archives land with the cache + FK in sync without
relying on the post_save on_commit hook firing.
The annotated_docs-wins rule prevents duplicate CAML creation when a
hand-crafted V2 artifact contains both (logged warning, no error).
Supporting changes:
* fork_tasks: skip the "[FORK] " title prefix on the corpus's
Readme.CAML Document - its title is the load-bearing lookup key for
CorpusDocumentService.get_corpus_caml_articles and the signal-driven
cache refresh; prefixing it silently detached the cache + the
descriptionRevisions facade on every fork.
* utils/etl.build_document_export: non-PDF documents WITH a populated
pdf_file now read those bytes into base64_pdf so the zip member
exists on the import side. Previously the export silently dropped
such files on re-roundtrips (a Readme.CAML imported in round N gets
a pdf_file populated from the round-N placeholder; round N+1 saw
pdf_file set + is_pdf=False and skipped the zip write, breaking
round-trip).
* _corpus_fixture: migrated md_description setup to use
CorpusService.update_description so the rich roundtrip fixture
produces a canonical Readme.CAML Document on construction. The
explicit _refresh_description_cache_for_corpus call pins the cache
for the snapshot helper (signal on_commit doesn't fire inside
TestCase test transactions).
* _corpus_snapshot: read md_description + revisions from the canonical
CAML doc (head body + version-tree siblings) instead of the legacy
Corpus.md_description FileField + CorpusDescriptionRevision rows;
exclude the CAML doc from active_paths so doc counts align with the
user-facing document surface (include_caml=False default on
CorpusDocumentService.get_corpus_documents).
* test_corpus_export_import_v2: rewritten the legacy
test_import_md_description_revisions assertion to verify the new
CAML synthesis contract; bumped TestV2FullRoundTrip's version
assertion to "3.0" (Task 12 contract).
* test_caml_rewrite: integration tests now read the synthesized
Readme.CAML body via read_caml_body instead of the legacy
md_description FileField.
* test_corpus_service: updated update_description's success-path
assertion to verify the returned Readme.CAML Document
(CorpusService.update_description returns Document | None post-Task 8,
not CorpusDescriptionRevision).
* test_non_pdf_export: assertions updated to reflect that non-PDF docs
now carry their file bytes in base64_pdf for round-trip parity.
Spec: docs/superpowers/specs/2026-05-27-canonical-caml-description-refactor-design.md sec 4.8
Migration 0053 removes the legacy storage that 0052 backfilled into Readme.CAML Documents. Model class slimmed: - md_description FileField removed - CorpusDescriptionRevision model removed - description_preview save() override removed (signal handler owns it) - Corpus.update_description / _read_md_description_content / _markdown_to_plain_text / _summarize_for_preview methods removed (live in description_cache.py / signal handler) - calculate_description_filepath function removed (inlined into the historical 0018 migration so it stays importable) - 0050_corpus_description_preview now pulls summarize_for_preview from description_cache rather than the now-removed Corpus staticmethod - llms/tools/core_tools/__init__.py drops the CorpusDescriptionRevision re-export (no consumers) - types/dicts.py drops DescriptionRevisionExport (V3 export doesn't emit it; V2 import shim reads dicts directly via .get()) Test fallout absorbed: test_corpus_description.py loses the legacy MarkdownToPlainTextTest / SummarizeForPreviewTest / UpdateDescriptionSyncTest classes (covered by test_corpus_description_cache.py and the UpdateDescriptionWritesThroughCamlTest already in the file). test_corpus_canonical_caml_migration.py replaces the now-unreachable backfill-staging tests with structural assertions that the legacy field and model are gone after 0053 runs. Corpus.description and Corpus.description_preview remain as auto-maintained read-only caches refreshed by the Document signal handler from Task 3. GraphQL surface (description, descriptionPreview, mdDescription, descriptionRevisions, readmeCamlDocument) is unchanged. Forward-only - column drop on user content has no useful reverse. Spec: docs/superpowers/specs/2026-05-27-canonical-caml-description-refactor-design.md §4.3 §4.9
…end queries Adds an [Unreleased] entry summarising: - Canonical-CAML consolidation (Readme.CAML Document body is the single source of truth; Corpus.description / description_preview / readme_caml_document are auto-maintained read-only caches). - GraphQL surface preservation + the new optional readmeCamlDocument field. - Export V3 (drops md_description / md_description_revisions; V2 import shim synthesizes a CAML doc). - Removed: Corpus.md_description FileField, CorpusDescriptionRevision model, LandingMarkdownSection, package_md_description_revisions helper, DescriptionRevisionExport TypedDict. - Migrations 0051 / 0052 / 0053. Frontend GraphQL cleanup: drops the diff field from the descriptionRevisions selection (queries.ts + mutations.ts) and from the CorpusRevision interface — the field was dropped from the GraphQL type in Task 9 because diffs are now computed on the fly from successive snapshots via difflib. Adds optional readmeCamlDocument to RawCorpusType for clients that want direct access to the canonical Document. Spec: docs/superpowers/specs/2026-05-27-canonical-caml-description-refactor-design.md
…-description-refactor # Conflicts: # CHANGELOG.md
Code Review: Canonical-CAML Corpus Description RefactorThis is an excellent architectural refactor that correctly identifies and eliminates a four-way description sprawl. The core design — canonical source in Readme.CAML Document body, derived cache columns refreshed via signal — is sound. The backward-compat story (V2 import shim, preserved GraphQL surface) is well-thought-out. Below are the issues I found, roughly ordered by severity. Bugs / Correctness1. Stale comment in The docstring says:
The signal is shipped in this PR ( 2. Unprotected binary fallback in migration 0054's except Exception:
field.open("rb") # ← no try/except; propagates on storage error
try:
return field.read().decode("utf-8", errors="ignore")
finally:
field.close()If 3. Migration numbering mismatch in PR description The PR description mentions migrations Performance4. N+1 query in def resolve_version(self, info) -> Any:
ordered_ids = list(
Document.objects.filter(version_tree_id=self.version_tree_id, ...)
.order_by("created", "pk")
.values_list("pk", flat=True)
)
return ordered_ids.index(self.pk) + 1
The fix is straightforward: annotate the 1-based position in the list resolver and attach it as an attribute, or pass a 5. Each Code Quality / Conventions6. Private
from opencontractserver.corpuses.signals import _refresh_description_cache_for_corpusImporting a 7. from opencontractserver.corpuses.signals import _read_caml_bodyThe signals module re-exports from opencontractserver.corpuses.services.description_cache import read_caml_body8. The function is defined, documented, and tested, but no non-test code calls it. The migration uses its own inline logic. If it's intended as a management-command helper for future use, a brief 9. for ...:
from opencontractserver.constants.document_processing import (
CAML_ARTICLE_TITLE, MARKDOWN_MIME_TYPE,
)
if doc.title == CAML_ARTICLE_TITLE and doc.file_type == MARKDOWN_MIME_TYPE:The import is cheap (cached after first call), but placing a module-level import inside a loop body is misleading style. Move it to the top of the function or the module's imports. Security / Data Safety10. If the canonical CAML Document is hard-deleted (e.g., via the admin or a bulk cleanup task), the FK silently becomes
The current Minor / Nits
SummaryThe refactor is solid: single canonical source, signal-driven cache, backward-compatible GraphQL surface, and a well-sequenced set of migrations. The main actionable items before merge:
|
…-description-refactor
- Add try/except around binary fallback in migration 0054 _read_md_description and description_cache.read_caml_body so a single corrupted CAML blob no longer halts the backfill or the cache-refresh signal - Fix N+1 in CorpusDescriptionRevisionType.resolve_version by pre-computing the 1-based version index in CorpusType.resolve_description_revisions and attaching it as _version_index per row; resolver falls back to the per-row query when invoked outside the list path - Move _refresh_description_cache_for_corpus from corpuses/signals.py to corpuses/services/description_cache.refresh_description_cache_for_corpus (now public); update signals.py to delegate; update import_v2, import_tasks_v2, and _corpus_fixture to import from the canonical public location - Switch corpus_service.update_description from importing _read_caml_body off signals.py to read_caml_body from the canonical description_cache module - Lift the CAML_ARTICLE_TITLE / MARKDOWN_MIME_TYPE imports out of the per-document loop body in fork_tasks.py - Drop stale 'on its way out' framing on CorpusDescriptionRevisionType — the legacy model was dropped in migration 0055 - Drop stale 'not yet shipped' framing on backfill_caml_doc_for_corpus — the Document post_save signal is in this PR - Fix descriptions.py:141 mypy lookup-type error (narrow Optional[int] before pk= lookup) - Add test_caml_review_tools, test_corpus_description_cache, test_v2_import_back_compat to mypy baseline (legacy test-attribute patterns; same treatment as other test files) - Pick up pre-commit-applied black/pyupgrade formatting touch-ups in adjacent files
…-description-refactor
Code Review: Canonical-CAML Corpus Description RefactorOverviewThis is a well-scoped, architecturally sound refactor that eliminates four-way description storage drift by making the Readme.CAML Document body the single canonical source of truth. The GraphQL surface is backward-compatible, the migration is split cleanly into schema -> data -> schema-cleanup, and the export schema version bump is accompanied by a proper back-compat shim. 116 tests passing and TypeScript compiling clean are solid signals. The review focuses on issues that could surface in production or degrade correctness under edge cases. Bugs / Correctness[CRITICAL] backfill_caml_doc_for_corpus uses the wrong body for cache refresh when an existing doc is found In opencontractserver/corpuses/services/description_cache.py, the else/doc=existing branch calls compute_cache_from_caml_body(md_description_body) where md_description_body is the caller-supplied legacy archive body, NOT the body stored in the existing document. If they differ, the cache columns end up reflecting the wrong content. Fix: after doc = existing, reassign md_description_body = read_caml_body(doc) before computing plain/preview. There is no test exercising the existing-is-not-None branch of backfill_caml_doc_for_corpus. [MEDIUM] assert used as a runtime guard in production code In opencontractserver/llms/tools/core_tools/descriptions.py, [LOW] Signal handler CAML identification relies on a user-editable title _is_readme_caml_document keys on doc.title == "Readme.CAML" and file_type == "text/markdown". A user who renames their Readme.CAML doc or creates another doc with that exact title + MIME type will trigger spurious cache refreshes. Worth documenting the fragility with a comment or adding model-level protection. Security / Permissions[MEDIUM] get_corpus_description does the CAML lookup as the corpus creator, not the calling agent's user In opencontractserver/llms/tools/core_tools/descriptions.py, CorpusDocumentService.get_corpus_caml_articles(corpus.creator, corpus) always passes the creator's identity. If an agent invokes this on behalf of a user without corpus READ access, the lookup silently succeeds, bypassing per-user permission checks. The actual caller's user should be threaded in here. Performance[MEDIUM] resolve_snapshot triggers one storage I/O per revision CorpusDescriptionRevisionType.resolve_snapshot calls read_caml_body(self) which opens and reads txt_extract_file per sibling. The parent resolver uses select_related("creator") but does not bulk-read file bodies. If the client requests all revisions including snapshot, that is N storage round-trips. Consider capping revision count or adding pagination before revision history becomes unbounded. [LOW] resolve_description_revisions loads entire version tree into memory oldest_first = list(...) materialises all siblings with no upper bound. Fine now but should be capped or paginated before revision count grows large (e.g. active corpora with frequent agent edits). Code Quality / Conventions[LOW] Function-level import of calculate_content_version inside the GraphQL mutation In config/graphql/corpus_mutations.py, calculate_content_version is imported inside the mutate method body. The project uses module-level imports generally; if there is no circular dependency forcing this, it should be at the module top. [LOW] Migration doc comments vs. actual migration numbers The PR body and 0055_drop_legacy_description_storage.py docstring reference "migration 0052" for the backfill and "0053" for the drop, but the actual files are 0053_add_readme_caml_fk, 0054_canonical_caml_backfill, and 0055_drop_legacy_description_storage. The PR Migrations section also lists 0051-0053. This mismatch will cause confusion for ops teams running the migration. [LOW] CorpusDescriptionRevisionType dropped relay.Node - verify client global ID assumptions The type changed from DjangoObjectType (base-64 relay global ID) to a plain graphene.ObjectType whose resolve_id returns the raw Document pk. Any client storing or comparing the id field across the refactor boundary will see different values. The Apollo cache may have stale relay-encoded entries from GET_CORPUS_WITH_HISTORY. Should be verified in the Playwright component tests listed in the test plan. What is Well Done
SummaryThe critical item to resolve before merge is the backfill_caml_doc_for_corpus wrong-body bug - the existing-doc branch should read the body from the document rather than using the legacy-field argument. The assert guard and the inline import are quick mechanical fixes. The permission semantics in get_corpus_description and the title-based CAML identification are worth addressing or documenting as accepted risks. Everything else is polish. |
…-description-refactor
…ft import, document accepted risks - description_cache.backfill_caml_doc_for_corpus: when an existing Readme.CAML doc is found, derive the cache columns from the document's stored body (read_caml_body) rather than the caller-supplied legacy md_description_body, which may have drifted. New regression test covers the existing-doc branch. [CRITICAL] - descriptions.update_corpus_description: replace 'assert author_id is not None' with an explicit raise (asserts are stripped under python -O, which would pass None into get(pk=None)). [MEDIUM] - corpus_mutations: lift calculate_content_version to a module-level import (no circular dependency forces the function-level import). [LOW] - get_corpus_description: document the creator-scoped CAML lookup as an accepted risk (not a regression; agent is corpus-scoped upstream; threading the live user needs a tool-signature change tracked as follow-up). [MEDIUM] - signals._is_readme_caml_document: document the title/MIME identification fragility (idempotent corpus-scoped refresh, so a false positive is wasteful not corrupting). [LOW] - 0055 migration docstring: correct the backfill/drop migration numbers (0054/0053, not 0052). [LOW]
Code Review — PR #1825: Canonical-CAML Corpus DescriptionThe architectural intent here is sound: four-way description sprawl (FileField, plain-text cache, preview cache, Readme.CAML Document) is collapsed into a single canonical source with auto-maintained projections. The service-layer routing, backward-compatible GraphQL surface, and V2→V3 export schema are well thought out. Below are my findings, roughly ordered by severity. Medium — Migration 0054 imports from live service code
Medium —
|
…r guard - Inline the cache derivation in data migrations 0050 and 0054 as migration-local helpers instead of importing compute_cache_from_caml_body / summarize_for_preview from the live description_cache service. Migrations are historical snapshots; a future rename/signature change to the service must not break them on fresh installs. The preview-length constant is frozen inline (280) for the same reason. - Document the resolve_snapshot N-storage-reads trade-off on CorpusDescriptionRevisionType: pre-reading would not reduce the read count (no batch read in object storage); the effective fix is the Task 16 frontend change to fetch snapshot only on single-revision drill-down. The list path is the modal-only viewer, so reads are human-bounded. - Add a defensive corpus.creator null guard in get_corpus_description so a hard-deleted creator yields an empty description instead of an AttributeError reaching get_corpus_caml_articles.
| # (production containers), which would silently pass ``None`` into | ||
| # ``get(pk=None)`` and surface as a confusing DoesNotExist. | ||
| if author_id is None: | ||
| raise ValueError("Provide either author or author_id.") |
Code Review — Canonical-CAML Corpus Description RefactorOverviewThis is a well-scoped architectural refactor that consolidates corpus description storage from four independent locations into a single source of truth (the That said, there are a few issues worth addressing before merge. Bug: Migration 0054 computes cache from wrong source when head already existsFile: body = _read_md_description(corpus) # reads the md_description FileField
head = _get_existing_caml_doc(...)
if head is None and body:
head = _create_caml_doc(...)
if head is not None:
plain, preview = _compute_cache_from_caml_body(body or "") # uses md_description bodyWhen effective_body = body or _read_body_from_head(head)
plain, preview = _compute_cache_from_caml_body(effective_body)In practice, no corpus should have a CAML doc without Breaking change:
|
…-description-refactor
…ile_type Address review: refresh_description_cache_for_corpus looked up the current Readme.CAML DocumentPath by path alone, while the save/delete signal guards on _is_readme_caml_document (title AND file_type == text/markdown). A current DocumentPath named 'Readme.CAML' pointing at a non-markdown document would therefore drive the description cache through the read path even though the signal would never have populated it. Add document__file_type=MARKDOWN_MIME_TYPE to the head_path filter so the read and write paths agree.
Code Review: Canonical-CAML Corpus Description (#1825)Overall: This is a well-architected refactor that correctly identifies and fixes a multi-source-of-truth problem. The signal-based cache pattern, backward-compatible V2 import shim, and self-contained historical migration fixes are all solid. A few issues worth addressing before merge. IssuesMajorMigration 0054 — cache recomputation uses wrong body in idempotent re-run path In This is unreachable in the normal 3-migration sequence (0053 → 0054 → 0055), but becomes a real data-loss scenario if ops ever need to re-run 0054 standalone after data repair — exactly the documented use case for an idempotent migration. The fix is to read the existing CAML doc's body when if head is not None:
cache_body = body if body else _read_caml_doc_body(head)
plain, preview = _compute_cache_from_caml_body(cache_body)The live MinorDirect
These won't trigger Task-number references in inline comments will rot Per CLAUDE.md: task/issue references belong in the PR description, not inline code comments. The following will become opaque noise as the task backlog moves:
Replace each with a description of the invariant/constraint, or remove (the spec link already covers intent). Migration number mismatch in PR description The PR summary references migrations Accepted security deviation should have a concrete follow-up issue
Positive notes
|
…-description-refactor
…on is empty On an idempotent re-run of migration 0054, the legacy md_description field may already be empty (cleared in a prior pass) while the Readme.CAML Document exists. The previous code computed the cache from `body or ""`, which zeroed description/description_preview for already-migrated corpora. Add _read_caml_doc_body helper and use it as the fallback so the cache derives from the canonical CAML doc body when the legacy field is empty. Also replace "Task N" task-number references in corpus_mutations.py, corpus_types.py, and description_cache.py with short descriptions of the invariant/constraint, per codebase style rules.
Code Review: Canonical-CAML Corpus Description RefactorOverviewThis is a well-scoped architectural refactor that consolidates corpus description storage from four divergent sources down to one canonical source (the Readme.CAML Document body), with derived read-only cache columns refreshed via Django signals. The design is sound and the implementation is generally clean. A few issues below are worth addressing before merge. Strengths
Issues1. Fragile signal guard — magic string coupling (Medium)The 2.
|
- Restore opencontractserver/corpuses/migrations/0018_* to its pre-#1825 contents (the no-op RunPython appended in #1825 mutated an already-shipped migration, which can diverge migration hash-checks in CI). The op was a no-op so reverting has no schema/data effect (issue #1848 item 6). - Rename test class AapplyCamlArticleEditCreatesVersionTreeSiblingTest -> ApplyCamlArticleEditCreatesVersionTreeSiblingTest (merge-artifact typo).
The prior revert (b504705) reintroduced a dangling reference to opencontractserver.corpuses.models.calculate_description_filepath, which the canonical-CAML refactor (#1825) removed. Django cannot import the historical migration, breaking the test suite. Restore the inlined calculate_description_filepath callable so 0018 stays importable on fresh installs. https://claude.ai/code/session_01Wa4U1t8WG2uAff2JY9d7z1
…tion follow-up tracking (#1848) (#1855) * Harden Readme.CAML edit lock ordering; track description follow-ups (#1848) - caml_article: acquire a row lock on the Corpus inside the atomic CAML edit block (select_for_update) so a concurrent corpus delete or readme_caml_document cache refresh cannot race between the Document lock and import_document. Documents the Document -> Corpus -> DocumentPath lock order (issue #1848 item 5). - descriptions/signals: replace bare follow-up prose with TODO(#1848) references for threading the live caller through get_corpus_description and for the readme-document signal-guard hardening (items 1 and 3). * Revert historical migration 0018; fix CAML test class typo (#1848) - Restore opencontractserver/corpuses/migrations/0018_* to its pre-#1825 contents (the no-op RunPython appended in #1825 mutated an already-shipped migration, which can diverge migration hash-checks in CI). The op was a no-op so reverting has no schema/data effect (issue #1848 item 6). - Rename test class AapplyCamlArticleEditCreatesVersionTreeSiblingTest -> ApplyCamlArticleEditCreatesVersionTreeSiblingTest (merge-artifact typo). * Restore migration 0018 inline upload_to helper (#1848) The prior revert (b504705) reintroduced a dangling reference to opencontractserver.corpuses.models.calculate_description_filepath, which the canonical-CAML refactor (#1825) removed. Django cannot import the historical migration, breaking the test suite. Restore the inlined calculate_description_filepath callable so 0018 stays importable on fresh installs. https://claude.ai/code/session_01Wa4U1t8WG2uAff2JY9d7z1 * Address PR #1855 review: clarify DocumentPath lock comment The lock-order comment implied DocumentPath is always locked inside import_document; it is locked via .select_for_update().first() only when the path already exists (the new-document case has no row to lock). Comment-only; no behavior change. --------- Co-authored-by: Claude <noreply@anthropic.com>
Summary
Consolidates corpus description storage to a single canonical source: the corpus's
Readme.CAMLDocument body. Eliminates four-place description sprawl (Corpus.md_descriptionFileField,Corpus.description,Corpus.description_previewadded by #1805, and the Readme.CAML Document) by makingCorpus.description/description_preview/readme_caml_documentauto-maintained read-only cache columns refreshed via signal on Readme.CAML save.GraphQL surface is fully backward-compatible:
description,descriptionPreview,mdDescription, anddescriptionRevisionspreserve their observable contracts; only the underlying storage shifts. AdditivereadmeCamlDocumentfield exposes the canonical Document directly for clients that want revision history / permissions / version-tree access.Replaces #1805 — the architectural critique that surfaced during that PR's review (that the bug is canonical-source consolidation, not a missing field) is addressed here.
What changed
CorpusService.update_description) and the agent's targeted-replacement tool (aapply_caml_article_edit) now route throughopencontractserver.documents.versioning.import_document, which atomically creates a new Document sharing the existingversion_tree_idand flipsDocumentPath.is_currentflags. Frontend deep-links resolve to the head of the version tree viacorpus.readmeCamlDocument(or DocumentPath join withis_current=True), no longer to a specific Document pk.Document.version_tree_idsiblings —CorpusDescriptionRevisionTypebecomes agraphene.ObjectTypefacade exposing the historicalid / version / author / snapshot / createdshape (dropdiff; clients compute viadifflibfrom successive snapshots).md_descriptionandmd_description_revisionsfrom the top-level payload; Readme.CAML rides inannotated_docslike any other Document. V2 imports remain supported via a back-compat shim that synthesizes a Readme.CAML doc + replays revisions as version-tree siblings + runsoc-import://link rewriting. Validator gates required fields by version.Corpus.md_descriptionFileField,CorpusDescriptionRevisionmodel + table, model-levelupdate_description/_read_md_description_content/_summarize_for_preview/_markdown_to_plain_texthelpers,Corpus.save()override branch fordescription_preview(signal owns it now),LandingMarkdownSectionstyled component (PR Corpus description preview field + render markdown on the corpus home page #1805 workaround).Migrations
0051_add_readme_caml_fk.py— adds theCorpus.readme_caml_documentFK column.0052_canonical_caml_backfill.py— for every Corpus with non-emptymd_description, creates a Readme.CAML Document + DocumentPath, replays eachCorpusDescriptionRevisionrow as a version-tree sibling (preserving historicalcreatedtimestamps), populates the cache columns. Idempotent via lookup-before-create on DocumentPath. Large deployments should schedule this in a maintenance window.0053_drop_legacy_description_storage.py— dropsCorpus.md_descriptioncolumn +CorpusDescriptionRevisiontable. Forward-only.Spec
docs/superpowers/specs/2026-05-27-canonical-caml-description-refactor-design.mdTest plan
docker compose -f test.yml run --rm django pytest opencontractserver/tests/test_corpus_description_cache.py opencontractserver/tests/test_corpus_description.py opencontractserver/tests/test_corpus_canonical_caml_migration.py opencontractserver/tests/test_v2_import_back_compat.py opencontractserver/tests/test_export_v3.py opencontractserver/tests/test_caml_review_tools.py opencontractserver/tests/test_llm_corpus_patch_tools.py opencontractserver/tests/test_corpus_service.py --create-db→ 116 passedcd frontend && npx tsc --noEmit→ cleanmd_descriptioncontent now has a Readme.CAML Document + populated cache columnsvalidate_export.py, dry-run import into a staging instancemd_descriptionbody withoc-import://links rewrittenyarn test:ct --reporter=list -g "CorpusHome|CorpusLandingView|CorpusListView|CorpusDescriptionEditor|AddToCorpusModal"Caveats
0018_corpus_md_description_corpusdescriptionrevision.pyinlines itscalculate_description_filepathhelper (live model function is gone in 0053). Fresh-install safe.frontend/src/graphql/queries.ts+mutations.tsdrop thedifffield fromdescriptionRevisionsselection — the GraphQL type no longer exposes it.update_corpus_descriptionandCorpusService.update_descriptionnow return aDocument(the new head) instead of aCorpusDescriptionRevision. The corpus-mutations resolver derives the integer version viacalculate_content_version; thepydantic_aitool wrapper wraps the helper insync_to_async. Test fixtures updated.