orchestrata_ultimate.kernel.audit.writers

Reference › orchestrata_ultimate › kernel › audit › writers

The storage backends of the audit trail, SPEC-001 story US-008 (REQ-001-US-008-011 to -018). AuditLogWriter is the abstract base with write, write_batch and close; the logger holds a list of writers and sends each entry (see model) to all of them. The curated kernel page names the graph writer Neo4jAuditWriter; in this source the class is GraphAuditWriter.

Each writer holds its own lock, and the logger treats a writer failure as a logged error, not an exception.

At a glance

Source src/orchestrata_ultimate/kernel/audit/writers.py
Lines of code 381
Classes 3
Functions 0
Imports (internal) orchestrata_ultimate.core.base.traces, orchestrata_ultimate.core.graph, orchestrata_ultimate.kernel.audit._common, orchestrata_ultimate.kernel.audit.model, orchestrata_ultimate.knowledge_graph.canonical_schema
Imported by orchestrata_ultimate.kernel.audit, orchestrata_ultimate.kernel.audit.logger
Tests that import it none

Class diagram

Class diagram orchestrata_ultimate.kernel.audit.writers

Dependency diagram

Dependency diagram orchestrata_ultimate.kernel.audit.writers

API

Classes

AuditLogWriter

Decorators: traces(spec='001', story='US-008', reqs=['REQ-001-US-008-018']) · Line 36

Base class for audit log writers (REQ-008-020).

Method Signature Summary
write async write(entry: AuditEntry) -> bool Write an audit entry.
write_batch async write_batch(entries: list[AuditEntry]) -> int Write multiple entries.
close async close() -> None Close the writer and release resources.

FileAuditWriter

