generated from coulomb/repo-seed
relationship persistence, context entities, idempotent asset creation, audit/version handling for relationship changes
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
"""Application services for the engine."""
|
||||
|
||||
from .asset_service import AssetChangeResult, AssetRegistryService
|
||||
|
||||
__all__ = ["AssetChangeResult", "AssetRegistryService"]
|
||||
from .asset_service import AssetChangeResult, AssetRegistryService, RelationshipChangeResult
|
||||
|
||||
__all__ = ["AssetChangeResult", "AssetRegistryService", "RelationshipChangeResult"]
|
||||
|
||||
@@ -10,15 +10,20 @@ from kontextual_engine.core import (
|
||||
AuditEvent,
|
||||
AuditOutcome,
|
||||
Classification,
|
||||
ContextEntity,
|
||||
CoreRelationship,
|
||||
IdempotencyRecord,
|
||||
KnowledgeAsset,
|
||||
LifecycleState,
|
||||
mapping_digest,
|
||||
MetadataRecord,
|
||||
OperationContext,
|
||||
PolicyDecision,
|
||||
RelationshipTargetKind,
|
||||
SourceReference,
|
||||
VersionChangeType,
|
||||
)
|
||||
from kontextual_engine.errors import AuthorizationError
|
||||
from kontextual_engine.errors import AuthorizationError, ValidationError
|
||||
from kontextual_engine.ports import AllowAllPolicyGateway, AssetRegistryRepository, PolicyGateway
|
||||
|
||||
|
||||
@@ -30,6 +35,14 @@ class AssetChangeResult:
|
||||
policy_decision: PolicyDecision
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RelationshipChangeResult:
|
||||
relationship: CoreRelationship
|
||||
version: AssetVersion
|
||||
audit_event: AuditEvent
|
||||
policy_decision: PolicyDecision
|
||||
|
||||
|
||||
class AssetRegistryService:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -50,7 +63,23 @@ class AssetRegistryService:
|
||||
representations: list[AssetRepresentation] | None = None,
|
||||
metadata_records: list[MetadataRecord] | None = None,
|
||||
asset_id: str | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
) -> AssetChangeResult:
|
||||
request_hash = mapping_digest(
|
||||
{
|
||||
"title": title,
|
||||
"classification": classification.to_dict(),
|
||||
"source_refs": [source_ref.to_dict() for source_ref in source_refs or []],
|
||||
"representations": [representation.to_dict() for representation in representations or []],
|
||||
"metadata_records": [record.to_dict() for record in metadata_records or []],
|
||||
"asset_id": asset_id,
|
||||
}
|
||||
)
|
||||
if idempotency_key:
|
||||
existing = self._idempotent_lookup("asset.create", idempotency_key, request_hash)
|
||||
if existing:
|
||||
return self._asset_change_from_idempotency(existing)
|
||||
|
||||
asset = KnowledgeAsset.create(
|
||||
title,
|
||||
classification,
|
||||
@@ -92,6 +121,19 @@ class AssetRegistryService:
|
||||
decision,
|
||||
details={"version_id": version.version_id},
|
||||
)
|
||||
if idempotency_key:
|
||||
self.repository.save_idempotency_record(
|
||||
IdempotencyRecord(
|
||||
key=idempotency_key,
|
||||
operation="asset.create",
|
||||
request_hash=request_hash,
|
||||
result_refs={
|
||||
"asset_id": asset.id,
|
||||
"version_id": version.version_id,
|
||||
"audit_event_id": event.event_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
return AssetChangeResult(asset, version, event, decision)
|
||||
|
||||
def add_metadata_record(
|
||||
@@ -206,6 +248,110 @@ class AssetRegistryService:
|
||||
def get_asset(self, asset_id: str) -> KnowledgeAsset:
|
||||
return self.repository.get_asset(asset_id)
|
||||
|
||||
def register_context_entity(self, entity: ContextEntity, context: OperationContext) -> ContextEntity:
|
||||
decision = self._authorize(
|
||||
context,
|
||||
"context_entity.register",
|
||||
f"context_entity:{entity.entity_id}",
|
||||
resource_metadata={"entity_type": entity.entity_type.value},
|
||||
)
|
||||
saved = self.repository.save_context_entity(entity)
|
||||
self._audit(
|
||||
"context_entity.register",
|
||||
f"context_entity:{entity.entity_id}",
|
||||
AuditOutcome.SUCCESS,
|
||||
context,
|
||||
decision,
|
||||
details={"entity_id": entity.entity_id},
|
||||
)
|
||||
return saved
|
||||
|
||||
def link_asset_to_asset(
|
||||
self,
|
||||
source_asset_id: str,
|
||||
target_asset_id: str,
|
||||
predicate: str,
|
||||
context: OperationContext,
|
||||
*,
|
||||
confidence: float | None = None,
|
||||
provenance: dict[str, str] | None = None,
|
||||
) -> RelationshipChangeResult:
|
||||
relationship = CoreRelationship(
|
||||
source_id=source_asset_id,
|
||||
target_id=target_asset_id,
|
||||
predicate=predicate,
|
||||
target_kind=RelationshipTargetKind.ASSET,
|
||||
confidence=confidence,
|
||||
actor_id=context.actor.id,
|
||||
provenance=dict(provenance or {}),
|
||||
)
|
||||
return self._save_relationship(relationship, context)
|
||||
|
||||
def link_asset_to_context_entity(
|
||||
self,
|
||||
source_asset_id: str,
|
||||
entity: ContextEntity,
|
||||
predicate: str,
|
||||
context: OperationContext,
|
||||
*,
|
||||
confidence: float | None = None,
|
||||
provenance: dict[str, str] | None = None,
|
||||
) -> RelationshipChangeResult:
|
||||
self.repository.save_context_entity(entity)
|
||||
relationship = CoreRelationship(
|
||||
source_id=source_asset_id,
|
||||
target_id=entity.entity_id,
|
||||
predicate=predicate,
|
||||
target_kind=RelationshipTargetKind.CONTEXT_ENTITY,
|
||||
confidence=confidence,
|
||||
actor_id=context.actor.id,
|
||||
provenance=dict(provenance or {}),
|
||||
)
|
||||
return self._save_relationship(relationship, context)
|
||||
|
||||
def _save_relationship(
|
||||
self,
|
||||
relationship: CoreRelationship,
|
||||
context: OperationContext,
|
||||
) -> RelationshipChangeResult:
|
||||
source_asset = self.repository.get_asset(relationship.source_id)
|
||||
decision = self._authorize(
|
||||
context,
|
||||
"asset.relationship.add",
|
||||
f"asset:{source_asset.id}",
|
||||
resource_metadata={
|
||||
"target_id": relationship.target_id,
|
||||
"target_kind": relationship.target_kind.value,
|
||||
"predicate": relationship.predicate,
|
||||
},
|
||||
)
|
||||
saved = self.repository.save_relationship(relationship)
|
||||
version = AssetVersion(
|
||||
asset_id=source_asset.id,
|
||||
sequence=self._next_sequence(source_asset.id),
|
||||
change_type=VersionChangeType.RELATIONSHIP_CHANGED,
|
||||
actor_id=context.actor.id,
|
||||
parent_version_id=source_asset.current_version_id,
|
||||
relationship_delta={"added": saved.to_dict()},
|
||||
lifecycle=source_asset.lifecycle.value,
|
||||
)
|
||||
updated_asset = source_asset.with_current_version(version.version_id)
|
||||
self.repository.save_asset(updated_asset)
|
||||
self.repository.save_version(version)
|
||||
event = self._audit(
|
||||
"asset.relationship.add",
|
||||
f"asset:{source_asset.id}",
|
||||
AuditOutcome.SUCCESS,
|
||||
context,
|
||||
decision,
|
||||
details={
|
||||
"relationship_id": saved.relationship_id,
|
||||
"target_id": saved.target_id,
|
||||
"version_id": version.version_id,
|
||||
},
|
||||
)
|
||||
return RelationshipChangeResult(saved, version, event, decision)
|
||||
|
||||
def _authorize(
|
||||
self,
|
||||
context: OperationContext,
|
||||
@@ -258,3 +404,39 @@ class AssetRegistryService:
|
||||
versions = self.repository.list_versions(asset_id)
|
||||
return len(versions) + 1
|
||||
|
||||
def _idempotent_lookup(
|
||||
self,
|
||||
operation: str,
|
||||
idempotency_key: str,
|
||||
request_hash: str,
|
||||
) -> IdempotencyRecord | None:
|
||||
existing = self.repository.get_idempotency_record(idempotency_key)
|
||||
if existing is None:
|
||||
return None
|
||||
if existing.operation != operation or existing.request_hash != request_hash:
|
||||
raise ValidationError(
|
||||
"Idempotency key was reused with a different request",
|
||||
details={
|
||||
"idempotency_key": idempotency_key,
|
||||
"operation": operation,
|
||||
"existing_operation": existing.operation,
|
||||
},
|
||||
)
|
||||
return existing
|
||||
|
||||
def _asset_change_from_idempotency(self, record: IdempotencyRecord) -> AssetChangeResult:
|
||||
refs = record.result_refs
|
||||
asset = self.repository.get_asset(str(refs["asset_id"]))
|
||||
version = self._version_by_id(asset.id, str(refs["version_id"]))
|
||||
event = self.repository.get_audit_event(str(refs["audit_event_id"]))
|
||||
decision = event.policy_decision or PolicyDecision.allow("unknown", record.operation, f"asset:{asset.id}")
|
||||
return AssetChangeResult(asset, version, event, decision)
|
||||
|
||||
def _version_by_id(self, asset_id: str, version_id: str) -> AssetVersion:
|
||||
for version in self.repository.list_versions(asset_id):
|
||||
if version.version_id == version_id:
|
||||
return version
|
||||
raise ValidationError(
|
||||
"Idempotency record references an unknown asset version",
|
||||
details={"asset_id": asset_id, "version_id": version_id},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user