"""Durable proposal evaluation, Git publication and receipt reconciliation.""" from __future__ import annotations import hashlib import json import os import secrets import threading from collections.abc import Callable, Mapping from dataclasses import dataclass, replace from datetime import UTC, datetime from pathlib import Path from typing import Literal, cast import portalocker import yaml from pydantic import BaseModel, TypeAdapter, ValidationError from gewu.application.context import ValidationContext from gewu.application.ports import ( CanonicalConflictError, CanonicalRepository, CanonicalRepositoryError, ) from gewu.application.serialization import safe_yaml_record from gewu.application.uploads import LocalArtifactStore, UploadError from gewu.domain.contracts import ( AcceptanceManifest, ChangedFile, ExtractionRecord, Finding, KnowledgeNode, OwnerDecisionReference, PolicyBinding, ProposalReceipt, ProposalRevision, ProposalState, ProposalStatus, RecordId, SourceRevisionRecord, TypedEdge, ) from gewu.domain.validation import ( ValidationReport, canonical_json, extraction_digest, validate_proposal, ) class AcceptanceError(RuntimeError): """Application failure with a stable, non-sensitive code.""" def __init__(self, code: str, message: str) -> None: super().__init__(message) self.code = code @dataclass(frozen=True, slots=True) class ActorContext: """Identity supplied by an authenticated transport adapter.""" principal_id: str credential_id: str roles: frozenset[str] | None = None project_ids: frozenset[str] | None = None readable_project_ids: frozenset[str] = frozenset() @dataclass(frozen=True, slots=True) class SubmissionResult: validation: ValidationReport receipt: ProposalReceipt | None owner_challenge_ids: tuple[str, ...] = () def _json_record(record: BaseModel) -> bytes: return canonical_json(record.model_dump(mode="json")) def _yaml_record(record: BaseModel) -> bytes: value = record.model_dump(mode="json") return yaml.safe_dump(value, allow_unicode=True, sort_keys=True, width=100).encode("utf-8") def _safe_key(value: str) -> str: return hashlib.sha256(value.encode("utf-8")).hexdigest() class AcceptanceService: """Accept eligible proposals through one recoverable canonical writer.""" def __init__( self, *, repository: CanonicalRepository, artifact_store: LocalArtifactStore, workflow_root: Path, context_provider: Callable[[], ValidationContext], actor_is_current: Callable[[ActorContext], bool], owner_challenge_issuer: Callable[..., None] | None = None, owner_decision_lookup: Callable[..., tuple[dict[str, object], ...]] | None = None, failure_hook: Callable[[str], None] = lambda _stage: None, ) -> None: self.repository = repository self.artifact_store = artifact_store self.root = workflow_root.expanduser().resolve() self.context_provider = context_provider self.actor_is_current = actor_is_current self.owner_challenge_issuer = owner_challenge_issuer self.owner_decision_lookup = owner_decision_lookup self.failure_hook = failure_hook self._thread_lock = threading.RLock() self._lock_path = self.root / "locks" / "canonical-writer.lock" self.proposals_root = self.root / "proposals" self.journal_root = self.root / "journal" self.receipts_root = self.root / "receipts" self.publishing_root = self.root / "publishing" for directory in ( self._lock_path.parent, self.proposals_root, self.journal_root, self.receipts_root, self.publishing_root, ): directory.mkdir(parents=True, exist_ok=True, mode=0o700) if directory.is_symlink() or not directory.is_dir(): raise RuntimeError("GEWU workflow directory must be a real directory") directory.chmod(0o700) def submit( self, proposal: ProposalRevision, *, actor: ActorContext, staged_extractions: Mapping[str, ExtractionRecord] | None = None, ) -> SubmissionResult: """Persist, validate, policy-check and publish a proposal if eligible.""" if actor.principal_id != proposal.proposer_id: raise AcceptanceError( "UNAUTHORIZED", "Authenticated actor does not match proposal author" ) staged = dict(staged_extractions or {}) if len(staged) > 100 or any( key != record.extraction_id or extraction_digest(record) != key for key, record in staged.items() ): raise AcceptanceError( "EXTRACTION_HASH_MISMATCH", "Staged extraction identity is invalid" ) with self._thread_lock: try: with portalocker.Lock(self._lock_path, mode="a", timeout=0): self._reconcile_pending() return self._submit_locked(proposal, actor, staged) except portalocker.exceptions.LockException as error: raise AcceptanceError( "BUSY", "Canonical writer is handling another proposal" ) from error def receipt(self, proposal_id: str, revision: int) -> ProposalReceipt | None: path = self._receipt_path(proposal_id, revision) if path.is_symlink() or not path.is_file(): return None try: return safe_yaml_record(path.read_bytes(), ProposalReceipt) except (OSError, ValueError, yaml.YAMLError, ValidationError) as error: raise AcceptanceError( "RECOVERY_REQUIRED", "Durable receipt requires reconciliation" ) from error def proposal_status( self, proposal_id: str, revision: int, *, actor: ActorContext ) -> ProposalStatus: """Read lifecycle for the authenticated proposal author without exposing its payload.""" try: safe_id = TypeAdapter(RecordId) validated_id = safe_id.validate_python(proposal_id, strict=True) except ValidationError as error: raise AcceptanceError("PROPOSAL_NOT_FOUND", "Proposal status is unavailable") from error if type(revision) is not int or revision < 1 or revision > 2**31 - 1: raise AcceptanceError("PROPOSAL_NOT_FOUND", "Proposal status is unavailable") proposal_path = self._proposal_path(validated_id, revision) if proposal_path.is_symlink() or not proposal_path.is_file(): raise AcceptanceError("PROPOSAL_NOT_FOUND", "Proposal status is unavailable") try: raw_proposal = proposal_path.read_bytes() if len(raw_proposal) > 1_000_000: raise ValueError("Proposal record exceeds its storage limit") stored_proposal = ProposalRevision.model_validate_json(raw_proposal, strict=True) except (OSError, ValueError, ValidationError) as error: raise AcceptanceError("RECOVERY_REQUIRED", "Stored proposal is unavailable") from error if stored_proposal.proposal_id != validated_id or stored_proposal.revision != revision: raise AcceptanceError("RECOVERY_REQUIRED", "Stored proposal identity is invalid") if stored_proposal.proposer_id != actor.principal_id: raise AcceptanceError("PROPOSAL_NOT_FOUND", "Proposal status is unavailable") receipt = self.receipt(validated_id, revision) if receipt is not None: return ProposalStatus( proposal_id=validated_id, revision=revision, lifecycle="accepted", validation_status="eligible", eligible_for_auto_accept=True, findings=receipt.findings, receipt=receipt, canonical_revision=receipt.commit_id, ) journal = self._journal_directory(validated_id, revision) if journal.is_symlink() or not journal.exists(): return ProposalStatus(proposal_id=validated_id, revision=revision, lifecycle="staged") if not journal.is_dir(): raise AcceptanceError("RECOVERY_REQUIRED", "Proposal journal is unavailable") events = [path for path in journal.glob("*.json") if path.stem.isdigit()] if len(events) > 1_000 or any(path.is_symlink() for path in events): raise AcceptanceError("RECOVERY_REQUIRED", "Proposal journal exceeds its limit") if not events: return ProposalStatus(proposal_id=validated_id, revision=revision, lifecycle="staged") latest = max(events, key=lambda path: int(path.stem)) try: if latest.is_symlink() or not latest.is_file(): raise ValueError("Journal event is unavailable") raw_event = latest.read_bytes() if len(raw_event) > 64_000: raise ValueError("Journal event exceeds its limit") event = json.loads(raw_event) except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as error: raise AcceptanceError("RECOVERY_REQUIRED", "Proposal journal is unavailable") from error if ( not isinstance(event, dict) or event.get("proposal_id") != validated_id or event.get("revision") != revision ): raise AcceptanceError("RECOVERY_REQUIRED", "Proposal journal identity is invalid") lifecycle = event.get("status") if not isinstance(lifecycle, str) or lifecycle not in { "staged", "evaluated", "publishing", "published-unreconciled", "reconciled", "withdrawn", }: raise AcceptanceError("RECOVERY_REQUIRED", "Proposal journal state is invalid") if lifecycle == "reconciled": raise AcceptanceError("RECOVERY_REQUIRED", "Accepted proposal receipt is unavailable") validation_status = event.get("validation_status") eligible = event.get("eligible_for_auto_accept") findings_raw = event.get("findings", []) if ( ( validation_status is not None and ( not isinstance(validation_status, str) or validation_status not in {"eligible", "needs-review", "invalid", "conflict"} ) ) or (eligible is not None and type(eligible) is not bool) or (not isinstance(findings_raw, list) or len(findings_raw) > 200) ): raise AcceptanceError("RECOVERY_REQUIRED", "Proposal journal data is invalid") try: findings = tuple(Finding.model_validate(item, strict=True) for item in findings_raw) except ValidationError as error: raise AcceptanceError("RECOVERY_REQUIRED", "Proposal findings are invalid") from error return ProposalStatus( proposal_id=validated_id, revision=revision, lifecycle=cast( Literal[ "staged", "evaluated", "publishing", "published-unreconciled", "accepted", "withdrawn", ], lifecycle, ), validation_status=cast( Literal["eligible", "needs-review", "invalid", "conflict"] | None, validation_status, ), eligible_for_auto_accept=eligible, findings=findings, ) def withdraw_proposal( self, proposal_id: str, revision: int, *, actor: ActorContext ) -> ProposalStatus: """Withdraw an unpublished proposal atomically against the canonical writer.""" try: validated_id = TypeAdapter(RecordId).validate_python(proposal_id, strict=True) except ValidationError as error: raise AcceptanceError("PROPOSAL_NOT_FOUND", "Proposal is unavailable") from error if type(revision) is not int or revision < 1 or revision > 2**31 - 1: raise AcceptanceError("PROPOSAL_NOT_FOUND", "Proposal is unavailable") with self._thread_lock: try: with portalocker.Lock(self._lock_path, mode="a", timeout=0): self._reconcile_pending() proposal_path = self._proposal_path(validated_id, revision) if proposal_path.is_symlink() or not proposal_path.is_file(): raise AcceptanceError("PROPOSAL_NOT_FOUND", "Proposal is unavailable") try: raw_proposal = proposal_path.read_bytes() if len(raw_proposal) > 1_000_000: raise ValueError("Proposal record exceeds its storage limit") stored_proposal = ProposalRevision.model_validate_json( raw_proposal, strict=True ) except (OSError, ValueError, ValidationError) as error: raise AcceptanceError( "RECOVERY_REQUIRED", "Stored proposal is unavailable" ) from error if ( stored_proposal.proposal_id != validated_id or stored_proposal.revision != revision or stored_proposal.proposer_id != actor.principal_id ): raise AcceptanceError("PROPOSAL_NOT_FOUND", "Proposal is unavailable") existing_receipt = self.receipt(validated_id, revision) if existing_receipt is not None: raise AcceptanceError( "PROPOSAL_ALREADY_ACCEPTED", "Accepted proposals cannot be withdrawn" ) status = self.proposal_status(validated_id, revision, actor=actor) if status.lifecycle == "withdrawn": return status if not self.actor_is_current(actor): raise AcceptanceError( "UNAUTHORIZED", "Actor credential is no longer active" ) self._write_journal( stored_proposal, { "status": "withdrawn", "payload_digest": stored_proposal.payload_digest, }, ) return self.proposal_status(validated_id, revision, actor=actor) except portalocker.exceptions.LockException as error: raise AcceptanceError( "BUSY", "Canonical writer is handling another proposal" ) from error def recover(self) -> None: """Reconcile publication and receipt writes before serving new mutations.""" with self._thread_lock: try: with portalocker.Lock(self._lock_path, mode="a", timeout=0): self._reconcile_pending() except portalocker.exceptions.LockException as error: raise AcceptanceError( "BUSY", "Canonical writer recovery is already running" ) from error def _submit_locked( self, proposal: ProposalRevision, actor: ActorContext, staged_extractions: Mapping[str, ExtractionRecord], ) -> SubmissionResult: proposal_path = self._proposal_path(proposal.proposal_id, proposal.revision) payload = _json_record(proposal) if proposal_path.exists(): if proposal_path.is_symlink() or proposal_path.read_bytes() != payload: raise AcceptanceError( "IDEMPOTENCY_CONFLICT", "Proposal ID and revision already bind different content", ) else: self._atomic_write(proposal_path, payload) existing_receipt = self.receipt(proposal.proposal_id, proposal.revision) if existing_receipt: if existing_receipt.payload_digest != proposal.payload_digest: raise AcceptanceError("IDEMPOTENCY_CONFLICT", "Proposal retry changed its content") return SubmissionResult( validation=ValidationReport( status="accepted", eligible_for_auto_accept=True, findings=(), affected_policy_ids=(), ), receipt=existing_receipt, ) prior_status = self.proposal_status( proposal.proposal_id, proposal.revision, actor=actor, ) if prior_status.lifecycle == "withdrawn": raise AcceptanceError("PROPOSAL_WITHDRAWN", "Withdrawn proposals cannot be submitted") base = self.repository.head() context = self.context_provider() if staged_extractions: context = replace( context, extractions={**context.extractions, **staged_extractions}, ) validation = validate_proposal( proposal, projects=dict(context.projects), policies=dict(context.policies), sources=dict(context.sources), extractions=dict(context.extractions), existing_nodes=dict(context.existing_nodes), current_base_commit=base, readable_project_ids=actor.readable_project_ids, ) self._write_journal( proposal, { "status": "evaluated", "payload_digest": proposal.payload_digest, "expected_head": base, "validation_status": validation.status, "eligible_for_auto_accept": validation.eligible_for_auto_accept, "findings": [item.model_dump(mode="json") for item in validation.findings], }, ) owner_challenge_ids: tuple[str, ...] = () owner_decisions: tuple[OwnerDecisionReference, ...] = () if self._policy_exception_only(validation): review_payload = self._owner_review_payload(proposal, context, validation, base) if review_payload is not None: diff_json, diff_digest, domains, owner_domains = review_payload owner_challenge_ids = self._issue_owner_challenges( proposal, diff_json=diff_json, diff_digest=diff_digest, domains=domains, owner_domains=owner_domains, ) owner_decisions = self._authorized_owner_decisions( proposal, diff_digest=diff_digest, domains=domains, owner_domains=owner_domains, ) if not validation.eligible_for_auto_accept and not owner_decisions: return SubmissionResult( validation=validation, receipt=None, owner_challenge_ids=owner_challenge_ids, ) if not self.actor_is_current(actor): raise AcceptanceError("UNAUTHORIZED", "Actor authority changed before publication") if proposal.base_commit != base: return SubmissionResult( validation=validate_proposal( proposal, projects=dict(context.projects), policies=dict(context.policies), sources=dict(context.sources), extractions=dict(context.extractions), existing_nodes=dict(context.existing_nodes), current_base_commit=base, readable_project_ids=actor.readable_project_ids, ), receipt=None, ) # Re-read authority and policies immediately before preparing canonical state. fresh_context = self.context_provider() if staged_extractions: fresh_context = replace( fresh_context, extractions={**fresh_context.extractions, **staged_extractions}, ) fresh_validation = validate_proposal( proposal, projects=dict(fresh_context.projects), policies=dict(fresh_context.policies), sources=dict(fresh_context.sources), extractions=dict(fresh_context.extractions), existing_nodes=dict(fresh_context.existing_nodes), current_base_commit=self.repository.head(), readable_project_ids=actor.readable_project_ids, ) files = self._canonical_files(proposal, fresh_context) changed_files = { path: content for path, content in files.items() if self.repository.read_file(base, path) != content } fresh_owner_decisions: tuple[OwnerDecisionReference, ...] = () if self._policy_exception_only(fresh_validation): fresh_payload = self._owner_review_payload( proposal, fresh_context, fresh_validation, base ) if fresh_payload is not None: _diff_json, fresh_diff_digest, fresh_domains, fresh_owner_domains = fresh_payload fresh_owner_decisions = self._authorized_owner_decisions( proposal, diff_digest=fresh_diff_digest, domains=fresh_domains, owner_domains=fresh_owner_domains, ) if not self.actor_is_current(actor) or not ( fresh_validation.eligible_for_auto_accept or fresh_owner_decisions ): raise AcceptanceError( "POLICY_CHANGED", "Proposal or owner authority changed before publication" ) if not changed_files: raise AcceptanceError( "NO_CANONICAL_CHANGE", "Proposal would not change canonical records" ) bindings = tuple( sorted( ( PolicyBinding( project_id=policy.project_id, policy_id=policy.policy_id, policy_version=policy.policy_version, ) for policy in fresh_context.policies.values() if policy.policy_id in fresh_validation.affected_policy_ids ), key=lambda binding: binding.project_id, ) ) manifest_path = self._manifest_path(proposal.proposal_id, proposal.revision) manifest = AcceptanceManifest( proposal_id=proposal.proposal_id, proposal_revision=proposal.revision, payload_digest=proposal.payload_digest, before_revision=base, changed_files=tuple( ChangedFile(path=path, content_sha256=hashlib.sha256(content).hexdigest()) for path, content in sorted(changed_files.items()) ), accepted_node_ids=tuple( operation.node.node_id for operation in proposal.operations if operation.node is not None ), actor_id=actor.principal_id, credential_id=actor.credential_id, policy_bindings=bindings, owner_decisions=fresh_owner_decisions, decided_at=datetime.now(UTC), ) manifest_bytes = _yaml_record(manifest) commit_files = {**changed_files, manifest_path: manifest_bytes} self._write_journal( proposal, { "status": "publishing", "payload_digest": proposal.payload_digest, "expected_head": base, "manifest_path": manifest_path, "manifest_sha256": hashlib.sha256(manifest_bytes).hexdigest(), }, ) self.failure_hook("before_commit") try: commit_id = self.repository.publish_files( expected_head=base, files=commit_files, message=f"Accept proposal {proposal.proposal_id} revision {proposal.revision}", index_directory=self.publishing_root, ) except CanonicalConflictError as error: raise AcceptanceError( "BASE_CONFLICT", "Canonical repository changed during publication" ) from error except CanonicalRepositoryError as error: raise AcceptanceError( "CANONICAL_WRITE_FAILED", "Canonical publication failed" ) from error self.failure_hook("after_ref_update") self._write_journal( proposal, { "status": "published-unreconciled", "payload_digest": proposal.payload_digest, "expected_head": base, "manifest_path": manifest_path, "manifest_sha256": hashlib.sha256(manifest_bytes).hexdigest(), "commit_id": commit_id, }, ) self.failure_hook("before_receipt") receipt = self._reconcile_manifest(manifest_path, proposal.payload_digest) self._write_journal( proposal, { "status": "reconciled", "payload_digest": proposal.payload_digest, "manifest_path": manifest_path, "receipt_sha256": hashlib.sha256(_yaml_record(receipt)).hexdigest(), }, ) return SubmissionResult( validation=fresh_validation, receipt=receipt, owner_challenge_ids=owner_challenge_ids, ) @staticmethod def _policy_exception_only(validation: ValidationReport) -> bool: return validation.status == "needs-review" and { finding.code for finding in validation.findings } == {"AUTO_ACCEPT_UNSPECIFIED"} def _owner_review_payload( self, proposal: ProposalRevision, context: ValidationContext, validation: ValidationReport, base: str, ) -> tuple[str, str, tuple[str, ...], dict[str, tuple[str, ...]]] | None: policies = tuple( sorted( ( policy for policy in context.policies.values() if policy.policy_id in validation.affected_policy_ids ), key=lambda policy: (policy.project_id, policy.policy_id), ) ) domains = tuple(sorted({policy.project_id for policy in policies})) owner_domains: dict[str, list[str]] = {} for domain in domains: project = context.projects.get(domain) if project is None or not project.owners: return None for owner in project.owners: owner_domains.setdefault(owner, []).append(domain) files = self._canonical_files(proposal, context) changes = [] for path, content in sorted(files.items()): before = self.repository.read_file(base, path) if before == content: continue try: before_text = before.decode("utf-8") if before is not None else None after_text = content.decode("utf-8") except UnicodeDecodeError: return None changes.append({"path": path, "before": before_text, "after": after_text}) if not changes: return None payload = { "proposal_id": proposal.proposal_id, "proposal_revision": proposal.revision, "payload_digest": proposal.payload_digest, "domains": domains, "owners_by_domain": {domain: context.projects[domain].owners for domain in domains}, "policy_bindings": [ { "project_id": policy.project_id, "policy_id": policy.policy_id, "policy_version": policy.policy_version, } for policy in policies ], "files": changes, } diff_json = canonical_json(payload).decode("utf-8") if len(diff_json.encode("utf-8")) > 512_000: return None return ( diff_json, hashlib.sha256(diff_json.encode("utf-8")).hexdigest(), domains, {owner: tuple(sorted(items)) for owner, items in owner_domains.items()}, ) def _issue_owner_challenges( self, proposal: ProposalRevision, *, diff_json: str, diff_digest: str, domains: tuple[str, ...], owner_domains: dict[str, tuple[str, ...]], ) -> tuple[str, ...]: if self.owner_challenge_issuer is None: return () issued: list[str] = [] covered: set[str] = set() now = int(datetime.now(UTC).timestamp()) for owner, owned_domains in sorted(owner_domains.items()): challenge_id = ( "owner-" + hashlib.sha256( f"{proposal.proposal_id}:{proposal.revision}:{owner}:{diff_digest}".encode() ).hexdigest()[:40] ) try: self.owner_challenge_issuer( challenge_id=challenge_id, owner_principal_id=owner, proposal_id=proposal.proposal_id, proposal_revision=proposal.revision, payload_digest=proposal.payload_digest, diff_digest=diff_digest, domains=owned_domains, expires_at=now + 300, diff_json=diff_json, now=now, ) except PermissionError: continue issued.append(challenge_id) covered.update(owned_domains) return tuple(issued) if set(domains) <= covered else () def _authorized_owner_decisions( self, proposal: ProposalRevision, *, diff_digest: str, domains: tuple[str, ...], owner_domains: dict[str, tuple[str, ...]], ) -> tuple[OwnerDecisionReference, ...]: if self.owner_decision_lookup is None: return () records = self.owner_decision_lookup( proposal_id=proposal.proposal_id, proposal_revision=proposal.revision, payload_digest=proposal.payload_digest, diff_digest=diff_digest, ) references: list[OwnerDecisionReference] = [] covered: set[str] = set() for record in records: owner = record.get("owner_principal_id") record_domains = record.get("domains") decision_digest = record.get("decision_digest") grant_version = record.get("grant_version") rationale = record.get("rationale") owner_session_id = record.get("owner_session_id") decided_at = record.get("decided_at") challenge_id = record.get("challenge_id") if ( record.get("decision") != "authorize" or not isinstance(owner, str) or not isinstance(record_domains, tuple) or not isinstance(decision_digest, str) or not isinstance(grant_version, int) or not isinstance(rationale, str) or not isinstance(owner_session_id, str) or not isinstance(decided_at, int) or not isinstance(challenge_id, str) or not set(record_domains) <= set(owner_domains.get(owner, ())) ): continue references.append( OwnerDecisionReference( challenge_id=challenge_id, owner_principal_id=owner, owner_session_id=owner_session_id, decision_digest=decision_digest, domains=record_domains, grant_version=grant_version, rationale=rationale, decided_at=datetime.fromtimestamp(decided_at, UTC), ) ) covered.update(record_domains) return tuple(references) if set(domains) <= covered else () def _canonical_files( self, proposal: ProposalRevision, context: ValidationContext, ) -> dict[str, bytes]: files: dict[str, bytes] = {} referenced_extractions: set[str] = set() for operation in proposal.operations: if operation.source is not None: source = operation.source if source.archive_status == "archived": self._verify_artifact(source) files[self._source_path(source)] = _yaml_record(source) if operation.node is not None: node = operation.node files[self._node_path(node)] = _yaml_record(node) referenced_extractions.update( evidence.extraction_id for claim in node.evidence_claims for evidence in claim.evidence ) if operation.edge is not None: files[self._edge_path(operation.edge)] = _yaml_record(operation.edge) referenced_extractions.update( evidence.extraction_id for evidence in operation.edge.evidence ) for extraction_id in referenced_extractions: extraction = context.extractions.get(extraction_id) if extraction is None: raise AcceptanceError("EXTRACTION_MISMATCH", "Cited extraction is unavailable") files[f"extractions/{extraction_id}.json"] = _json_record(extraction) return files def _verify_artifact(self, source: SourceRevisionRecord) -> None: if source.artifact_sha256 is None: raise AcceptanceError( "SOURCE_ARTIFACT_MISSING", "Archived source artifact is unavailable" ) try: with self.artifact_store.open_artifact(source.artifact_sha256) as stream: digest = hashlib.sha256() byte_length = 0 while block := stream.read(1024 * 1024): digest.update(block) byte_length += len(block) except UploadError as error: raise AcceptanceError( "SOURCE_ARTIFACT_MISSING", "Source artifact is unavailable" ) from error if byte_length != source.byte_length or digest.hexdigest() != source.artifact_sha256: raise AcceptanceError( "SOURCE_ARTIFACT_MISMATCH", "Source artifact failed integrity checks" ) def _reconcile_pending(self) -> None: for journal_directory in sorted(self.journal_root.iterdir()): if journal_directory.is_symlink() or not journal_directory.is_dir(): continue journal_events = sorted(journal_directory.glob("*.json")) if not journal_events: continue journal_path = journal_events[-1] try: entry = json.loads(journal_path.read_text(encoding="utf-8")) if not isinstance(entry, dict) or entry.get("status") not in { "publishing", "published-unreconciled", }: continue manifest_path = entry.get("manifest_path") payload_digest = entry.get("payload_digest") if not isinstance(manifest_path, str) or not isinstance(payload_digest, str): raise ValueError("journal binding missing") if self.repository.read_file(self.repository.head(), manifest_path) is None: if entry.get("status") == "published-unreconciled": raise ValueError("published manifest missing from canonical history") self._append_journal_event(journal_directory, {**entry, "status": "staged"}) continue expected_hash = entry.get("manifest_sha256") receipt = self._reconcile_manifest( manifest_path, payload_digest, expected_manifest_sha256=expected_hash if isinstance(expected_hash, str) else None, ) self._append_journal_event( journal_directory, { **entry, "status": "reconciled", "commit_id": receipt.commit_id, "receipt_sha256": hashlib.sha256(_yaml_record(receipt)).hexdigest(), }, ) except (OSError, ValueError, KeyError, yaml.YAMLError, ValidationError) as error: raise AcceptanceError( "RECOVERY_REQUIRED", "Canonical publication requires operator reconciliation" ) from error def _reconcile_manifest( self, manifest_path: str, payload_digest: str, *, expected_manifest_sha256: str | None = None, ) -> ProposalReceipt: head = self.repository.head() raw = self.repository.read_file(head, manifest_path) if raw is None: raise AcceptanceError( "RECOVERY_REQUIRED", "Acceptance manifest is not on the canonical branch" ) manifest = safe_yaml_record(raw, AcceptanceManifest) if manifest.payload_digest != payload_digest: raise AcceptanceError( "RECOVERY_REQUIRED", "Acceptance manifest digest does not match proposal" ) manifest_sha256 = hashlib.sha256(raw).hexdigest() if expected_manifest_sha256 and not secrets.compare_digest( manifest_sha256, expected_manifest_sha256 ): raise AcceptanceError( "RECOVERY_REQUIRED", "Manifest hash does not match publication journal" ) for changed in manifest.changed_files: stored = self.repository.read_file(head, changed.path) if stored is None or not secrets.compare_digest( hashlib.sha256(stored).hexdigest(), changed.content_sha256 ): raise AcceptanceError( "RECOVERY_REQUIRED", "Accepted record failed manifest verification" ) commit_id = self.repository.introducing_commit(head, manifest_path) if commit_id is None: raise AcceptanceError( "RECOVERY_REQUIRED", "Acceptance manifest has no introducing commit" ) receipt = ProposalReceipt( proposal_id=manifest.proposal_id, revision=manifest.proposal_revision, payload_digest=manifest.payload_digest, state=ProposalState.ACCEPTED, outcome="accepted", commit_id=commit_id, accepted_node_ids=manifest.accepted_node_ids, findings=(), decided_at=manifest.decided_at, ) receipt_path = self._receipt_path(manifest.proposal_id, manifest.proposal_revision) serialized = _yaml_record(receipt) if receipt_path.exists() and receipt_path.read_bytes() != serialized: raise AcceptanceError( "RECOVERY_REQUIRED", "Existing receipt conflicts with acceptance manifest" ) if not receipt_path.exists(): self._atomic_write(receipt_path, serialized) if self.repository.read_file(commit_id, manifest_path) is None: raise AcceptanceError( "RECOVERY_REQUIRED", "Manifest was not introduced by an accepted commit" ) return receipt def _write_journal(self, proposal: ProposalRevision, values: dict[str, object]) -> None: payload = { "proposal_id": proposal.proposal_id, "revision": proposal.revision, **values, } self._append_journal_event( self._journal_directory(proposal.proposal_id, proposal.revision), payload ) def _append_journal_event(self, directory: Path, payload: dict[str, object]) -> None: directory.mkdir(parents=True, exist_ok=True, mode=0o700) if directory.is_symlink(): raise AcceptanceError("PRIVATE_STORE_UNAVAILABLE", "Workflow storage is unavailable") event_paths = [path for path in directory.glob("*.json") if path.stem.isdigit()] sequence = 1 + max((int(path.stem) for path in event_paths), default=0) self._atomic_write(directory / f"{sequence:08d}.json", canonical_json(payload)) @staticmethod def _atomic_write(path: Path, content: bytes) -> None: path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) if path.parent.is_symlink() or path.is_symlink(): raise AcceptanceError("PRIVATE_STORE_UNAVAILABLE", "Workflow storage is unavailable") path.parent.chmod(0o700) temporary = path.with_name(f".{path.name}.{secrets.token_hex(12)}.tmp") try: with temporary.open("xb") as stream: stream.write(content) stream.flush() os.fsync(stream.fileno()) os.replace(temporary, path) if os.name != "nt": descriptor = os.open(path.parent, os.O_RDONLY) try: os.fsync(descriptor) finally: os.close(descriptor) finally: temporary.unlink(missing_ok=True) def _proposal_path(self, proposal_id: str, revision: int) -> Path: return self.proposals_root / _safe_key(proposal_id) / f"{revision}.json" def _journal_directory(self, proposal_id: str, revision: int) -> Path: return self.journal_root / f"{_safe_key(proposal_id)}-{revision}" def _receipt_path(self, proposal_id: str, revision: int) -> Path: return self.receipts_root / _safe_key(proposal_id) / f"{revision}.yaml" @staticmethod def _manifest_path(proposal_id: str, revision: int) -> str: return f"acceptance/{_safe_key(proposal_id)}/{revision}.yaml" @staticmethod def _source_path(source: SourceRevisionRecord) -> str: return f"sources/{_safe_key(source.source_id)}/{_safe_key(source.revision_id)}.yaml" @staticmethod def _node_path(node: KnowledgeNode) -> str: return f"projects/{_safe_key(node.project_id)}/nodes/{_safe_key(node.node_id)}.yaml" @staticmethod def _edge_path(edge: TypedEdge) -> str: return f"edges/{_safe_key(edge.edge_id)}.yaml"