feat: strengthen transactional audit delivery

This commit is contained in:
2026-08-01 17:48:23 +02:00
parent 57ceef0173
commit 4177287b22
4 changed files with 125 additions and 1 deletions
+16
View File
@@ -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.
+36 -1
View File
@@ -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",),
),
)
+14
View File
@@ -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
)
+59
View File
@@ -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)