1006 lines
42 KiB
Python
1006 lines
42 KiB
Python
"""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"
|