from __future__ import annotations import hashlib import json from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Q from control_plane.common import TimestampedModel def text_sha256(value: str) -> str: return hashlib.sha256(value.encode("utf-8")).hexdigest() class StoryStatus(models.TextChoices): PLANNING = "PLANNING" REVISING = "REVISING" DRAFTING = "DRAFTING" COMPLETE = "COMPLETE" class ChapterStatus(models.TextChoices): PLANNED = "PLANNED" DRAFTING = "DRAFTING" REVIEW = "REVIEW" APPROVED = "APPROVED" class RevisionStatus(models.TextChoices): SOURCE = "SOURCE" DRAFT = "DRAFT" REVIEW = "REVIEW" APPROVED = "APPROVED" REJECTED = "REJECTED" class FindingSeverity(models.TextChoices): INFO = "INFO" LOW = "LOW" MEDIUM = "MEDIUM" HIGH = "HIGH" CRITICAL = "CRITICAL" class FindingStatus(models.TextChoices): OPEN = "OPEN" RESOLVED = "RESOLVED" ACCEPTED = "ACCEPTED" class StateDocumentStatus(models.TextChoices): EXTRACTED = "EXTRACTED" NEEDS_REVISION = "NEEDS_REVISION" VALIDATED = "VALIDATED" COMMITTED = "COMMITTED" class RequirementStatus(models.TextChoices): HIT = "HIT" PARTIAL = "PARTIAL" MISSED = "MISSED" CONTRADICTED = "CONTRADICTED" UNVERIFIABLE = "UNVERIFIABLE" class StateChangeStatus(models.TextChoices): PROPOSED = "PROPOSED" VALIDATED = "VALIDATED" COMMITTED = "COMMITTED" REJECTED = "REJECTED" class StateOperation(models.TextChoices): SET = "SET" ADD = "ADD" REMOVE = "REMOVE" TRANSFER = "TRANSFER" OPEN = "OPEN" CLOSE = "CLOSE" class WorkType(models.TextChoices): BOOK = "book" SERIES_REFERENCE = "series_reference" OTHER = "other" class DocumentType(models.TextChoices): MANUSCRIPT = "manuscript" SCENE = "scene" OUTLINE = "outline" PLANNING = "planning" CANON = "canon" STATE = "state" REFERENCE = "reference" OTHER = "other" class DocumentAuthority(models.TextChoices): CANON = "canon" PROVISIONAL = "provisional" PLANNING = "planning" SUPERSEDED = "superseded" REJECTED = "rejected" NONCANON_EXPERIMENT = "noncanon_experiment" class SceneDraftStatus(models.TextChoices): PLANNING = "planning" PLAN_REVIEW = "plan_review" READY = "ready" DRAFTING = "drafting" DRAFT_REVIEW = "draft_review" APPROVED = "approved" REJECTED = "rejected" FAILED = "failed" class BookStateStatus(models.TextChoices): DRAFT = "draft" REVIEW = "review" APPROVED = "approved" REJECTED = "rejected" class BookRunStatus(models.TextChoices): PENDING = "pending" RUNNING = "running" PAUSED = "paused" REVIEW = "review" COMPLETE = "complete" FAILED = "failed" CANCELLED = "cancelled" class Series(TimestampedModel): title = models.CharField(max_length=255) slug = models.SlugField(max_length=160, unique=True) description = models.TextField(blank=True) metadata = models.JSONField(default=dict, blank=True) class Meta: verbose_name_plural = "series" def __str__(self) -> str: return self.title class Work(TimestampedModel): series = models.ForeignKey(Series, on_delete=models.CASCADE, related_name="works") title = models.CharField(max_length=255) slug = models.SlugField(max_length=160) work_type = models.CharField( max_length=32, choices=WorkType.choices, default=WorkType.BOOK ) sequence = models.PositiveIntegerField(null=True, blank=True) metadata = models.JSONField(default=dict, blank=True) current_book_state = models.ForeignKey( "BookStateVersion", on_delete=models.SET_NULL, null=True, blank=True, related_name="current_for_works", ) class Meta: ordering = ["sequence", "title"] constraints = [ models.UniqueConstraint(fields=["series", "slug"], name="unique_series_work_slug") ] def __str__(self) -> str: return self.title class StoryProject(TimestampedModel): project = models.OneToOneField( "projects.Project", on_delete=models.CASCADE, related_name="story_project" ) work = models.OneToOneField( Work, on_delete=models.PROTECT, related_name="story_project", null=True, blank=True, ) title = models.CharField(max_length=255) series = models.CharField(max_length=255, blank=True) slug = models.SlugField(max_length=160, unique=True) status = models.CharField( max_length=32, choices=StoryStatus.choices, default=StoryStatus.PLANNING ) artifact_root = models.TextField(blank=True) config = models.JSONField(default=dict, blank=True) def __str__(self) -> str: return self.title class SourceDocument(TimestampedModel): work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name="source_documents") logical_key = models.CharField(max_length=500) title = models.CharField(max_length=500) document_type = models.CharField( max_length=32, choices=DocumentType.choices, default=DocumentType.OTHER ) metadata = models.JSONField(default=dict, blank=True) class Meta: ordering = ["logical_key"] constraints = [ models.UniqueConstraint( fields=["work", "logical_key"], name="unique_work_source_document_key" ) ] def __str__(self) -> str: return self.title class SourceDocumentVersion(TimestampedModel): document = models.ForeignKey(SourceDocument, on_delete=models.CASCADE, related_name="versions") version = models.PositiveIntegerField() authority = models.CharField( max_length=32, choices=DocumentAuthority.choices, default=DocumentAuthority.PROVISIONAL, db_index=True, ) source_path = models.TextField() content = models.TextField() source_sha256 = models.CharField(max_length=64, db_index=True) byte_size = models.PositiveBigIntegerField() encoding = models.CharField(max_length=40, default="utf-8") supersedes = models.ForeignKey( "self", on_delete=models.PROTECT, null=True, blank=True, related_name="superseded_by", ) metadata = models.JSONField(default=dict, blank=True) class Meta: ordering = ["document", "version"] constraints = [ models.UniqueConstraint( fields=["document", "version"], name="unique_source_document_version" ), ] def __str__(self) -> str: return f"{self.document.title} v{self.version}" def save(self, *args: object, **kwargs: object) -> None: if not self._state.adding: original = SourceDocumentVersion.objects.get(pk=self.pk) immutable_fields = ( "document_id", "version", "authority", "source_path", "content", "source_sha256", "byte_size", "encoding", "supersedes_id", "metadata", ) changed = any( getattr(self, field) != getattr(original, field) for field in immutable_fields ) if changed: raise ValueError( "source document versions are immutable; create a superseding version" ) super().save(*args, **kwargs) class SourcePassage(TimestampedModel): document_version = models.ForeignKey( SourceDocumentVersion, on_delete=models.CASCADE, related_name="passages" ) ordinal = models.PositiveIntegerField() start_line = models.PositiveIntegerField() end_line = models.PositiveIntegerField() start_char = models.PositiveBigIntegerField() end_char = models.PositiveBigIntegerField() content = models.TextField() sha256 = models.CharField(max_length=64, db_index=True) metadata = models.JSONField(default=dict, blank=True) class Meta: ordering = ["document_version", "ordinal"] constraints = [ models.UniqueConstraint( fields=["document_version", "ordinal"], name="unique_source_document_passage_ordinal", ), models.CheckConstraint( condition=Q(end_line__gte=models.F("start_line")), name="source_passage_line_range_valid", ), models.CheckConstraint( condition=Q(end_char__gte=models.F("start_char")), name="source_passage_char_range_valid", ), ] def save(self, *args: object, **kwargs: object) -> None: if not self._state.adding: original = SourcePassage.objects.get(pk=self.pk) immutable_fields = ( "document_version_id", "ordinal", "start_line", "end_line", "start_char", "end_char", "content", "sha256", "metadata", ) changed = any( getattr(self, field) != getattr(original, field) for field in immutable_fields ) if changed: raise ValueError("source passages are immutable with their document version") super().save(*args, **kwargs) class BookStateVersion(TimestampedModel): work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name="book_state_versions") parent = models.ForeignKey( "self", on_delete=models.PROTECT, null=True, blank=True, related_name="children", ) version = models.PositiveIntegerField() status = models.CharField( max_length=16, choices=BookStateStatus.choices, default=BookStateStatus.DRAFT ) content = models.JSONField() sha256 = models.CharField(max_length=64, db_index=True) context_pack = models.JSONField(default=dict, blank=True) context_pack_sha256 = models.CharField(max_length=64, blank=True) validation = models.JSONField(default=dict, blank=True) reviews = models.JSONField(default=dict, blank=True) change_summary = models.JSONField(default=dict, blank=True) generation_metadata = models.JSONField(default=dict, blank=True) created_by = models.CharField(max_length=160, blank=True) json_artifact_uri = models.TextField(blank=True) markdown_artifact_uri = models.TextField(blank=True) approved_at = models.DateTimeField(null=True, blank=True) approved_by = models.CharField(max_length=160, blank=True) approval_notes = models.TextField(blank=True) approval_forced = models.BooleanField(default=False) rejected_at = models.DateTimeField(null=True, blank=True) rejected_by = models.CharField(max_length=160, blank=True) rejection_notes = models.TextField(blank=True) class Meta: ordering = ["work", "version"] constraints = [ models.UniqueConstraint( fields=["work", "version"], name="unique_work_book_state_version" ) ] def save(self, *args: object, **kwargs: object) -> None: if self._state.adding: canonical = json.dumps( self.content, ensure_ascii=False, sort_keys=True, separators=(",", ":") ) self.sha256 = text_sha256(canonical) if self.context_pack: canonical_context = json.dumps( self.context_pack, ensure_ascii=False, sort_keys=True, separators=(",", ":"), ) self.context_pack_sha256 = text_sha256(canonical_context) else: original = BookStateVersion.objects.get(pk=self.pk) immutable_fields = ( "work_id", "parent_id", "version", "content", "sha256", "context_pack", "context_pack_sha256", "generation_metadata", "created_by", ) if original.status == BookStateStatus.APPROVED: immutable_fields += ( "status", "reviews", "validation", "change_summary", "approved_at", "approved_by", "approval_notes", "approval_forced", "rejected_at", "rejected_by", "rejection_notes", "json_artifact_uri", "markdown_artifact_uri", ) if any( getattr(self, field) != getattr(original, field) for field in immutable_fields ): raise ValueError( "book state versions are immutable; create a child version" ) super().save(*args, **kwargs) def __str__(self) -> str: return f"{self.work} v{self.version}" class StandaloneScene(TimestampedModel): work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name="standalone_scenes") story = models.ForeignKey( StoryProject, on_delete=models.SET_NULL, null=True, blank=True, related_name="standalone_scenes", ) parent = models.ForeignKey( "self", on_delete=models.PROTECT, null=True, blank=True, related_name="revisions", ) source_version = models.ForeignKey( SourceDocumentVersion, on_delete=models.SET_NULL, null=True, blank=True, related_name="generated_scenes", ) book_state = models.ForeignKey( BookStateVersion, on_delete=models.PROTECT, null=True, blank=True, related_name="standalone_scenes", ) book_chapter_key = models.CharField(max_length=80, blank=True) scene_key = models.SlugField(max_length=200) revision = models.PositiveIntegerField(default=1) title = models.CharField(max_length=500) status = models.CharField( max_length=32, choices=SceneDraftStatus.choices, default=SceneDraftStatus.PLANNING ) brief = models.TextField() target_words = models.PositiveIntegerField( default=1800, validators=[MinValueValidator(300), MaxValueValidator(10000)], ) constraints = models.JSONField(default=list, blank=True) forbidden_events = models.JSONField(default=list, blank=True) boundary_constraints = models.JSONField(default=list, blank=True) context_query = models.TextField(blank=True) context_pack = models.JSONField(default=dict, blank=True) context_pack_sha256 = models.CharField(max_length=64, blank=True) plan = models.JSONField(default=dict, blank=True) contract_requirements = models.JSONField(default=list, blank=True) prose = models.TextField(blank=True) word_count = models.PositiveIntegerField(default=0) sha256 = models.CharField(max_length=64, blank=True) partial_artifact_uri = models.TextField(blank=True) artifact_uri = models.TextField(blank=True) review_artifact_uri = models.TextField(blank=True) review = models.JSONField(default=dict, blank=True) generation_metadata = models.JSONField(default=dict, blank=True) plan_approved_at = models.DateTimeField(null=True, blank=True) approved_at = models.DateTimeField(null=True, blank=True) approved_by = models.CharField(max_length=160, blank=True) failure_reason = models.TextField(blank=True) class Meta: ordering = ["-updated_at"] constraints = [ models.UniqueConstraint( fields=["work", "scene_key", "revision"], condition=Q(book_state__isnull=True), name="unique_unbound_scene_revision", ), models.UniqueConstraint( fields=[ "work", "book_state", "book_chapter_key", "scene_key", "revision", ], condition=Q(book_state__isnull=False), name="unique_bound_scene_revision", ), models.CheckConstraint( condition=(Q(book_state__isnull=True) & Q(book_chapter_key="")) | (Q(book_state__isnull=False) & ~Q(book_chapter_key="")), name="scene_book_state_chapter_key_paired", ), ] def save(self, *args: object, **kwargs: object) -> None: if not self._state.adding: original = StandaloneScene.objects.get(pk=self.pk) if original.status == SceneDraftStatus.APPROVED: immutable_fields = ( "work_id", "story_id", "parent_id", "source_version_id", "book_state_id", "book_chapter_key", "scene_key", "revision", "title", "status", "brief", "target_words", "constraints", "forbidden_events", "boundary_constraints", "context_query", "context_pack", "context_pack_sha256", "plan", "contract_requirements", "prose", "sha256", "artifact_uri", "review", "review_artifact_uri", "generation_metadata", "approved_at", "approved_by", ) changed = any( getattr(self, field) != getattr(original, field) for field in immutable_fields ) if changed: raise ValueError( "approved standalone scenes are immutable; create a new revision" ) if self.prose: import re self.word_count = len(re.findall(r"\b\S+\b", self.prose)) self.sha256 = text_sha256(self.prose) super().save(*args, **kwargs) def __str__(self) -> str: return f"{self.title} r{self.revision}" class SceneIdeation(TimestampedModel): work = models.ForeignKey(Work, on_delete=models.CASCADE, related_name="scene_ideations") book_state = models.ForeignKey( BookStateVersion, on_delete=models.PROTECT, null=True, blank=True, related_name="scene_ideations", ) target_book = models.CharField(max_length=160, blank=True) focus = models.TextField(blank=True) requested_scene_types = models.JSONField(default=list, blank=True) candidate_count = models.PositiveSmallIntegerField( default=10, validators=[MinValueValidator(1), MaxValueValidator(12)], ) authorities = models.JSONField(default=list) pinned_document_keys = models.JSONField(default=list, blank=True) context_pack = models.JSONField(default=dict) context_pack_sha256 = models.CharField(max_length=64) candidates = models.JSONField(default=list) generation_metadata = models.JSONField(default=dict) class Meta: ordering = ["-created_at"] def __str__(self) -> str: return f"{self.work}: {self.candidate_count} scene ideas" class BookRun(TimestampedModel): """Durable orchestration cursor for a book state, not prose state.""" book_state = models.ForeignKey( BookStateVersion, on_delete=models.PROTECT, related_name="runs" ) status = models.CharField( max_length=16, choices=BookRunStatus.choices, default=BookRunStatus.PENDING, db_index=True, ) current_chapter_key = models.CharField(max_length=80, blank=True) progress = models.JSONField(default=dict) policy = models.JSONField(default=dict) reviews = models.JSONField(default=dict, blank=True) failure_reason = models.TextField(blank=True) started_at = models.DateTimeField(null=True, blank=True) finished_at = models.DateTimeField(null=True, blank=True) class Meta: ordering = ["-created_at"] class SceneContextCitation(TimestampedModel): scene = models.ForeignKey( StandaloneScene, on_delete=models.CASCADE, related_name="context_citations" ) passage = models.ForeignKey( SourcePassage, on_delete=models.PROTECT, related_name="scene_citations" ) rank = models.PositiveIntegerField() score = models.FloatField(default=0) reason = models.CharField(max_length=255, blank=True) class Meta: ordering = ["scene", "rank"] constraints = [ models.UniqueConstraint( fields=["scene", "passage"], name="unique_scene_context_passage" ), models.UniqueConstraint( fields=["scene", "rank"], name="unique_scene_context_rank" ), ] class StoryBibleVersion(TimestampedModel): story = models.ForeignKey(StoryProject, on_delete=models.CASCADE, related_name="bible_versions") version = models.PositiveIntegerField() content = models.TextField() structured_canon = models.JSONField(default=dict, blank=True) sha256 = models.CharField(max_length=64, blank=True) approved_at = models.DateTimeField(null=True, blank=True) class Meta: constraints = [ models.UniqueConstraint(fields=["story", "version"], name="unique_story_bible_version") ] def save(self, *args: object, **kwargs: object) -> None: self.sha256 = text_sha256(self.content) super().save(*args, **kwargs) class OutlineVersion(TimestampedModel): story = models.ForeignKey(StoryProject, on_delete=models.CASCADE, related_name="outline_versions") version = models.PositiveIntegerField() content = models.JSONField(default=dict) sha256 = models.CharField(max_length=64, blank=True) approved_at = models.DateTimeField(null=True, blank=True) class Meta: constraints = [ models.UniqueConstraint(fields=["story", "version"], name="unique_story_outline_version") ] def save(self, *args: object, **kwargs: object) -> None: import json canonical = json.dumps(self.content, ensure_ascii=False, sort_keys=True, separators=(",", ":")) self.sha256 = text_sha256(canonical) super().save(*args, **kwargs) class PromptVersion(TimestampedModel): name = models.CharField(max_length=160) purpose = models.CharField(max_length=80) version = models.PositiveIntegerField() system_text = models.TextField(blank=True) user_template = models.TextField() config = models.JSONField(default=dict, blank=True) is_active = models.BooleanField(default=False) class Meta: constraints = [ models.UniqueConstraint(fields=["name", "version"], name="unique_authoring_prompt_version"), models.UniqueConstraint( fields=["purpose"], condition=Q(is_active=True), name="unique_active_authoring_prompt_purpose", ), ] class Chapter(TimestampedModel): story = models.ForeignKey(StoryProject, on_delete=models.CASCADE, related_name="chapters") number = models.PositiveIntegerField() title = models.CharField(max_length=255) status = models.CharField( max_length=32, choices=ChapterStatus.choices, default=ChapterStatus.PLANNED ) current_revision = models.ForeignKey( "ChapterRevision", on_delete=models.SET_NULL, null=True, blank=True, related_name="current_for_chapters", ) class Meta: ordering = ["number"] constraints = [ models.UniqueConstraint(fields=["story", "number"], name="unique_story_chapter_number") ] def __str__(self) -> str: return f"{self.story.title} - Chapter {self.number}: {self.title}" class GenerationContextSnapshot(TimestampedModel): story = models.ForeignKey(StoryProject, on_delete=models.CASCADE, related_name="context_snapshots") chapter = models.ForeignKey(Chapter, on_delete=models.CASCADE, related_name="context_snapshots") story_bible = models.ForeignKey(StoryBibleVersion, on_delete=models.PROTECT) outline = models.ForeignKey(OutlineVersion, on_delete=models.PROTECT) prior_canon = models.ForeignKey( "CanonSnapshot", on_delete=models.PROTECT, null=True, blank=True, related_name="derived_contexts" ) content = models.JSONField(default=dict) sha256 = models.CharField(max_length=64) class ChapterRevision(TimestampedModel): chapter = models.ForeignKey(Chapter, on_delete=models.CASCADE, related_name="revisions") revision = models.PositiveIntegerField() status = models.CharField( max_length=32, choices=RevisionStatus.choices, default=RevisionStatus.DRAFT ) parent = models.ForeignKey( "self", on_delete=models.SET_NULL, null=True, blank=True, related_name="children" ) source_revision = models.ForeignKey( "self", on_delete=models.SET_NULL, null=True, blank=True, related_name="source_children" ) story_bible = models.ForeignKey(StoryBibleVersion, on_delete=models.PROTECT) outline = models.ForeignKey(OutlineVersion, on_delete=models.PROTECT) context_snapshot = models.ForeignKey( GenerationContextSnapshot, on_delete=models.SET_NULL, null=True, blank=True, related_name="revisions", ) scene_plan = models.JSONField(default=dict, blank=True) prose = models.TextField(blank=True) continuity_state = models.JSONField(default=dict, blank=True) artifact_uri = models.TextField(blank=True) word_count = models.PositiveIntegerField(default=0) sha256 = models.CharField(max_length=64, blank=True) graph_thread_id = models.CharField(max_length=255, blank=True, db_index=True) generation_metadata = models.JSONField(default=dict, blank=True) approved_at = models.DateTimeField(null=True, blank=True) class Meta: constraints = [ models.UniqueConstraint( fields=["chapter", "revision"], name="unique_chapter_revision_number" ) ] def save(self, *args: object, **kwargs: object) -> None: if self.prose: import re self.word_count = len(re.findall(r"\b\S+\b", self.prose)) self.sha256 = text_sha256(self.prose) super().save(*args, **kwargs) class ChapterContract(TimestampedModel): revision = models.OneToOneField( ChapterRevision, on_delete=models.CASCADE, related_name="contract" ) entry_canon = models.ForeignKey( "CanonSnapshot", on_delete=models.PROTECT, null=True, blank=True, related_name="chapter_contracts", ) requirements = models.JSONField(default=list) scene_plan_sha256 = models.CharField(max_length=64) approved_at = models.DateTimeField(null=True, blank=True) class ChapterStateDocument(TimestampedModel): revision = models.OneToOneField( ChapterRevision, on_delete=models.CASCADE, related_name="state_document" ) contract = models.ForeignKey( ChapterContract, on_delete=models.PROTECT, related_name="state_documents" ) status = models.CharField( max_length=32, choices=StateDocumentStatus.choices, default=StateDocumentStatus.EXTRACTED, ) start_state = models.JSONField(default=dict) observed_state = models.JSONField(default=dict) proposed_delta = models.JSONField(default=list) coverage = models.JSONField(default=dict) verdict = models.CharField(max_length=32, blank=True) json_artifact_uri = models.TextField(blank=True) markdown_artifact_uri = models.TextField(blank=True) sha256 = models.CharField(max_length=64) model_metadata = models.JSONField(default=dict, blank=True) validated_at = models.DateTimeField(null=True, blank=True) committed_at = models.DateTimeField(null=True, blank=True) class StoryEntity(TimestampedModel): story = models.ForeignKey(StoryProject, on_delete=models.CASCADE, related_name="state_entities") entity_key = models.CharField(max_length=200) kind = models.CharField(max_length=64) canonical_name = models.CharField(max_length=255) aliases = models.JSONField(default=list, blank=True) metadata = models.JSONField(default=dict, blank=True) first_revision = models.ForeignKey( ChapterRevision, on_delete=models.SET_NULL, null=True, blank=True, related_name="introduced_state_entities", ) class Meta: constraints = [ models.UniqueConstraint( fields=["story", "entity_key"], name="unique_story_state_entity_key" ) ] class StateChange(TimestampedModel): state_document = models.ForeignKey( ChapterStateDocument, on_delete=models.CASCADE, related_name="changes" ) story = models.ForeignKey(StoryProject, on_delete=models.CASCADE, related_name="state_changes") revision = models.ForeignKey( ChapterRevision, on_delete=models.CASCADE, related_name="state_changes" ) entity = models.ForeignKey( StoryEntity, on_delete=models.PROTECT, null=True, blank=True, related_name="changes" ) related_entity = models.ForeignKey( StoryEntity, on_delete=models.PROTECT, null=True, blank=True, related_name="related_changes", ) sequence = models.PositiveIntegerField() change_type = models.CharField(max_length=80) predicate = models.CharField(max_length=200) operation = models.CharField(max_length=16, choices=StateOperation.choices) previous_value = models.JSONField(null=True, blank=True) new_value = models.JSONField(null=True, blank=True) effective_chapter = models.PositiveIntegerField() evidence_quote = models.TextField(blank=True) evidence_location = models.CharField(max_length=255, blank=True) status = models.CharField( max_length=16, choices=StateChangeStatus.choices, default=StateChangeStatus.PROPOSED, ) supersedes = models.ForeignKey( "self", on_delete=models.PROTECT, null=True, blank=True, related_name="superseded_by", ) metadata = models.JSONField(default=dict, blank=True) sha256 = models.CharField(max_length=64) class Meta: ordering = ["effective_chapter", "sequence"] constraints = [ models.UniqueConstraint( fields=["state_document", "sequence"], name="unique_state_change_sequence" ) ] class RequirementCheck(TimestampedModel): state_document = models.ForeignKey( ChapterStateDocument, on_delete=models.CASCADE, related_name="requirement_checks" ) requirement_id = models.CharField(max_length=80) requirement_type = models.CharField(max_length=32) requirement_text = models.TextField() status = models.CharField(max_length=24, choices=RequirementStatus.choices) severity = models.CharField(max_length=16, choices=FindingSeverity.choices) evidence_quote = models.TextField(blank=True) evidence_location = models.CharField(max_length=255, blank=True) details = models.TextField(blank=True) model_metadata = models.JSONField(default=dict, blank=True) class Meta: constraints = [ models.UniqueConstraint( fields=["state_document", "requirement_id"], name="unique_state_document_requirement", ) ] class CanonSnapshot(TimestampedModel): story = models.ForeignKey(StoryProject, on_delete=models.CASCADE, related_name="canon_snapshots") through_chapter = models.PositiveIntegerField() version = models.PositiveIntegerField() state = models.JSONField(default=dict) source_revision = models.OneToOneField( ChapterRevision, on_delete=models.PROTECT, related_name="committed_canon" ) sha256 = models.CharField(max_length=64) class Meta: ordering = ["version"] constraints = [ models.UniqueConstraint(fields=["story", "version"], name="unique_story_canon_version") ] class EditorialFinding(TimestampedModel): revision = models.ForeignKey(ChapterRevision, on_delete=models.CASCADE, related_name="findings") review_kind = models.CharField(max_length=80) severity = models.CharField( max_length=16, choices=FindingSeverity.choices, default=FindingSeverity.INFO ) category = models.CharField(max_length=80) location = models.CharField(max_length=255, blank=True) description = models.TextField() suggested_revision = models.TextField(blank=True) evidence = models.JSONField(default=dict, blank=True) status = models.CharField( max_length=16, choices=FindingStatus.choices, default=FindingStatus.OPEN ) model_metadata = models.JSONField(default=dict, blank=True)