"""Local project/policy configuration and committed-record context provider.""" from __future__ import annotations import json from contextlib import AbstractContextManager from dataclasses import dataclass from pathlib import Path from typing import Any import portalocker import yaml from pydantic import BaseModel, ValidationError from gewu.application.context import ValidationContext from gewu.application.paths import reject_symlink_components from gewu.application.ports import CanonicalConflictError, CanonicalRepository from gewu.application.serialization import safe_yaml_record from gewu.domain.contracts import ( AutoAcceptPolicy, ExtractionRecord, KnowledgeNode, ProjectRecord, ProjectStatus, SourceRevisionRecord, ) from gewu.domain.validation import extraction_digest MAX_CONTEXT_RECORDS = 20_000 MAX_CONTEXT_RECORD_BYTES = 1_048_576 MAX_PROJECT_REGISTRY_RESPONSE_BYTES = 1_048_576 class RegistryError(RuntimeError): """Configured or committed context is malformed; acceptance must stop.""" class RegistryConflict(RegistryError): """A registry write conflicts with current project or policy identity.""" class RegistryNotFound(RegistryError): """A requested project is not registered.""" def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]: result: dict[str, object] = {} for key, value in pairs: if key in result: raise ValueError("duplicate JSON key") result[key] = value return result def _records[RecordT: BaseModel]( raw: bytes, field: str, record_type: type[RecordT] ) -> tuple[RecordT, ...]: if len(raw) > MAX_CONTEXT_RECORD_BYTES: raise RegistryError("Registry configuration exceeds the supported size") try: value = json.loads(raw, object_pairs_hook=_unique_object) if ( not isinstance(value, dict) or set(value) != {"schema_version", "projects", "policies"} or value.get("schema_version") != 1 ): raise ValueError("registry schema version or fields are invalid") records = value[field] if not isinstance(records, list): raise ValueError("registry field must be a list") return tuple( record_type.model_validate_json(json.dumps(item, ensure_ascii=False), strict=True) for item in records ) except (KeyError, TypeError, ValueError, json.JSONDecodeError, ValidationError) as error: raise RegistryError("Registry configuration failed validation") from error def _index[RecordT: BaseModel](records: tuple[RecordT, ...], field: str) -> dict[str, RecordT]: result: dict[str, RecordT] = {} for record in records: key = getattr(record, field) if key in result: raise RegistryError("Registry contains duplicate stable IDs") result[key] = record return result def _canonical_records[RecordT: BaseModel]( repository: CanonicalRepository, revision: str, prefix: str, record_type: type[RecordT], id_field: str, ) -> dict[str, RecordT]: records: dict[str, RecordT] = {} for record_path in repository.list_files(revision, prefix): if not record_path.endswith(".yaml"): continue if prefix == "projects" and "/nodes/" not in record_path: continue raw = repository.read_file(revision, record_path) if raw is None or len(raw) > MAX_CONTEXT_RECORD_BYTES: raise RegistryError("Canonical context record is unavailable or too large") record = safe_yaml_record(raw, record_type) record_id = getattr(record, id_field) if record_id in records: raise RegistryError("Canonical repository contains duplicate stable IDs") records[record_id] = record if len(records) > MAX_CONTEXT_RECORDS: raise RegistryError("Canonical context exceeds the configured record limit") return records @dataclass(frozen=True, slots=True) class LocalContextProvider: repository: CanonicalRepository registry_file: Path def __call__(self) -> ValidationContext: path = self.registry_file.expanduser().absolute() try: reject_symlink_components(path) except ValueError as error: raise RegistryError("Local registry path traverses a symbolic link") from error if path.exists() and not path.is_file(): raise RegistryError("Local registry file is unavailable") if not path.exists(): projects: dict[str, ProjectRecord] = {} policies: dict[str, AutoAcceptPolicy] = {} else: try: registry_bytes = path.read_bytes() except OSError as error: raise RegistryError("Local registry file is unavailable") from error projects = _index(_records(registry_bytes, "projects", ProjectRecord), "project_id") policies = _index(_records(registry_bytes, "policies", AutoAcceptPolicy), "policy_id") revision = self.repository.head() projects.update(_canonical_projects(self.repository, revision)) policies.update( _canonical_records( self.repository, revision, "policies", AutoAcceptPolicy, "policy_id", ) ) sources = _canonical_records( self.repository, revision, "sources", SourceRevisionRecord, "revision_id" ) nodes = _canonical_records(self.repository, revision, "projects", KnowledgeNode, "node_id") extractions: dict[str, ExtractionRecord] = {} record_count = 0 try: record_count += len(sources) + len(nodes) for record_path in self.repository.list_files(revision, "extractions"): if not record_path.endswith(".json"): continue record_count += 1 extraction_bytes = self.repository.read_file(revision, record_path) if extraction_bytes is None or len(extraction_bytes) > MAX_CONTEXT_RECORD_BYTES: raise RegistryError("Canonical extraction is unavailable or too large") extraction = ExtractionRecord.model_validate_json(extraction_bytes, strict=True) if extraction_digest(extraction) != extraction.extraction_id: raise RegistryError("Canonical extraction identity is invalid") if extraction.extraction_id in extractions: raise RegistryError("Canonical repository contains duplicate extraction IDs") extractions[extraction.extraction_id] = extraction except (OSError, ValueError, ValidationError) as error: raise RegistryError("Canonical context failed validation") from error if record_count > MAX_CONTEXT_RECORDS: raise RegistryError("Canonical context exceeds the configured record limit") return ValidationContext( projects=projects, policies=policies, sources=sources, extractions=extractions, existing_nodes=nodes, ) @dataclass(frozen=True, slots=True) class LocalProjectRegistry: """Read legacy control entries and canonical project lifecycle records.""" registry_file: Path repository: CanonicalRepository | None = None index_directory: Path | None = None authority_lock_path: Path | None = None def _legacy_projects(self) -> tuple[ProjectRecord, ...]: path = self.registry_file.expanduser().absolute() try: reject_symlink_components(path) except ValueError as error: raise RegistryError("Local registry path traverses a symbolic link") from error if not path.exists(): return () if path.is_symlink() or not path.is_file(): raise RegistryError("Local registry file is unavailable") try: raw = path.read_bytes() except OSError as error: raise RegistryError("Local registry file is unavailable") from error return _records(raw, "projects", ProjectRecord) def _projects(self) -> tuple[ProjectRecord, ...]: projects = _index(self._legacy_projects(), "project_id") if self.repository is not None: projects.update(_canonical_projects(self.repository, self.repository.head())) return tuple(projects.values()) def _policies(self) -> tuple[AutoAcceptPolicy, ...]: path = self.registry_file.expanduser().absolute() if not path.exists(): policies: dict[str, AutoAcceptPolicy] = {} elif path.is_symlink() or not path.is_file(): raise RegistryError("Local registry file is unavailable") else: try: raw = path.read_bytes() except OSError as error: raise RegistryError("Local registry file is unavailable") from error policies = _index(_records(raw, "policies", AutoAcceptPolicy), "policy_id") if self.repository is not None: policies.update( _canonical_records( self.repository, self.repository.head(), "policies", AutoAcceptPolicy, "policy_id", ) ) return tuple(policies.values()) def __call__(self) -> frozenset[str]: return frozenset(_index(self._projects(), "project_id")) def records(self) -> tuple[ProjectRecord, ...]: records = tuple(sorted(self._projects(), key=lambda project: project.project_id)) response_bytes = sum( len( json.dumps( project.model_dump(mode="json"), ensure_ascii=False, separators=(",", ":"), ).encode("utf-8") ) for project in records ) if response_bytes > MAX_PROJECT_REGISTRY_RESPONSE_BYTES: raise RegistryError("Project registry listing exceeds the supported size") return records def create(self, project: ProjectRecord, policy: AutoAcceptPolicy) -> ProjectRecord: """Atomically add a project and its explicitly supplied restrictive policy.""" if project.project_id == "shared" or project.status != ProjectStatus.ACTIVE: raise ValueError("New projects must use a non-reserved ID and active status") if not project.owners: raise ValueError("At least one named project owner is required") if ( project.autonomy_policy_id != policy.policy_id or policy.project_id != project.project_id ): raise ValueError("Project and initial policy identities must match") if not policy.require_supported_assessment: raise ValueError("Project policies must require supported assessments") if ( self.repository is None or self.index_directory is None or self.authority_lock_path is None ): raise RegistryError("Canonical project publication is not configured") with self._write_lock(): project_index = _index(self._projects(), "project_id") policy_index = _index(self._policies(), "policy_id") if project.project_id in project_index: raise RegistryConflict("Project ID is already registered") if policy.policy_id in policy_index: raise RegistryConflict("Policy ID is already registered") available_projects = set(project_index) | {project.project_id, "shared"} if any(item not in available_projects for item in project.default_includes): raise ValueError("Project default includes must reference registered projects") if project.project_id in project.default_includes: raise ValueError("A project cannot include itself by default") try: self.repository.publish_files( expected_head=self.repository.head(), files={ _project_record_path(project.project_id): _project_markdown(project), _policy_record_path(policy.policy_id): _yaml_record(policy), }, message=f"Register project {project.project_id}", index_directory=self.index_directory, ) except CanonicalConflictError as error: raise RegistryConflict( "Canonical project registry changed during creation" ) from error return project def update_content( self, project_id: str, *, description: str, north_star: str, key_questions: tuple[str, ...] ) -> ProjectRecord: """Update a project's human-authored description and research questions.""" if self.repository is None or self.index_directory is None or self.authority_lock_path is None: raise RegistryError("Canonical project publication is not configured") with self._write_lock(): project = _index(self._projects(), "project_id").get(project_id) if project is None: raise RegistryNotFound("Project is not registered") updated = project.model_copy(update={ "description": description, "north_star": north_star, "key_questions": key_questions, }) try: self.repository.publish_files( expected_head=self.repository.head(), files={_project_record_path(project_id): _project_markdown(updated)}, message=f"Update project description and questions for {project_id}", index_directory=self.index_directory, ) except CanonicalConflictError as error: raise RegistryConflict("Canonical project registry changed during update") from error return updated def archive(self, project_id: str) -> ProjectRecord: """Mark a project archived while preserving its registry and read history.""" if project_id == "shared": raise ValueError("The shared scope cannot be archived") if ( self.repository is None or self.index_directory is None or self.authority_lock_path is None ): raise RegistryError("Canonical project publication is not configured") with self._write_lock(): project_index = _index(self._projects(), "project_id") project = project_index.get(project_id) if project is None: raise RegistryNotFound("Project is not registered") if project.status == ProjectStatus.ARCHIVED: return project archived = project.model_copy(update={"status": ProjectStatus.ARCHIVED}) try: self.repository.publish_files( expected_head=self.repository.head(), files={_project_record_path(project_id): _project_markdown(archived)}, message=f"Archive project {project_id}", index_directory=self.index_directory, ) except CanonicalConflictError as error: raise RegistryConflict( "Canonical project registry changed during archive" ) from error return archived def _write_lock(self) -> AbstractContextManager[Any]: lock_path = self.authority_lock_path if lock_path is None: lock_path = self.registry_file.with_suffix(self.registry_file.suffix + ".lock") lock_path = lock_path.expanduser().absolute() reject_symlink_components(lock_path) lock_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) lock_path.parent.chmod(0o700) return portalocker.Lock(lock_path, mode="a", timeout=0) def default_includes(self, project_id: str) -> tuple[str, ...]: projects = _index(self._projects(), "project_id") project = projects.get(project_id) if project is None: raise RegistryError("Project is unavailable for default scope resolution") return tuple(include for include in project.default_includes if include != "shared") def _project_record_path(project_id: str) -> str: return f"projects/{project_id}/project.md" def _policy_record_path(policy_id: str) -> str: import hashlib digest = hashlib.sha256(policy_id.encode("utf-8")).hexdigest() return f"policies/{digest}.yaml" def _yaml_record(record: BaseModel) -> bytes: return yaml.safe_dump( record.model_dump(mode="json"), allow_unicode=True, sort_keys=True, ).encode("utf-8") def _project_markdown(project: ProjectRecord) -> bytes: return b"---\n" + _yaml_record(project) + b"---\n" def _canonical_projects(repository: CanonicalRepository, revision: str) -> dict[str, ProjectRecord]: projects: dict[str, ProjectRecord] = {} total_bytes = 0 for record_path in repository.list_files(revision, "projects"): if not record_path.endswith("/project.md"): continue raw = repository.read_file(revision, record_path) if raw is None or len(raw) > MAX_CONTEXT_RECORD_BYTES: raise RegistryError("Canonical project record is unavailable or too large") total_bytes += len(raw) if total_bytes > MAX_PROJECT_REGISTRY_RESPONSE_BYTES: raise RegistryError("Canonical project registry exceeds the supported size") if not raw.startswith(b"---\n") or b"\n---\n" not in raw[4:]: raise RegistryError("Canonical project frontmatter is invalid") frontmatter = raw[4:].split(b"\n---\n", maxsplit=1)[0] project = safe_yaml_record(frontmatter, ProjectRecord) if record_path != _project_record_path(project.project_id): raise RegistryError("Canonical project path does not match its stable ID") if project.project_id in projects: raise RegistryError("Canonical repository contains duplicate project IDs") projects[project.project_id] = project if len(projects) > MAX_CONTEXT_RECORDS: raise RegistryError("Canonical project registry exceeds the supported record count") return projects