relationship persistence, context entities, idempotent asset creation, audit/version handling for relationship changes

This commit is contained in:
2026-05-06 02:09:23 +02:00
parent bf59087073
commit 286ebc3cb6
12 changed files with 651 additions and 24 deletions

View File

@@ -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"]

View File

@@ -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},
)