Files
GEWU/src/gewu/application/acceptance.py
T

1006 lines
42 KiB
Python
Raw Normal View History

"""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"