Bases: AuditLogWriter · Decorators: traces(spec='001', story='US-008', reqs=['REQ-001-US-008-012', 'REQ-001-US-008-013', 'REQ-001-US-008-014', 'REQ-001-US-… · Line 71

File-based audit log writer (REQ-001-US-008-012).

Method Signature Summary
__init__ __init__(config: AuditLogConfig) -> None
_get_current_log_path _get_current_log_path() -> Path Get path for current log file.
_should_rotate _should_rotate() -> bool Check if log rotation is needed.
_rotate_log async _rotate_log() -> None Rotate the current log file.
_compress_file async _compress_file(path: Path) -> None Compress a log file with gzip.
_cleanup_old_logs async _cleanup_old_logs() -> None Remove logs older than retention period (REQ-001-US-008-015).
_mask_sensitive_data _mask_sensitive_data(data: dict[str, Any]) -> dict[str, Any] Mask sensitive fields in data (REQ-001-US-008-016).
write async write(entry: AuditEntry) -> bool Write an audit entry to file.
close async close() -> None Close file handles.

GraphAuditWriter

Bases: AuditLogWriter · Decorators: traces(spec='001', story='US-008', reqs=['REQ-001-US-008-011', 'REQ-001-US-010-013']) · Line 237

Graph-based audit log writer (REQ-001-US-008-011).

Method Signature Summary
__init__ __init__(config: AuditLogConfig) -> None
_get_repo async _get_repo() -> AsyncGraphRepository | None Get or create the owned async graph repository.
write async write(entry: AuditEntry) -> bool Write an audit entry to graph asynchronously.
close async close() -> None Close graph driver asynchronously.

Source

Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/a4cbc2bdf/src/orchestrata_ultimate/kernel/audit/writers.py

# mypy: ignore-errors
"""Audit log writers: base, file-based, and graph-based destinations."""

import asyncio
import gzip
import json
import shutil
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any

from orchestrata_ultimate.core.base.traces import traces
from orchestrata_ultimate.core.graph import (
    AsyncGraphRepository,
    GraphRepositoryConfig,
    create_async_graph_repository,
)
from orchestrata_ultimate.kernel.audit._common import (
    GRAPH_DRIVER_AVAILABLE,
    _logger,
    _utc_now,
)
from orchestrata_ultimate.kernel.audit.model import AuditEntry, AuditLogConfig
from orchestrata_ultimate.knowledge_graph.canonical_schema import (
    LABEL_SESSION,
    LABEL_VOLK,
    REL_BY_VOLK,
)


@traces(
    spec="001",
    story="US-008",
    reqs=["REQ-001-US-008-018"],
)
class AuditLogWriter:
    """Base class for audit log writers (REQ-008-020)."""

    @traces(
        spec="001",
        story="US-008",
        reqs=["REQ-001-US-008-018"],
    )
    async def write(self, entry: AuditEntry) -> bool:
        """Write an audit entry. Returns True on success."""
        raise NotImplementedError

    async def write_batch(self, entries: list[AuditEntry]) -> int:
        """Write multiple entries. Returns count of successful writes."""
        count = 0
        for entry in entries:
            if await self.write(entry):
                count += 1
        return count

    async def close(self) -> None:
        """Close the writer and release resources."""


@traces(
    spec="001",
    story="US-008",
    reqs=[
        "REQ-001-US-008-012",
        "REQ-001-US-008-013",
        "REQ-001-US-008-014",
        "REQ-001-US-008-015",
        "REQ-001-US-008-016",
    ],
)
class FileAuditWriter(AuditLogWriter):
    """File-based audit log writer (REQ-001-US-008-012).

    Handles log rotation (REQ-001-US-008-013), compression (REQ-001-US-008-014),
    and retention policies (REQ-001-US-008-015).
    """

    @traces(spec="001", story="US-001-001", reqs=["REQ-001-US-008-012"])
    def __init__(self, config: AuditLogConfig) -> None:
        self.config = config
        self._lock = asyncio.Lock()
        self._current_file: Path | None = None
        self._current_size = 0
        self._file_handle = None

        # Ensure log directory exists
        self.config.log_dir.mkdir(parents=True, exist_ok=True)

    @traces(spec="001", story="US-001-001", reqs=["REQ-001-US-008-012"])
    def _get_current_log_path(self) -> Path:
        """Get path for current log file."""
        date_str = _utc_now().strftime("%Y-%m-%d")
        return self.config.log_dir / f"audit_{date_str}.jsonl"

    @traces(spec="001", story="US-008", reqs=["REQ-001-US-008-013"])
    def _should_rotate(self) -> bool:
        """Check if log rotation is needed."""
        if self._current_file is None:
            return False

        # Check file size
        if self._current_size >= self.config.max_file_size_mb * 1024 * 1024:
            return True

        # Check if date changed
        current_path = self._get_current_log_path()
        if current_path != self._current_file:
            return True

        return False

    @traces(spec="001", story="US-008", reqs=["REQ-001-US-008-013"])
    async def _rotate_log(self) -> None:
        """Rotate the current log file."""
        if self._file_handle:
            self._file_handle.close()
            self._file_handle = None

        if self._current_file and self._current_file.exists():
            # Rename with timestamp
            timestamp = _utc_now().strftime("%Y%m%d_%H%M%S")
            archive_name = f"{self._current_file.stem}_{timestamp}.jsonl"
            archive_path = self._current_file.parent / archive_name

            shutil.move(str(self._current_file), str(archive_path))

            # Compress if configured (REQ-001-US-008-014)
            if self.config.compress_archives:
                await self._compress_file(archive_path)

        self._current_file = None
        self._current_size = 0

    @traces(spec="001", story="US-008", reqs=["REQ-001-US-008-014"])
    async def _compress_file(self, path: Path) -> None:
        """Compress a log file with gzip."""
        compressed_path = path.with_suffix(".jsonl.gz")

        @traces(spec="001", story="US-008", reqs=["REQ-001-US-008-014"])
        def compress() -> None:
            with open(path, "rb") as f_in:
                with gzip.open(compressed_path, "wb") as f_out:
                    shutil.copyfileobj(f_in, f_out)
            path.unlink()

        loop = asyncio.get_event_loop()
        await loop.run_in_executor(None, compress)

    @traces(spec="001", story="US-008", reqs=["REQ-001-US-008-015"])
    async def _cleanup_old_logs(self) -> None:
        """Remove logs older than retention period (REQ-001-US-008-015)."""
        cutoff = _utc_now() - timedelta(days=self.config.retention_days)

        for log_file in self.config.log_dir.glob("audit_*.jsonl*"):
            try:
                stat = log_file.stat()
                file_time = datetime.fromtimestamp(stat.st_mtime, tz=UTC)
                if file_time < cutoff:
                    log_file.unlink()
                    _logger.info(f"Deleted old audit log: {log_file}")
            except OSError as e:
                _logger.error(f"Error cleaning up {log_file}: {e}")

    @traces(spec="001", story="US-008", reqs=["REQ-001-US-008-016"])
    def _mask_sensitive_data(self, data: dict[str, Any]) -> dict[str, Any]:
        """Mask sensitive fields in data (REQ-001-US-008-016)."""
        sensitive_keys = {"password", "token", "secret", "key", "credential"}
        masked = {}

        for key, value in data.items():
            if any(s in key.lower() for s in sensitive_keys):
                masked[key] = "***REDACTED***"
            elif isinstance(value, dict):
                masked[key] = self._mask_sensitive_data(value)
            else:
                masked[key] = value

        return masked

    @traces(
        spec="001",
        story="US-008",
        reqs=["REQ-001-US-008-012", "REQ-001-US-008-018"],
    )
    async def write(self, entry: AuditEntry) -> bool:
        """Write an audit entry to file."""
        async with self._lock:
            try:
                # Check rotation
                if self._should_rotate():
                    await self._rotate_log()

                # Get current log path
                log_path = self._get_current_log_path()

                # Open file if needed
                if self._current_file != log_path:
                    if self._file_handle:
                        self._file_handle.close()  # pragma: no cover
                    self._current_file = log_path
                    self._file_handle = open(log_path, "a", encoding="utf-8")
                    self._current_size = (
                        log_path.stat().st_size if log_path.exists() else 0
                    )

                # Prepare entry
                entry_dict = entry.to_dict()
                if self.config.encrypt_sensitive:
                    entry_dict["data"] = self._mask_sensitive_data(
                        entry_dict.get("data", {})
                    )

                # Write entry
                line = json.dumps(entry_dict) + "\n"
                # Invariant: the branch above always opens a handle before
                # the first write (self._current_file starts as None and
                # log_path is never None), so it is open by this point.
                assert self._file_handle is not None
                self._file_handle.write(line)
                self._file_handle.flush()
                self._current_size += len(line.encode("utf-8"))

                return True

            except OSError as e:
                _logger.error(f"Failed to write audit entry to file: {e}")
                return False

    async def close(self) -> None:
        """Close file handles."""
        if self._file_handle:
            self._file_handle.close()
            self._file_handle = None


@traces(spec="001", story="US-008", reqs=["REQ-001-US-008-011", "REQ-001-US-010-013"])
class GraphAuditWriter(AuditLogWriter):
    """Graph-based audit log writer (REQ-001-US-008-011)."""

    @traces(spec="001", story="US-001-001", reqs=["REQ-001-US-008-011"])
    def __init__(self, config: AuditLogConfig) -> None:
        self.config = config
        self._repo: AsyncGraphRepository | None = None
        self._lock = asyncio.Lock()

    @traces(spec="001", story="US-010", reqs=["REQ-001-US-010-013"])
    async def _get_repo(self) -> AsyncGraphRepository | None:
        """Get or create the owned async graph repository."""
        if self._repo is None:
            if not GRAPH_DRIVER_AVAILABLE:
                _logger.warning("graph driver not available")
                return None

            try:
                # No credential fallback: NEO4J_PASSWORD was retired with the
                # Neo4j containers on 2026-07-27, and the literal "password"
                # it defaulted to was never the credential of any live
                # instance — it could only turn a config gap into an auth
                # failure that reads like an outage.
                _password = self.config.graph_password or ""
                repo = create_async_graph_repository(
                    GraphRepositoryConfig(
                        uri=self.config.graph_uri,
                        auth=(self.config.graph_user, _password),
                        driver_kwargs={
                            "connection_timeout": self.config.connection_timeout,
                        },
                    )
                )
                # Verify connectivity
                await repo.verify_connectivity()
                self._repo = repo
                _logger.info(f"Connected to graph at {self.config.graph_uri}")
            except Exception as e:
                _logger.error(f"Failed to connect to graph: {e}")
                self._repo = None
                return None
        return self._repo

    @traces(
        spec="001",
        story="US-008",
        reqs=["REQ-001-US-008-011", "REQ-001-US-008-018"],
    )
    async def write(self, entry: AuditEntry) -> bool:
        """Write an audit entry to graph asynchronously."""
        repo = await self._get_repo()
        if repo is None:
            return False

        async with self._lock:
            try:
                # Volk's canonical merge key is `alias` (canonical_schema
                # CANONICAL_MERGE_KEYS) -- `entry.volk_id` is supposed to
                # carry the volk's alias string (e.g. "senior_dev", "qa"; see
                # pipeline_kg.py's `MERGE (v:Volk {alias: row.alias})` and
                # gateway/handlers/projects.py's `Volk {alias: ...}` MATCH),
                # but callers have historically passed non-agent strings
                # (e.g. a protocol/schema version like "v1") through this
                # field, unconditionally MERGEing a junk `:Volk` node per bad
                # value (session-254 connectivity audit: 202 live
                # `(:AuditEntry)-[:BY_VOLK]->(:Volk {alias:'v1'})` edges).
                # This writer therefore never MINTS a Volk node itself --
                # it only OPTIONAL MATCHes an existing one (by alias or the
                # legacy `volk_id` property) and attaches BY_VOLK when found.
                #
                # Session was previously handled differently -- `MERGE
                # (s:Session {session_id: ...})` unconditionally MINTED a new
                # orphan `:Session` node whenever `entry.session_id` did not
                # match an existing one (the kernel-runtime SessionManager
                # never attaches `(:Project)-[:HAS_SESSION]->(:Session)`, so a
                # minted Session was itself unreachable from the root
                # `:Project` -- exactly the floating-subgraph failure mode
                # session-254's connectivity gate exists to catch). Session is
                # now OPTIONAL MATCHed exactly like Volk: IN_SESSION is
                # attached only when a real, already-hub-linked Session
                # exists. An AuditEntry whose volk_id AND session_id both fail
                # to resolve is still written, just with neither edge -- a
                # residual floating-node risk this writer cannot close without
                # a new Project-hub relationship for AuditEntry, which is not
                # declared in canonical_schema.py (see kernel/audit/writers.py
                # module docstring / the wiring report that shipped this
                # comment for why that vocabulary addition was deliberately
                # left to the orchestrator rather than invented here).
                # entry.session_id/volk_id are also stored as plain
                # properties on the node itself (previously ONLY used as
                # MERGE-key inputs for the edges below, never persisted) --
                # a genuine gap for any graph-side query_by_session/volk_id
                # equivalent of the file-writer's query methods, found while
                # wiring this writer into production.
                query = f"""
                CREATE (e:AuditEntry {{
                    entry_id: $entry_id,
                    event_type: $event_type,
                    timestamp: datetime($timestamp),
                    session_id: $session_id,
                    volk_id: $volk_id,
                    correlation_id: $correlation_id,
                    outcome: $outcome,
                    data: $data,
                    reason: $reason,
                    duration_ms: $duration_ms
                }})
                WITH e
                OPTIONAL MATCH (v:{LABEL_VOLK})
                WHERE v.alias = $volk_id OR v.volk_id = $volk_id
                FOREACH (_ IN CASE WHEN v IS NOT NULL THEN [1] ELSE [] END |
                    CREATE (e)-[:{REL_BY_VOLK}]->(v)
                )
                WITH e
                OPTIONAL MATCH (s:{LABEL_SESSION} {{session_id: $session_id}})
                FOREACH (_ IN CASE WHEN s IS NOT NULL THEN [1] ELSE [] END |
                    CREATE (e)-[:IN_SESSION]->(s)
                )
                """
                params = {
                    "entry_id": entry.entry_id,
                    "event_type": entry.event_type.value,
                    "timestamp": entry.timestamp.isoformat(),
                    "session_id": entry.session_id,
                    "correlation_id": entry.correlation_id,
                    "volk_id": entry.volk_id,
                    "outcome": entry.outcome.value if entry.outcome else None,
                    "data": json.dumps(entry.data),
                    "reason": entry.reason,
                    "duration_ms": entry.duration_ms,
                }
                await repo.execute_write(
                    query, params, database=self.config.graph_database
                )
                return True

            except Exception as e:
                _logger.error(f"Failed to write audit entry to graph: {e}")
                return False

    async def close(self) -> None:
        """Close graph driver asynchronously."""
        if self._repo:
            await self._repo.close()
            self._repo = None