diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e5094a1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,16 @@ +# GovOPlaN Audit Codex Guide + +## Scope + +This repository owns durable audit records, audit administration surfaces, retention behavior, and the transactional platform-event outbox. + +## Documentation Contract + +- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior. +- Keep feature content here; `govoplan-docs` projects it without importing Audit internals. +- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes. + +## Boundaries + +- Store bounded evidence and trace context, not arbitrary feature payloads. +- Preserve transactional recording, retention, redaction, and retry guarantees. diff --git a/src/govoplan_audit/backend/manifest.py b/src/govoplan_audit/backend/manifest.py index ef78c40..0abb4de 100644 --- a/src/govoplan_audit/backend/manifest.py +++ b/src/govoplan_audit/backend/manifest.py @@ -10,7 +10,8 @@ from govoplan_core.core.access import ( CAPABILITY_AUTH_PRINCIPAL_RESOLVER, ) from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard -from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest +from govoplan_core.core.modules import DocumentationTopic, FrontendModule, MigrationSpec, ModuleContext, ModuleManifest +from govoplan_core.core.provider_governance import declared_module_architecture from govoplan_core.core.events import CAPABILITY_PLATFORM_EVENT_OUTBOX from govoplan_core.core.views import ViewSurface from govoplan_core.db.base import Base @@ -55,6 +56,27 @@ manifest = ModuleManifest( version="0.1.8", required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), route_factory=_route_factory, + documentation=( + DocumentationTopic( + id="audit.read-authorized-evidence", + title="Read authorized audit evidence", + summary="Audit history explains who performed a governed action, when it happened, and which resource and trace context were involved.", + body="Audit views are permission- and tenant-scoped. Entries are evidence, not editable business records. Sensitive payloads may be redacted while stable resource, actor, outcome, request, run, and trace references remain available for investigation.", + documentation_types=("user",), + audience=("auditor", "tenant_admin", "operator"), + metadata={"kind": "reference"}, + ), + DocumentationTopic( + id="audit.recording-retention-and-outbox", + title="Operate audit recording and event delivery", + summary="Audit owns durable audit records, retention operations, and the transactional platform-event outbox.", + body="Modules record bounded audit facts through the Audit capability. Governed platform events are committed to the outbox with retry and delivery metadata so a failed consumer does not erase the originating transaction. Retention and destructive retirement must preserve the configured evidence and recovery guarantees.", + documentation_types=("admin",), + audience=("auditor", "security_officer", "operator"), + related_modules=("policy", "ops"), + metadata={"kind": "reference"}, + ), + ), frontend=FrontendModule( module_id="audit", package_name="@govoplan/audit-webui", @@ -91,6 +113,19 @@ manifest = ModuleManifest( CAPABILITY_AUDIT_RETENTION: _audit_retention, CAPABILITY_PLATFORM_EVENT_OUTBOX: _event_outbox, }, + architecture=declared_module_architecture( + layer="governance_accountability", + kind="governance", + maturity="vertical_slice", + documentation_ref="docs/AUDIT_TRACE_CONTEXT.md", + test_ref="tests/test_audit_module_contract.py", + known_limits=("Cross-deployment archival and evidentiary export profiles are not yet reference-ready.",), + owned_concepts=("audit record", "audit retention", "transactional event outbox"), + non_owned_concepts=("domain record", "policy decision", "external effect"), + recovery_docs=("README.md",), + security_docs=("docs/AUDIT_TRACE_CONTEXT.md",), + operations_docs=("README.md",), + ), ) diff --git a/src/govoplan_audit/backend/outbox.py b/src/govoplan_audit/backend/outbox.py index 876587b..2b20e50 100644 --- a/src/govoplan_audit/backend/outbox.py +++ b/src/govoplan_audit/backend/outbox.py @@ -21,6 +21,7 @@ from govoplan_core.core.events import ( ensure_event_trace, publish_platform_event, ) +from govoplan_core.core.institutional import GovernedContextEnvelope EventDispatcher = Callable[[PlatformEvent], None] @@ -562,6 +563,19 @@ def _event_from_payload(payload: Mapping[str, Any]) -> PlatformEvent: subject=_object_ref(payload.get("subject")), resource=_object_ref(payload.get("resource")), classification=cast(EventClassification, str(payload.get("classification") or "internal")), + institutional_context=_institutional_context( + payload.get("institutional_context") + ), + ) + + +def _institutional_context( + value: object, +) -> GovernedContextEnvelope | None: + return ( + GovernedContextEnvelope.from_mapping(value) + if isinstance(value, Mapping) + else None ) diff --git a/tests/test_audit_delivery.py b/tests/test_audit_delivery.py index 6dcc4f1..1e1ef57 100644 --- a/tests/test_audit_delivery.py +++ b/tests/test_audit_delivery.py @@ -17,6 +17,11 @@ from govoplan_core.core.events import ( EventActorRef, PlatformEvent, ) +from govoplan_core.core.institutional import ( + GovernedContextEnvelope, + InstitutionalReference, + TemporalRevision, +) from govoplan_core.db.base import Base @@ -109,6 +114,60 @@ class AuditOutboxTests(unittest.TestCase): delivery.delivery_key, ) + def test_outbox_preserves_institutional_context(self) -> None: + Session = self._database() + outbox = SqlAuditOutbox() + seen: list[PlatformEvent] = [] + now = datetime.now(timezone.utc) + context = GovernedContextEnvelope( + tenant_id="tenant-1", + temporal=TemporalRevision(revision="decision:7", recorded_at=now), + decision_ref=InstitutionalReference( + kind="decision", + owner_module="committee", + object_id="decision-7", + tenant_id="tenant-1", + version="7", + valid_at=now, + ), + approval_refs=( + InstitutionalReference( + kind="approval", + owner_module="workflow", + object_id="approval-3", + tenant_id="tenant-1", + version="3", + valid_at=now, + ), + ), + ) + + with Session() as session: + outbox.enqueue( + session, + PlatformEvent( + type="committee.decision.recorded", + module_id="committee", + institutional_context=context, + ), + ) + outbox.dispatch_pending( + session, + consumers=( + DurableEventConsumer( + consumer_id="tests.institutional-context.v1", + event_types=frozenset({"committee.decision.recorded"}), + handler=lambda delivered, _key: seen.append(delivered), + ), + ), + ) + + self.assertEqual("decision-7", seen[0].institutional_context.decision_ref.object_id) + self.assertEqual( + "approval-3", + seen[0].institutional_context.approval_refs[0].object_id, + ) + def test_outbox_retries_then_quarantines_a_failed_consumer(self) -> None: Session = self._database() outbox = SqlAuditOutbox(max_attempts=2)