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

@@ -26,6 +26,8 @@ from .core import (
ContextEntityType,
CoreRelationship,
DerivedArtifactLineage,
IdempotencyRecord,
IdempotencyStatus,
KnowledgeAsset,
LifecycleState,
MetadataRecord,
@@ -51,7 +53,7 @@ from .ingestion import IngestionRequest, IngestionResult, IngestionService
from .ports import AllowAllPolicyGateway, AssetRegistryRepository, PolicyGateway
from .query import QueryEngine, QueryResult
from .relationships import RelationshipGraph
from .services import AssetChangeResult, AssetRegistryService
from .services import AssetChangeResult, AssetRegistryService, RelationshipChangeResult
from .storage import InMemoryKnowledgeRepository
from .workflows import (
InputBundle,
@@ -97,6 +99,8 @@ __all__ = [
"IngestionResult",
"IngestionService",
"InputBundle",
"IdempotencyRecord",
"IdempotencyStatus",
"KnowledgeAsset",
"KontextualError",
"LifecycleState",
@@ -111,6 +115,7 @@ __all__ = [
"QueryEngine",
"QueryResult",
"Relationship",
"RelationshipChangeResult",
"RelationshipGraph",
"RelationshipTargetKind",
"RelationshipType",

View File

@@ -10,6 +10,9 @@ from kontextual_engine.core import (
AssetRepresentation,
AssetVersion,
AuditEvent,
ContextEntity,
CoreRelationship,
IdempotencyRecord,
KnowledgeAsset,
LifecycleState,
MetadataRecord,
@@ -24,8 +27,11 @@ class InMemoryAssetRegistryRepository:
assets: dict[str, KnowledgeAsset] = field(default_factory=dict)
representations: dict[str, AssetRepresentation] = field(default_factory=dict)
metadata_records: dict[str, list[MetadataRecord]] = field(default_factory=dict)
context_entities: dict[str, ContextEntity] = field(default_factory=dict)
relationships: dict[str, CoreRelationship] = field(default_factory=dict)
versions: dict[str, list[AssetVersion]] = field(default_factory=dict)
audit_events: dict[str, AuditEvent] = field(default_factory=dict)
idempotency_records: dict[str, IdempotencyRecord] = field(default_factory=dict)
def save_actor(self, actor: Actor) -> Actor:
self.actors[actor.id] = actor
@@ -96,6 +102,50 @@ class InMemoryAssetRegistryRepository:
self.get_asset(asset_id)
return list(self.metadata_records.get(asset_id, []))
def save_context_entity(self, entity: ContextEntity) -> ContextEntity:
self.context_entities[entity.entity_id] = entity
return entity
def get_context_entity(self, entity_id: str) -> ContextEntity:
try:
return self.context_entities[entity_id]
except KeyError as exc:
raise NotFoundError("Context entity not found", details={"entity_id": entity_id}) from exc
def list_context_entities(self) -> list[ContextEntity]:
return sorted(self.context_entities.values(), key=lambda entity: (entity.entity_type.value, entity.name, entity.entity_id))
def save_relationship(self, relationship: CoreRelationship) -> CoreRelationship:
self.get_asset(relationship.source_id)
if relationship.target_kind.value == "asset":
self.get_asset(relationship.target_id)
else:
self.get_context_entity(relationship.target_id)
self.relationships[relationship.relationship_id] = relationship
return relationship
def get_relationship(self, relationship_id: str) -> CoreRelationship:
try:
return self.relationships[relationship_id]
except KeyError as exc:
raise NotFoundError(
"Relationship not found",
details={"relationship_id": relationship_id},
) from exc
def list_relationships(
self,
*,
source_id: str | None = None,
target_id: str | None = None,
) -> list[CoreRelationship]:
relationships: Iterable[CoreRelationship] = self.relationships.values()
if source_id is not None:
relationships = [item for item in relationships if item.source_id == source_id]
if target_id is not None:
relationships = [item for item in relationships if item.target_id == target_id]
return sorted(relationships, key=lambda item: (item.source_id, item.target_id, item.predicate, item.relationship_id))
def save_version(self, version: AssetVersion) -> AssetVersion:
self.get_asset(version.asset_id)
current = self.versions.setdefault(version.asset_id, [])
@@ -115,6 +165,12 @@ class InMemoryAssetRegistryRepository:
self.audit_events[event.event_id] = event
return event
def get_audit_event(self, event_id: str) -> AuditEvent:
try:
return self.audit_events[event_id]
except KeyError as exc:
raise NotFoundError("Audit event not found", details={"event_id": event_id}) from exc
def list_audit_events(
self,
*,
@@ -128,3 +184,9 @@ class InMemoryAssetRegistryRepository:
events = [event for event in events if event.correlation_id == correlation_id]
return sorted(events, key=lambda event: (event.occurred_at, event.event_id))
def save_idempotency_record(self, record: IdempotencyRecord) -> IdempotencyRecord:
self.idempotency_records[record.key] = record
return record
def get_idempotency_record(self, key: str) -> IdempotencyRecord | None:
return self.idempotency_records.get(key)

View File

@@ -12,10 +12,14 @@ from kontextual_engine.core import (
AssetRepresentation,
AssetVersion,
AuditEvent,
ContextEntity,
CoreRelationship,
IdempotencyRecord,
KnowledgeAsset,
LifecycleState,
MetadataRecord,
RepresentationKind,
RelationshipTargetKind,
)
from kontextual_engine.errors import NotFoundError, ValidationError
@@ -183,6 +187,90 @@ class SQLiteAssetRegistryRepository:
self.get_asset(asset_id)
return [MetadataRecord.from_dict(_loads(row["payload"])) for row in rows]
def save_context_entity(self, entity: ContextEntity) -> ContextEntity:
with self._connect() as conn:
conn.execute(
"""
insert into context_entities (id, entity_type, name, payload)
values (?, ?, ?, ?)
on conflict(id) do update set
entity_type=excluded.entity_type,
name=excluded.name,
payload=excluded.payload
""",
(entity.entity_id, entity.entity_type.value, entity.name, _json(entity.to_dict())),
)
return entity
def get_context_entity(self, entity_id: str) -> ContextEntity:
row = self._one("select payload from context_entities where id = ?", (entity_id,))
if row is None:
raise NotFoundError("Context entity not found", details={"entity_id": entity_id})
return ContextEntity.from_dict(_loads(row["payload"]))
def list_context_entities(self) -> list[ContextEntity]:
rows = self._all("select payload from context_entities order by entity_type, name, id", ())
return [ContextEntity.from_dict(_loads(row["payload"])) for row in rows]
def save_relationship(self, relationship: CoreRelationship) -> CoreRelationship:
self.get_asset(relationship.source_id)
if relationship.target_kind == RelationshipTargetKind.ASSET:
self.get_asset(relationship.target_id)
else:
self.get_context_entity(relationship.target_id)
with self._connect() as conn:
conn.execute(
"""
insert into core_relationships (id, source_id, target_id, target_kind, predicate, payload)
values (?, ?, ?, ?, ?, ?)
on conflict(id) do update set
source_id=excluded.source_id,
target_id=excluded.target_id,
target_kind=excluded.target_kind,
predicate=excluded.predicate,
payload=excluded.payload
""",
(
relationship.relationship_id,
relationship.source_id,
relationship.target_id,
relationship.target_kind.value,
relationship.predicate,
_json(relationship.to_dict()),
),
)
return relationship
def get_relationship(self, relationship_id: str) -> CoreRelationship:
row = self._one("select payload from core_relationships where id = ?", (relationship_id,))
if row is None:
raise NotFoundError(
"Relationship not found",
details={"relationship_id": relationship_id},
)
return CoreRelationship.from_dict(_loads(row["payload"]))
def list_relationships(
self,
*,
source_id: str | None = None,
target_id: str | None = None,
) -> list[CoreRelationship]:
clauses = []
params: list[Any] = []
if source_id is not None:
clauses.append("source_id = ?")
params.append(source_id)
if target_id is not None:
clauses.append("target_id = ?")
params.append(target_id)
where = f" where {' and '.join(clauses)}" if clauses else ""
rows = self._all(
f"select payload from core_relationships{where} order by source_id, target_id, predicate, id",
tuple(params),
)
return [CoreRelationship.from_dict(_loads(row["payload"])) for row in rows]
def save_version(self, version: AssetVersion) -> AssetVersion:
try:
with self._connect() as conn:
@@ -241,6 +329,12 @@ class SQLiteAssetRegistryRepository:
)
return event
def get_audit_event(self, event_id: str) -> AuditEvent:
row = self._one("select payload from audit_events where id = ?", (event_id,))
if row is None:
raise NotFoundError("Audit event not found", details={"event_id": event_id})
return AuditEvent.from_dict(_loads(row["payload"]))
def list_audit_events(
self,
*,
@@ -259,6 +353,34 @@ class SQLiteAssetRegistryRepository:
rows = self._all(f"select payload from audit_events{where} order by occurred_at, id", tuple(params))
return [AuditEvent.from_dict(_loads(row["payload"])) for row in rows]
def save_idempotency_record(self, record: IdempotencyRecord) -> IdempotencyRecord:
with self._connect() as conn:
conn.execute(
"""
insert into idempotency_records (key, operation, request_hash, status, payload)
values (?, ?, ?, ?, ?)
on conflict(key) do update set
operation=excluded.operation,
request_hash=excluded.request_hash,
status=excluded.status,
payload=excluded.payload
""",
(
record.key,
record.operation,
record.request_hash,
record.status.value,
_json(record.to_dict()),
),
)
return record
def get_idempotency_record(self, key: str) -> IdempotencyRecord | None:
row = self._one("select payload from idempotency_records where key = ?", (key,))
if row is None:
return None
return IdempotencyRecord.from_dict(_loads(row["payload"]))
def _initialize(self) -> None:
with self._connect() as conn:
conn.executescript(
@@ -288,6 +410,20 @@ class SQLiteAssetRegistryRepository:
key text not null,
payload text not null
);
create table if not exists context_entities (
id text primary key,
entity_type text not null,
name text not null,
payload text not null
);
create table if not exists core_relationships (
id text primary key,
source_id text not null references assets(id) on delete cascade,
target_id text not null,
target_kind text not null,
predicate text not null,
payload text not null
);
create table if not exists asset_versions (
id text primary key,
asset_id text not null references assets(id) on delete cascade,
@@ -306,9 +442,19 @@ class SQLiteAssetRegistryRepository:
payload text not null,
foreign key(actor_id) references actors(id)
);
create table if not exists idempotency_records (
key text primary key,
operation text not null,
request_hash text not null,
status text not null,
payload text not null
);
create index if not exists idx_assets_lifecycle on assets(lifecycle);
create index if not exists idx_representations_asset on representations(asset_id);
create index if not exists idx_metadata_asset on metadata_records(asset_id);
create index if not exists idx_entities_type on context_entities(entity_type);
create index if not exists idx_relationships_source on core_relationships(source_id);
create index if not exists idx_relationships_target on core_relationships(target_id);
create index if not exists idx_versions_asset on asset_versions(asset_id);
create index if not exists idx_audit_target on audit_events(target);
create index if not exists idx_audit_correlation on audit_events(correlation_id);

View File

@@ -3,6 +3,7 @@
from .actors import Actor, ActorType, OperationContext
from .assets import AssetRepresentation, KnowledgeAsset, RepresentationKind
from .audit import AuditEvent, AuditOutcome
from .idempotency import IdempotencyRecord, IdempotencyStatus
from .metadata import Classification, LifecycleState, MetadataRecord, Sensitivity
from .policy import PolicyDecision, PolicyEffect
from .primitives import content_digest, mapping_digest, new_id, stable_json_dumps, utc_now
@@ -31,6 +32,8 @@ __all__ = [
"ContextEntityType",
"CoreRelationship",
"DerivedArtifactLineage",
"IdempotencyRecord",
"IdempotencyStatus",
"KnowledgeAsset",
"LifecycleState",
"MetadataRecord",
@@ -48,4 +51,3 @@ __all__ = [
"stable_json_dumps",
"utc_now",
]

View File

@@ -0,0 +1,48 @@
"""Idempotency records for mutation safety."""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
from .primitives import compact_dict, utc_now
class IdempotencyStatus(str, Enum):
COMPLETED = "completed"
FAILED = "failed"
@dataclass(frozen=True)
class IdempotencyRecord:
key: str
operation: str
request_hash: str
result_refs: dict[str, Any]
status: IdempotencyStatus = IdempotencyStatus.COMPLETED
created_at: str = field(default_factory=lambda: utc_now().isoformat())
def to_dict(self) -> dict[str, Any]:
return compact_dict(
{
"key": self.key,
"operation": self.operation,
"request_hash": self.request_hash,
"result_refs": dict(self.result_refs),
"status": self.status.value,
"created_at": self.created_at,
}
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "IdempotencyRecord":
return cls(
key=data["key"],
operation=data["operation"],
request_hash=data["request_hash"],
result_refs=dict(data.get("result_refs", {})),
status=IdempotencyStatus(data.get("status", IdempotencyStatus.COMPLETED.value)),
created_at=data["created_at"],
)

View File

@@ -41,6 +41,16 @@ class ContextEntity:
}
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ContextEntity":
return cls(
entity_id=data["entity_id"],
entity_type=ContextEntityType(data["entity_type"]),
name=data["name"],
external_ref=data.get("external_ref"),
metadata=dict(data.get("metadata", {})),
)
class RelationshipTargetKind(str, Enum):
ASSET = "asset"
@@ -80,3 +90,19 @@ class CoreRelationship:
}
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "CoreRelationship":
return cls(
relationship_id=data["relationship_id"],
source_id=data["source_id"],
target_id=data["target_id"],
predicate=data["predicate"],
target_kind=RelationshipTargetKind(data.get("target_kind", RelationshipTargetKind.ASSET.value)),
direction=data.get("direction", "outbound"),
confidence=data.get("confidence"),
valid_from=data.get("valid_from"),
valid_to=data.get("valid_to"),
actor_id=data.get("actor_id"),
provenance=dict(data.get("provenance", {})),
created_at=data["created_at"],
)

View File

@@ -9,6 +9,9 @@ from kontextual_engine.core import (
AssetRepresentation,
AssetVersion,
AuditEvent,
ContextEntity,
CoreRelationship,
IdempotencyRecord,
KnowledgeAsset,
LifecycleState,
MetadataRecord,
@@ -41,10 +44,24 @@ class AssetRegistryRepository(Protocol):
def save_metadata_record(self, asset_id: str, record: MetadataRecord) -> MetadataRecord: ...
def list_metadata_records(self, asset_id: str) -> list[MetadataRecord]: ...
def save_context_entity(self, entity: ContextEntity) -> ContextEntity: ...
def get_context_entity(self, entity_id: str) -> ContextEntity: ...
def list_context_entities(self) -> list[ContextEntity]: ...
def save_relationship(self, relationship: CoreRelationship) -> CoreRelationship: ...
def get_relationship(self, relationship_id: str) -> CoreRelationship: ...
def list_relationships(
self,
*,
source_id: str | None = None,
target_id: str | None = None,
) -> list[CoreRelationship]: ...
def save_version(self, version: AssetVersion) -> AssetVersion: ...
def list_versions(self, asset_id: str) -> list[AssetVersion]: ...
def save_audit_event(self, event: AuditEvent) -> AuditEvent: ...
def get_audit_event(self, event_id: str) -> AuditEvent: ...
def list_audit_events(
self,
*,
@@ -52,3 +69,5 @@ class AssetRegistryRepository(Protocol):
correlation_id: str | None = None,
) -> list[AuditEvent]: ...
def save_idempotency_record(self, record: IdempotencyRecord) -> IdempotencyRecord: ...
def get_idempotency_record(self, key: str) -> IdempotencyRecord | None: ...

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