diff --git a/CHANGELOG.md b/CHANGELOG.md index ccd9e2a..c2fbc10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Session lifecycle invariants ([#15](https://github.com/ARPAHLS/aura/issues/15))** — strict closed-session errors (`SessionClosedError`, `SessionAlreadyOpenError`); `export=False` builds in-memory `summary` and `audit_report` on `SessionRun`; atomic summary + OTel commit on export; frozen `declared_rules` / `open_snapshot_hash` at open (runtime rule merges via skill bind still apply to constraints); `trace_id` on summary export. + - **Documentation sweep ([#14](https://github.com/ARPAHLS/aura/issues/14))** — `docs/INDEX.md` three-tier entry (Start / Build / Decide + Optional vision); demoted narrative, three-rings, aura-levels, field-services; refreshed architecture, concepts, stack-position, field-services shipped vs planned; fixed stale v0.2 voice in getting-started and concepts. ## [0.3.4] - 2026-08-26 diff --git a/aura/__init__.py b/aura/__init__.py index 8022b0a..75bf306 100644 --- a/aura/__init__.py +++ b/aura/__init__.py @@ -3,6 +3,9 @@ from aura.api import ( AgentHandle, ApprovalRequired, + ExportError, + SessionClosedError, + SessionNotOpenError, SessionRun, agent, configure, @@ -23,4 +26,7 @@ "AgentHandle", "SessionRun", "ApprovalRequired", + "SessionClosedError", + "SessionNotOpenError", + "ExportError", ] diff --git a/aura/api.py b/aura/api.py index 29f8150..5653af4 100644 --- a/aura/api.py +++ b/aura/api.py @@ -11,10 +11,11 @@ from aura.agents.profile import AgentProfile from aura.agents.registry import AgentRegistry from aura.config import configure as _configure, get_config -from aura.core.conformance import ConformanceEngine +from aura.core.conformance import ConformanceEngine, ConformanceReport from aura.core.constraints import ApprovalRequired +from aura.core.errors import ExportError, SessionClosedError, SessionNotOpenError from aura.core.session import Session, SessionMode -from aura.exporters.jsonl import export_session +from aura.exporters.jsonl import build_session_summary, export_session @dataclass @@ -23,6 +24,9 @@ class SessionRun: _session: Session exports: dict[str, str] = field(default_factory=dict) + summary: dict[str, Any] | None = None + audit_report: dict[str, Any] | None = None + conformance: ConformanceReport | None = None @property def session_id(self) -> str: @@ -32,6 +36,10 @@ def session_id(self) -> str: def aura_id(self) -> str: return self._session.profile.aura_id + @property + def trace_id(self) -> str | None: + return self._session.trace_id + def emit(self, kind: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: return self._session.emit(kind, payload) @@ -61,6 +69,26 @@ def current_session() -> SessionRun | None: return _current_run.get() +def _finalize_session_run(run: SessionRun, session: Session, *, do_export: bool) -> None: + """Build in-memory receipt; optionally commit export artifacts.""" + if not session.spine: + return + + report = ConformanceEngine().summarize( + session.spine, + session.declared_rules, + session.open_snapshot_hash, + sequencer_spec=session.sequencer_spec or session.profile.sequencer, + ) + run.conformance = report + run.summary = build_session_summary(session, conformance=report) + audit = run.summary.get("audit_report") + run.audit_report = audit if isinstance(audit, dict) else None + + if do_export: + run.exports = export_session(session, get_config().sessions_dir(), conformance=report) + + @dataclass class AgentHandle: profile: AgentProfile @@ -86,14 +114,7 @@ def session( _current_run.reset(token) session.close() do_export = export if export is not None else cfg.values.get("export_on_close", True) - if do_export and session.spine: - report = ConformanceEngine().summarize( - session.spine, - session.rules, - session.snapshot_hash, - sequencer_spec=session.sequencer_spec or session.profile.sequencer, - ) - run.exports = export_session(session, cfg.sessions_dir(), conformance=report) + _finalize_session_run(run, session, do_export=do_export) def _build_session( @@ -164,5 +185,8 @@ def list_agents(include_archived: bool = False) -> list[AgentProfile]: "AgentHandle", "SessionRun", "ApprovalRequired", + "SessionClosedError", + "SessionNotOpenError", + "ExportError", "current_session", ] diff --git a/aura/core/errors.py b/aura/core/errors.py new file mode 100644 index 0000000..f35da57 --- /dev/null +++ b/aura/core/errors.py @@ -0,0 +1,40 @@ +"""Session and export lifecycle errors.""" + + +class AuraSessionError(Exception): + """Base class for session lifecycle errors.""" + + +class SessionNotOpenError(AuraSessionError): + """Operation requires an open session.""" + + def __init__(self, session_id: str | None = None) -> None: + self.session_id = session_id + sid = session_id or "unknown" + super().__init__(f"Session not open: {sid}") + + +class SessionClosedError(AuraSessionError): + """Session is closed; further mutations are rejected.""" + + def __init__(self, session_id: str | None = None) -> None: + self.session_id = session_id + sid = session_id or "unknown" + super().__init__(f"Session already closed: {sid}") + + +class SessionAlreadyOpenError(AuraSessionError): + """Session open() was called more than once on the same handle.""" + + def __init__(self, session_id: str | None = None) -> None: + self.session_id = session_id + sid = session_id or "unknown" + super().__init__(f"Session already open: {sid}") + + +class ExportError(Exception): + """Atomic session export failed; summary and OTel artifacts were not committed.""" + + def __init__(self, session_id: str, message: str) -> None: + self.session_id = session_id + super().__init__(f"Export failed for {session_id}: {message}") diff --git a/aura/core/session.py b/aura/core/session.py index faa517f..e1c3204 100644 --- a/aura/core/session.py +++ b/aura/core/session.py @@ -2,15 +2,21 @@ from __future__ import annotations +import copy +import json +import uuid from dataclasses import dataclass, field from enum import Enum from hashlib import sha256 from pathlib import Path from typing import Any -import json -import uuid from aura.agents.profile import AgentProfile +from aura.core.errors import ( + SessionAlreadyOpenError, + SessionClosedError, + SessionNotOpenError, +) from aura.core.constraints import ( ApprovalRequired, ConstraintContext, @@ -28,6 +34,11 @@ class SessionMode(str, Enum): CONTINUOUS = "continuous" +_IMMUTABLE_AFTER_OPEN = frozenset( + {"session_id", "spine", "profile", "mode", "_declared_rules", "_open_snapshot_hash"} +) + + @dataclass class Session: """One runtime activation of an agent.""" @@ -48,11 +59,42 @@ class Session: _closed: bool = False _log_path: Path | None = None _goal_reached: bool = False + _declared_rules: list[dict[str, Any]] = field(default_factory=list) + _open_snapshot_hash: str | None = None + + def __setattr__(self, name: str, value: Any) -> None: + if ( + name + not in { + "_open", + "_closed", + "_goal_reached", + "state", + "_approved", + "_observers", + "rules", + "snapshot_hash", + } + and getattr(self, "_open", False) + and name in _IMMUTABLE_AFTER_OPEN + ): + raise AttributeError(f"{name} is immutable after session open") + object.__setattr__(self, name, value) + + def _ensure_active(self) -> None: + if self._closed: + raise SessionClosedError(self.session_id) + if not self._open or not self.spine: + raise SessionNotOpenError(self.session_id) def open(self, sessions_dir: Path) -> None: + if self._closed: + raise SessionClosedError(self.session_id) if self._open: - return + raise SessionAlreadyOpenError(self.session_id) self.snapshot_hash = _snapshot_hash(self.profile, self.rules) + self._open_snapshot_hash = self.snapshot_hash + self._declared_rules = copy.deepcopy(self.rules) self._log_path = sessions_dir / f"{self.session_id}.jsonl" self.spine = AuditSpine( session_id=self.session_id, @@ -107,7 +149,9 @@ def _attach_profile_observers(self) -> None: def close(self, reason: str = "normal") -> dict[str, Any]: if self._closed: - return self.state.get("_summary", {}) + raise SessionClosedError(self.session_id) + if not self._open: + raise SessionNotOpenError(self.session_id) if self.spine: self.emit("session.close", {"reason": reason, "goal_reached": self._goal_reached}) self._closed = True @@ -115,6 +159,9 @@ def close(self, reason: str = "normal") -> dict[str, Any]: return { "session_id": self.session_id, "log_path": str(self._log_path) if self._log_path else None, + "trace_id": self.trace_id, + "snapshot_hash": self.snapshot_hash, + "open_snapshot_hash": self.open_snapshot_hash, } def emit( @@ -124,8 +171,7 @@ def emit( *, step_id: str | None = None, ) -> dict[str, Any]: - if not self.spine: - raise RuntimeError("Session not open") + self._ensure_active() ctx = ConstraintContext( event_kind=kind, payload=dict(payload or {}), @@ -186,6 +232,7 @@ def require_approval( """Gate helper — log approval requirement and raise.""" if request_id in self._approved: return + self._ensure_active() if self.spine: self.spine.append( "constraint.approval_required", @@ -200,6 +247,7 @@ def require_approval( raise ApprovalRequired(request_id, message, rule) def approve(self, request_id: str, *, principal: str | None = None) -> None: + self._ensure_active() self._approved.add(request_id) if self.spine: payload: dict[str, Any] = {"request_id": request_id} @@ -235,6 +283,20 @@ def log_path(self) -> Path | None: def is_open(self) -> bool: return self._open and not self._closed + @property + def trace_id(self) -> str | None: + return self.spine.trace_id if self.spine else None + + @property + def declared_rules(self) -> list[dict[str, Any]]: + if self._declared_rules: + return self._declared_rules + return self.rules + + @property + def open_snapshot_hash(self) -> str | None: + return self._open_snapshot_hash or self.snapshot_hash + def _snapshot_hash(profile: AgentProfile, rules: list[dict[str, Any]]) -> str: blob = json.dumps( diff --git a/aura/exporters/__init__.py b/aura/exporters/__init__.py index 8f440e9..1a3ffa7 100644 --- a/aura/exporters/__init__.py +++ b/aura/exporters/__init__.py @@ -1,5 +1,5 @@ """Session exporters.""" -from aura.exporters.jsonl import export_session +from aura.exporters.jsonl import build_session_summary, export_session -__all__ = ["export_session"] +__all__ = ["build_session_summary", "export_session"] diff --git a/aura/exporters/jsonl.py b/aura/exporters/jsonl.py index 445ca2b..8bccb13 100644 --- a/aura/exporters/jsonl.py +++ b/aura/exporters/jsonl.py @@ -3,34 +3,35 @@ from __future__ import annotations import json +import uuid from pathlib import Path from typing import Any -from aura.core.audit_report import AuditReportBuilder +from aura.core.audit_report import AuditReport, AuditReportBuilder from aura.core.conformance import ConformanceEngine, ConformanceReport +from aura.core.errors import ExportError from aura.core.session import Session -from aura.exporters.otel import export_session_otel +from aura.exporters.otel import export_otel_jsonl +from aura.core.spine import AuditSpine -def export_session( +def build_session_summary( session: Session, - sessions_dir: Path, *, conformance: ConformanceReport | None = None, - include_otel: bool = True, -) -> dict[str, str]: - """Write summary JSON alongside existing JSONL log.""" + audit_report: AuditReport | None = None, +) -> dict[str, Any]: + """In-memory session summary (same shape as ``.summary.json`` on disk).""" if conformance is None and session.spine: engine = ConformanceEngine() conformance = engine.summarize( session.spine, - session.rules, - session.snapshot_hash, + session.declared_rules, + session.open_snapshot_hash, sequencer_spec=session.sequencer_spec or session.profile.sequencer, ) - audit_report = None - if session.spine and conformance: + if audit_report is None and session.spine and conformance: audit_report = AuditReportBuilder().build( session.spine, conformance, @@ -38,8 +39,7 @@ def export_session( policy_version=session.profile.policy_version, ) - summary_path = sessions_dir / f"{session.session_id}.summary.json" - summary: dict[str, Any] = { + return { "session_id": session.session_id, "aura_id": session.profile.aura_id, "agent_ref": session.profile.agent_ref, @@ -47,6 +47,8 @@ def export_session( "policy_version": session.profile.policy_version, "mode": session.mode.value, "snapshot_hash": session.snapshot_hash, + "open_snapshot_hash": session.open_snapshot_hash, + "trace_id": session.trace_id, "agent_ids": session.profile.id_trailer(), "purpose": session.profile.purpose, "conformance": conformance.to_dict() if conformance else None, @@ -54,13 +56,63 @@ def export_session( "event_count": len(session.spine.stream()) if session.spine else 0, "log": str(session.log_path) if session.log_path else None, } - with summary_path.open("w", encoding="utf-8") as f: - json.dump(summary, f, indent=2) - - paths: dict[str, str] = {"summary": str(summary_path)} - if session.log_path: - paths["jsonl"] = str(session.log_path) - if include_otel and session.spine: - otel_path = export_session_otel(session.session_id, sessions_dir) - paths["otel"] = str(otel_path) + + +def _atomic_replace(staging_path: Path, final_path: Path) -> None: + staging_path.replace(final_path) + + +def _write_staging_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + + +def _write_staging_otel(session_id: str, sessions_dir: Path, staging_path: Path) -> None: + log_path = sessions_dir / f"{session_id}.jsonl" + events = AuditSpine.read_jsonl(log_path) + export_otel_jsonl(events, staging_path) + + +def export_session( + session: Session, + sessions_dir: Path, + *, + conformance: ConformanceReport | None = None, + include_otel: bool = True, +) -> dict[str, str]: + """Write summary JSON and OTel JSONL atomically alongside the live JSONL log.""" + summary = build_session_summary(session, conformance=conformance) + if not session.spine: + raise ExportError(session.session_id, "session spine missing") + + if not session.log_path or not session.log_path.is_file(): + raise ExportError(session.session_id, "JSONL audit trail missing") + + token = uuid.uuid4().hex + summary_final = sessions_dir / f"{session.session_id}.summary.json" + summary_staging = sessions_dir / f"{session.session_id}.summary.json.{token}.staging" + otel_final = sessions_dir / f"{session.session_id}.otel.jsonl" + otel_staging = sessions_dir / f"{session.session_id}.otel.jsonl.{token}.staging" + staged: list[Path] = [] + + try: + _write_staging_json(summary_staging, summary) + staged.append(summary_staging) + if include_otel: + _write_staging_otel(session.session_id, sessions_dir, otel_staging) + staged.append(otel_staging) + + _atomic_replace(summary_staging, summary_final) + staged.remove(summary_staging) + if include_otel: + _atomic_replace(otel_staging, otel_final) + staged.remove(otel_staging) + except Exception as exc: + for path in staged: + path.unlink(missing_ok=True) + raise ExportError(session.session_id, str(exc)) from exc + + paths: dict[str, str] = {"summary": str(summary_final), "jsonl": str(session.log_path)} + if include_otel: + paths["otel"] = str(otel_final) return paths diff --git a/aura/runtime/python.py b/aura/runtime/python.py index 1db198d..85d0598 100644 --- a/aura/runtime/python.py +++ b/aura/runtime/python.py @@ -35,7 +35,7 @@ def run_script( finally: sys.argv = old_argv run.emit("runtime.detach", {"script": str(path)}) - return {"session_id": run.session_id, "exports": run.exports} + return {"session_id": run.session_id, "exports": run.exports, "audit_report": run.audit_report} def aura_wrapped( diff --git a/docs/concepts.md b/docs/concepts.md index 4517640..1b8f86f 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -49,7 +49,11 @@ The live, append-only record of a session. Official name for what the code calls ## Session export -What you get when a session closes: JSONL audit file + conformance **summary** JSON. Ship to logs, observability, or storage. +What you get when a session closes: JSONL audit file + conformance **summary** JSON (+ OTel JSONL by default). Ship to logs, observability, or storage. + +With `export=False`, no files are written, but `run.summary` and `run.audit_report` are still built in memory when the context exits. Summary and OTel files commit atomically on disk export; a failed export leaves neither artifact updated. + +After close, the session is sealed — further `emit` or `approve` calls raise `SessionClosedError`. ## Constitution @@ -63,7 +67,7 @@ Built-in types: `max_tokens_per_step`, `confirm_before`, `allow_tools`, `deny_to ## Conformance -On session close, AURA compares **declared rules** and **sequencer step order** vs **observed events** and writes a summary. +On session close, AURA compares **declared rules at open** (and sequencer step order) vs **observed events** and writes a summary. The open-time `open_snapshot_hash` in the summary matches conformance when base rules are unchanged; runtime skill binds may update `snapshot_hash` for live constraint checks. ## Sequencer diff --git a/docs/getting-started.md b/docs/getting-started.md index d8e9fd0..4b54549 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -45,6 +45,7 @@ with ag.session() as run: run.emit("turn.end", {"output": "world", "tokens": 50}) print(run.exports) # JSONL + summary paths +print(run.audit_report) # in-memory receipt (always built; use export=False to skip disk) ``` ## Sequencer example diff --git a/docs/outputs.md b/docs/outputs.md index 33c3ea6..4f8f9ae 100644 --- a/docs/outputs.md +++ b/docs/outputs.md @@ -16,6 +16,10 @@ Identity fields on export: [trust-paths.md](trust-paths.md). Session-close workf CLI: `aura report show `, `aura report show --json`, `aura export`, `aura export-otel`, `aura compare`, `aura verify chain `. +**Export commit:** Summary and OTel files are written to staging paths and renamed atomically on success. If export fails, neither artifact is committed (the live JSONL trail may still exist from the session). With `export=False`, the SDK still builds `run.summary` and `run.audit_report` in memory. + +**Closed session:** After close, `emit`, `approve`, and a second `close()` raise `SessionClosedError`. `session_id` and `trace_id` are fixed at open. `open_snapshot_hash` captures rules + sequencer at open for conformance; `snapshot_hash` in the summary may update when skills bind at runtime. + --- ## Audit report (summary JSON) diff --git a/docs/using-aura.md b/docs/using-aura.md index eec386b..c529a8c 100644 --- a/docs/using-aura.md +++ b/docs/using-aura.md @@ -71,6 +71,8 @@ aura verify chain ~/.aura/sessions/aura_sess_xxxxxxxxxxxx.jsonl See [outputs.md](outputs.md) for the complete artifact schema and [comparison.md](comparison.md) for comparing two summary files. If the JSONL hash chain is broken, `aura verify chain` reports the first affected event and exits with status 1. +With `export=False`, no files are written, but `run.summary` and `run.audit_report` are still populated when the context exits — use them for tests and programmatic gates. After close, further `emit` or `approve` calls raise `SessionClosedError`. + --- ## Python SDK (primary) @@ -99,6 +101,7 @@ with ag.session(mode="task") as run: | `configure()` | Merge global + project config | | `agent(name)` | Get/create agent profile | | `agent.session()` | Open session, auto-export on close | +| `run.summary` / `run.audit_report` | In-memory receipt (always built; disk write optional via `export=`) | | `run.emit(kind, payload)` | Append audited event | | `run.approve(request_id, principal="operator@corp")` | Satisfy confirm/gate and record the approver | | `run.run_sequencer(host=...)` | Run declared step pipeline | diff --git a/tests/test_session_invariants.py b/tests/test_session_invariants.py new file mode 100644 index 0000000..206fb86 --- /dev/null +++ b/tests/test_session_invariants.py @@ -0,0 +1,135 @@ +"""Session lifecycle and export invariant tests.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from aura import agent +from aura.core.errors import ExportError, SessionAlreadyOpenError, SessionClosedError +from aura.core.session import Session, SessionMode, _snapshot_hash +from aura.exporters.jsonl import export_session + + +def test_export_false_builds_in_memory_summary(aura_home: Path): + ag = agent("inv-no-export") + with ag.session(export=False) as run: + run.emit("turn.start", {}) + trace_id = run.trace_id + assert run.summary is not None + assert run.audit_report is not None + assert run.summary["session_id"] == run.session_id + assert run.summary["trace_id"] == trace_id + assert run.summary["snapshot_hash"] == run._session.snapshot_hash + assert run.summary["open_snapshot_hash"] == run._session.open_snapshot_hash + assert run.exports == {} + assert not (aura_home / "sessions" / f"{run.session_id}.summary.json").exists() + + +def test_export_true_writes_all_artifacts(aura_home: Path): + ag = agent("inv-export") + with ag.session() as run: + run.emit("turn.start", {}) + sessions = aura_home / "sessions" + sid = run.session_id + assert (sessions / f"{sid}.jsonl").is_file() + assert (sessions / f"{sid}.summary.json").is_file() + assert (sessions / f"{sid}.otel.jsonl").is_file() + assert run.summary is not None + assert run.audit_report == run.summary.get("audit_report") + + +def test_double_close_raises(aura_home: Path): + ag = agent("inv-double-close") + with ag.session(export=False) as run: + run.emit("turn.start", {}) + with pytest.raises(SessionClosedError): + run._session.close() + + +def test_emit_after_close_raises(aura_home: Path): + ag = agent("inv-emit-after") + with ag.session(export=False) as run: + run.emit("turn.start", {}) + with pytest.raises(SessionClosedError): + run.emit("turn.end", {}) + + +def test_double_open_raises(aura_home: Path): + ag = agent("inv-double-open") + session = Session(profile=ag.profile, mode=SessionMode.SCRIPT) + session.open(aura_home / "sessions") + with pytest.raises(SessionAlreadyOpenError): + session.open(aura_home / "sessions") + session.close() + + +def test_session_id_immutable_after_open(aura_home: Path): + ag = agent("inv-immutable-id") + session = Session(profile=ag.profile, mode=SessionMode.SCRIPT) + session.open(aura_home / "sessions") + with pytest.raises(AttributeError): + session.session_id = "aura_sess_override" + session.close() + + +def test_trace_id_stable_for_session(aura_home: Path): + ag = agent("inv-trace") + with ag.session(export=False) as run: + first = run.trace_id + run.emit("turn.start", {}) + assert run.trace_id == first + assert run.summary["trace_id"] == first + + +def test_snapshot_hash_matches_conformance_when_rules_unchanged(aura_home: Path): + ag = agent("inv-snapshot", rules=[{"type": "allow", "tools": ["search"]}]) + with ag.session(export=False) as run: + run.emit("turn.start", {}) + open_hash = run._session.open_snapshot_hash + assert run.conformance is not None + assert run.conformance.snapshot_hash == open_hash + assert run.summary["open_snapshot_hash"] == open_hash + + +def test_declared_rules_frozen_after_open(aura_home: Path): + ag = agent("inv-rules-freeze", rules=[{"type": "allow", "tools": ["search"]}]) + with ag.session(export=False) as run: + run.emit("turn.start", {}) + original_hash = run._session.open_snapshot_hash + run._session.rules.append({"type": "deny", "tools": ["delete"]}) + assert run._session.open_snapshot_hash == original_hash + assert len(run._session.declared_rules) == 1 + assert run.conformance is not None + assert len(run.conformance.declared_rules) == 1 + + +def test_export_atomic_on_otel_failure(aura_home: Path, monkeypatch: pytest.MonkeyPatch): + ag = agent("inv-export-fail") + with ag.session(export=False) as run: + run.emit("turn.start", {}) + + sessions = aura_home / "sessions" + sid = run.session_id + + def _boom(*_args, **_kwargs): + raise OSError("disk full") + + monkeypatch.setattr("aura.exporters.jsonl._write_staging_otel", _boom) + with pytest.raises(ExportError): + export_session(run._session, sessions) + + assert (sessions / f"{sid}.jsonl").is_file() + assert not (sessions / f"{sid}.summary.json").exists() + assert not (sessions / f"{sid}.otel.jsonl").exists() + + +def test_close_summary_includes_trace_and_snapshot(aura_home: Path): + ag = agent("inv-close-meta") + session = Session(profile=ag.profile, mode=SessionMode.SCRIPT) + session.open(aura_home / "sessions") + session.emit("turn.start", {}) + meta = session.close() + assert meta["trace_id"] == session.trace_id + assert meta["open_snapshot_hash"] == _snapshot_hash(session.profile, session.declared_rules)