Governed asset registry slice with asset creation, representations, metadata, lifecycle transitions, policy authorization, fail-closed denial, audit events, and version records

This commit is contained in:
2026-05-06 00:35:30 +02:00
parent d7e38606d2
commit bf59087073
22 changed files with 1259 additions and 6 deletions

View File

@@ -0,0 +1,6 @@
"""Application services for the engine."""
from .asset_service import AssetChangeResult, AssetRegistryService
__all__ = ["AssetChangeResult", "AssetRegistryService"]

View File

@@ -0,0 +1,260 @@
"""Application service for governed knowledge asset registry operations."""
from __future__ import annotations
from dataclasses import dataclass, replace
from kontextual_engine.core import (
AssetRepresentation,
AssetVersion,
AuditEvent,
AuditOutcome,
Classification,
KnowledgeAsset,
LifecycleState,
MetadataRecord,
OperationContext,
PolicyDecision,
SourceReference,
VersionChangeType,
)
from kontextual_engine.errors import AuthorizationError
from kontextual_engine.ports import AllowAllPolicyGateway, AssetRegistryRepository, PolicyGateway
@dataclass(frozen=True)
class AssetChangeResult:
asset: KnowledgeAsset
version: AssetVersion
audit_event: AuditEvent
policy_decision: PolicyDecision
class AssetRegistryService:
def __init__(
self,
repository: AssetRegistryRepository,
*,
policy_gateway: PolicyGateway | None = None,
) -> None:
self.repository = repository
self.policy_gateway = policy_gateway or AllowAllPolicyGateway()
def create_asset(
self,
title: str,
classification: Classification,
context: OperationContext,
*,
source_refs: list[SourceReference] | None = None,
representations: list[AssetRepresentation] | None = None,
metadata_records: list[MetadataRecord] | None = None,
asset_id: str | None = None,
) -> AssetChangeResult:
asset = KnowledgeAsset.create(
title,
classification,
asset_id=asset_id,
source_refs=source_refs,
)
decision = self._authorize(
context,
"asset.create",
f"asset:{asset.id}",
resource_metadata={
"asset_type": classification.asset_type,
"sensitivity": classification.sensitivity.value,
},
)
version = AssetVersion(
asset_id=asset.id,
sequence=1,
change_type=VersionChangeType.CREATED,
representation_ids=tuple(item.representation_id for item in representations or []),
actor_id=context.actor.id,
lifecycle=classification.lifecycle.value,
)
asset = asset.with_current_version(version.version_id)
self.repository.save_actor(context.actor)
self.repository.save_asset(asset)
for representation in representations or []:
if representation.asset_id != asset.id:
representation = replace(representation, asset_id=asset.id)
self.repository.save_representation(representation)
for record in metadata_records or []:
self.repository.save_metadata_record(asset.id, record)
self.repository.save_version(version)
event = self._audit(
"asset.create",
f"asset:{asset.id}",
AuditOutcome.SUCCESS,
context,
decision,
details={"version_id": version.version_id},
)
return AssetChangeResult(asset, version, event, decision)
def add_metadata_record(
self,
asset_id: str,
record: MetadataRecord,
context: OperationContext,
) -> AssetChangeResult:
asset = self.repository.get_asset(asset_id)
decision = self._authorize(context, "asset.metadata.add", f"asset:{asset.id}")
next_sequence = self._next_sequence(asset.id)
self.repository.save_metadata_record(asset.id, record)
version = AssetVersion(
asset_id=asset.id,
sequence=next_sequence,
change_type=VersionChangeType.METADATA_CHANGED,
actor_id=context.actor.id,
parent_version_id=asset.current_version_id,
metadata_delta={record.key: record.value},
lifecycle=asset.lifecycle.value,
)
asset = asset.with_current_version(version.version_id)
self.repository.save_asset(asset)
self.repository.save_version(version)
event = self._audit(
"asset.metadata.add",
f"asset:{asset.id}",
AuditOutcome.SUCCESS,
context,
decision,
details={"record_id": record.record_id, "version_id": version.version_id},
)
return AssetChangeResult(asset, version, event, decision)
def add_representation(
self,
asset_id: str,
representation: AssetRepresentation,
context: OperationContext,
) -> AssetChangeResult:
asset = self.repository.get_asset(asset_id)
decision = self._authorize(
context,
"asset.representation.add",
f"asset:{asset.id}",
resource_metadata={"kind": representation.kind.value, "media_type": representation.media_type},
)
if representation.asset_id != asset.id:
representation = replace(representation, asset_id=asset.id)
self.repository.save_representation(representation)
version = AssetVersion(
asset_id=asset.id,
sequence=self._next_sequence(asset.id),
change_type=VersionChangeType.CONTENT_CHANGED,
representation_ids=(representation.representation_id,),
actor_id=context.actor.id,
parent_version_id=asset.current_version_id,
lifecycle=asset.lifecycle.value,
)
asset = asset.with_current_version(version.version_id)
self.repository.save_asset(asset)
self.repository.save_version(version)
event = self._audit(
"asset.representation.add",
f"asset:{asset.id}",
AuditOutcome.SUCCESS,
context,
decision,
details={"representation_id": representation.representation_id, "version_id": version.version_id},
)
return AssetChangeResult(asset, version, event, decision)
def transition_lifecycle(
self,
asset_id: str,
lifecycle: LifecycleState,
context: OperationContext,
) -> AssetChangeResult:
asset = self.repository.get_asset(asset_id)
decision = self._authorize(
context,
"asset.lifecycle.transition",
f"asset:{asset.id}",
resource_metadata={"from": asset.lifecycle.value, "to": lifecycle.value},
)
updated = asset.transition_lifecycle(lifecycle)
version = AssetVersion(
asset_id=asset.id,
sequence=self._next_sequence(asset.id),
change_type=VersionChangeType.LIFECYCLE_CHANGED,
actor_id=context.actor.id,
parent_version_id=asset.current_version_id,
lifecycle=lifecycle.value,
metadata_delta={"lifecycle": {"from": asset.lifecycle.value, "to": lifecycle.value}},
)
updated = updated.with_current_version(version.version_id)
self.repository.save_asset(updated)
self.repository.save_version(version)
event = self._audit(
"asset.lifecycle.transition",
f"asset:{asset.id}",
AuditOutcome.SUCCESS,
context,
decision,
details={"version_id": version.version_id, "lifecycle": lifecycle.value},
)
return AssetChangeResult(updated, version, event, decision)
def request_delete(self, asset_id: str, context: OperationContext) -> AssetChangeResult:
return self.transition_lifecycle(asset_id, LifecycleState.DELETE_REQUESTED, context)
def get_asset(self, asset_id: str) -> KnowledgeAsset:
return self.repository.get_asset(asset_id)
def _authorize(
self,
context: OperationContext,
action: str,
resource: str,
*,
resource_metadata: dict[str, str] | None = None,
) -> PolicyDecision:
self.repository.save_actor(context.actor)
decision = self.policy_gateway.authorize(
context,
action,
resource,
resource_metadata=resource_metadata,
)
if not decision.allowed:
self._audit(action, resource, AuditOutcome.DENIED, context, decision)
raise AuthorizationError(
"Operation denied by policy",
details={
"action": action,
"resource": resource,
"correlation_id": context.correlation_id,
"policy_decision": decision.to_dict(),
},
)
return decision
def _audit(
self,
operation: str,
target: str,
outcome: AuditOutcome,
context: OperationContext,
policy_decision: PolicyDecision,
*,
details: dict[str, str] | None = None,
) -> AuditEvent:
event = AuditEvent.from_context(
operation,
target,
outcome,
context,
policy_decision=policy_decision,
details=details,
)
return self.repository.save_audit_event(event)
def _next_sequence(self, asset_id: str) -> int:
versions = self.repository.list_versions(asset_id)
return len(versions) + 1