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

@@ -11,6 +11,8 @@ from .artifacts import (
bundle_digest,
content_digest,
)
from .adapters.memory import InMemoryAssetRegistryRepository
from .adapters.sqlite import SQLiteAssetRegistryRepository
from .context import ContextAssembler, ContextItem, ContextPackage
from .core import (
Actor,
@@ -38,6 +40,7 @@ from .core import (
)
from .errors import (
AdapterUnavailableError,
AuthorizationError,
Diagnostic,
DuplicateResourceError,
KontextualError,
@@ -45,8 +48,10 @@ from .errors import (
ValidationError,
)
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 .storage import InMemoryKnowledgeRepository
from .workflows import (
InputBundle,
@@ -60,6 +65,7 @@ from .workflows import (
__all__ = [
"__version__",
"AdapterUnavailableError",
"AllowAllPolicyGateway",
"Artifact",
"ArtifactMetadata",
"ArtifactReference",
@@ -67,9 +73,13 @@ __all__ = [
"Actor",
"ActorType",
"AssetRepresentation",
"AssetChangeResult",
"AssetRegistryRepository",
"AssetRegistryService",
"AssetVersion",
"AuditEvent",
"AuditOutcome",
"AuthorizationError",
"Classification",
"Collection",
"ContextAssembler",
@@ -81,6 +91,7 @@ __all__ = [
"DerivedArtifactLineage",
"Diagnostic",
"DuplicateResourceError",
"InMemoryAssetRegistryRepository",
"InMemoryKnowledgeRepository",
"IngestionRequest",
"IngestionResult",
@@ -94,6 +105,7 @@ __all__ = [
"OperationRun",
"OperationStage",
"OperationContext",
"PolicyGateway",
"PolicyDecision",
"PolicyEffect",
"QueryEngine",
@@ -107,6 +119,7 @@ __all__ = [
"RunStatus",
"Sensitivity",
"SourceReference",
"SQLiteAssetRegistryRepository",
"ValidationError",
"VersionChangeType",
"WorkflowStep",

View File

@@ -0,0 +1,2 @@
"""Infrastructure adapters for engine-owned ports."""

View File

@@ -0,0 +1,6 @@
"""In-memory adapters for deterministic tests."""
from .asset_registry import InMemoryAssetRegistryRepository
__all__ = ["InMemoryAssetRegistryRepository"]

View File

@@ -0,0 +1,130 @@
"""In-memory asset registry repository."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Iterable
from kontextual_engine.core import (
Actor,
AssetRepresentation,
AssetVersion,
AuditEvent,
KnowledgeAsset,
LifecycleState,
MetadataRecord,
RepresentationKind,
)
from kontextual_engine.errors import NotFoundError, ValidationError
@dataclass
class InMemoryAssetRegistryRepository:
actors: dict[str, Actor] = field(default_factory=dict)
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)
versions: dict[str, list[AssetVersion]] = field(default_factory=dict)
audit_events: dict[str, AuditEvent] = field(default_factory=dict)
def save_actor(self, actor: Actor) -> Actor:
self.actors[actor.id] = actor
return actor
def get_actor(self, actor_id: str) -> Actor:
try:
return self.actors[actor_id]
except KeyError as exc:
raise NotFoundError("Actor not found", details={"actor_id": actor_id}) from exc
def save_asset(self, asset: KnowledgeAsset) -> KnowledgeAsset:
self.assets[asset.id] = asset
return asset
def get_asset(self, asset_id: str) -> KnowledgeAsset:
try:
return self.assets[asset_id]
except KeyError as exc:
raise NotFoundError("Asset not found", details={"asset_id": asset_id}) from exc
def list_assets(
self,
*,
lifecycle: LifecycleState | None = None,
asset_type: str | None = None,
) -> list[KnowledgeAsset]:
assets: Iterable[KnowledgeAsset] = self.assets.values()
if lifecycle is not None:
assets = [asset for asset in assets if asset.lifecycle == lifecycle]
if asset_type is not None:
assets = [asset for asset in assets if asset.classification.asset_type == asset_type]
return sorted(assets, key=lambda asset: (asset.title, asset.id))
def save_representation(self, representation: AssetRepresentation) -> AssetRepresentation:
self.get_asset(representation.asset_id)
self.representations[representation.representation_id] = representation
return representation
def get_representation(self, representation_id: str) -> AssetRepresentation:
try:
return self.representations[representation_id]
except KeyError as exc:
raise NotFoundError(
"Representation not found",
details={"representation_id": representation_id},
) from exc
def list_representations(
self,
*,
asset_id: str | None = None,
kind: RepresentationKind | None = None,
) -> list[AssetRepresentation]:
representations: Iterable[AssetRepresentation] = self.representations.values()
if asset_id is not None:
representations = [item for item in representations if item.asset_id == asset_id]
if kind is not None:
representations = [item for item in representations if item.kind == kind]
return sorted(representations, key=lambda item: (item.asset_id, item.kind.value, item.representation_id))
def save_metadata_record(self, asset_id: str, record: MetadataRecord) -> MetadataRecord:
self.get_asset(asset_id)
self.metadata_records.setdefault(asset_id, []).append(record)
return record
def list_metadata_records(self, asset_id: str) -> list[MetadataRecord]:
self.get_asset(asset_id)
return list(self.metadata_records.get(asset_id, []))
def save_version(self, version: AssetVersion) -> AssetVersion:
self.get_asset(version.asset_id)
current = self.versions.setdefault(version.asset_id, [])
if any(existing.sequence == version.sequence for existing in current):
raise ValidationError(
"Version sequence already exists for asset",
details={"asset_id": version.asset_id, "sequence": version.sequence},
)
current.append(version)
return version
def list_versions(self, asset_id: str) -> list[AssetVersion]:
self.get_asset(asset_id)
return sorted(self.versions.get(asset_id, []), key=lambda version: version.sequence)
def save_audit_event(self, event: AuditEvent) -> AuditEvent:
self.audit_events[event.event_id] = event
return event
def list_audit_events(
self,
*,
target: str | None = None,
correlation_id: str | None = None,
) -> list[AuditEvent]:
events: Iterable[AuditEvent] = self.audit_events.values()
if target is not None:
events = [event for event in events if event.target == target]
if correlation_id is not None:
events = [event for event in events if event.correlation_id == correlation_id]
return sorted(events, key=lambda event: (event.occurred_at, event.event_id))

View File

@@ -0,0 +1,6 @@
"""SQLite adapters for local-first durability."""
from .asset_registry import SQLiteAssetRegistryRepository
__all__ = ["SQLiteAssetRegistryRepository"]

View File

@@ -0,0 +1,338 @@
"""SQLite asset registry repository."""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
from typing import Any
from kontextual_engine.core import (
Actor,
AssetRepresentation,
AssetVersion,
AuditEvent,
KnowledgeAsset,
LifecycleState,
MetadataRecord,
RepresentationKind,
)
from kontextual_engine.errors import NotFoundError, ValidationError
class SQLiteAssetRegistryRepository:
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
self._initialize()
def save_actor(self, actor: Actor) -> Actor:
with self._connect() as conn:
conn.execute(
"""
insert into actors (id, actor_type, payload)
values (?, ?, ?)
on conflict(id) do update set
actor_type=excluded.actor_type,
payload=excluded.payload
""",
(actor.id, actor.actor_type.value, _json(actor.to_dict())),
)
return actor
def get_actor(self, actor_id: str) -> Actor:
row = self._one("select payload from actors where id = ?", (actor_id,))
if row is None:
raise NotFoundError("Actor not found", details={"actor_id": actor_id})
return Actor.from_dict(_loads(row["payload"]))
def save_asset(self, asset: KnowledgeAsset) -> KnowledgeAsset:
with self._connect() as conn:
conn.execute(
"""
insert into assets (id, title, asset_type, lifecycle, payload)
values (?, ?, ?, ?, ?)
on conflict(id) do update set
title=excluded.title,
asset_type=excluded.asset_type,
lifecycle=excluded.lifecycle,
payload=excluded.payload
""",
(
asset.id,
asset.title,
asset.classification.asset_type,
asset.lifecycle.value,
_json(asset.to_dict()),
),
)
return asset
def get_asset(self, asset_id: str) -> KnowledgeAsset:
row = self._one("select payload from assets where id = ?", (asset_id,))
if row is None:
raise NotFoundError("Asset not found", details={"asset_id": asset_id})
return KnowledgeAsset.from_dict(_loads(row["payload"]))
def list_assets(
self,
*,
lifecycle: LifecycleState | None = None,
asset_type: str | None = None,
) -> list[KnowledgeAsset]:
clauses = []
params: list[Any] = []
if lifecycle is not None:
clauses.append("lifecycle = ?")
params.append(lifecycle.value)
if asset_type is not None:
clauses.append("asset_type = ?")
params.append(asset_type)
where = f" where {' and '.join(clauses)}" if clauses else ""
rows = self._all(f"select payload from assets{where} order by title, id", tuple(params))
return [KnowledgeAsset.from_dict(_loads(row["payload"])) for row in rows]
def save_representation(self, representation: AssetRepresentation) -> AssetRepresentation:
try:
with self._connect() as conn:
conn.execute(
"""
insert into representations (id, asset_id, kind, digest, payload)
values (?, ?, ?, ?, ?)
on conflict(id) do update set
asset_id=excluded.asset_id,
kind=excluded.kind,
digest=excluded.digest,
payload=excluded.payload
""",
(
representation.representation_id,
representation.asset_id,
representation.kind.value,
representation.digest,
_json(representation.to_dict()),
),
)
except sqlite3.IntegrityError as exc:
raise ValidationError(
"Representation references an unknown asset",
details={
"asset_id": representation.asset_id,
"representation_id": representation.representation_id,
},
) from exc
return representation
def get_representation(self, representation_id: str) -> AssetRepresentation:
row = self._one("select payload from representations where id = ?", (representation_id,))
if row is None:
raise NotFoundError(
"Representation not found",
details={"representation_id": representation_id},
)
return AssetRepresentation.from_dict(_loads(row["payload"]))
def list_representations(
self,
*,
asset_id: str | None = None,
kind: RepresentationKind | None = None,
) -> list[AssetRepresentation]:
clauses = []
params: list[Any] = []
if asset_id is not None:
clauses.append("asset_id = ?")
params.append(asset_id)
if kind is not None:
clauses.append("kind = ?")
params.append(kind.value)
where = f" where {' and '.join(clauses)}" if clauses else ""
rows = self._all(
f"select payload from representations{where} order by asset_id, kind, id",
tuple(params),
)
return [AssetRepresentation.from_dict(_loads(row["payload"])) for row in rows]
def save_metadata_record(self, asset_id: str, record: MetadataRecord) -> MetadataRecord:
try:
with self._connect() as conn:
conn.execute(
"""
insert into metadata_records (id, asset_id, key, payload)
values (?, ?, ?, ?)
on conflict(id) do update set
asset_id=excluded.asset_id,
key=excluded.key,
payload=excluded.payload
""",
(record.record_id, asset_id, record.key, _json(record.to_dict())),
)
except sqlite3.IntegrityError as exc:
raise ValidationError(
"Metadata record references an unknown asset",
details={"asset_id": asset_id, "record_id": record.record_id},
) from exc
return record
def list_metadata_records(self, asset_id: str) -> list[MetadataRecord]:
rows = self._all(
"select payload from metadata_records where asset_id = ? order by key, id",
(asset_id,),
)
if not rows:
self.get_asset(asset_id)
return [MetadataRecord.from_dict(_loads(row["payload"])) for row in rows]
def save_version(self, version: AssetVersion) -> AssetVersion:
try:
with self._connect() as conn:
conn.execute(
"""
insert into asset_versions (id, asset_id, sequence, change_type, payload)
values (?, ?, ?, ?, ?)
""",
(
version.version_id,
version.asset_id,
version.sequence,
version.change_type.value,
_json(version.to_dict()),
),
)
except sqlite3.IntegrityError as exc:
raise ValidationError(
"Version sequence already exists for asset",
details={"asset_id": version.asset_id, "sequence": version.sequence},
) from exc
return version
def list_versions(self, asset_id: str) -> list[AssetVersion]:
rows = self._all(
"select payload from asset_versions where asset_id = ? order by sequence",
(asset_id,),
)
if not rows:
self.get_asset(asset_id)
return [AssetVersion.from_dict(_loads(row["payload"])) for row in rows]
def save_audit_event(self, event: AuditEvent) -> AuditEvent:
with self._connect() as conn:
conn.execute(
"""
insert into audit_events (id, target, actor_id, correlation_id, outcome, occurred_at, payload)
values (?, ?, ?, ?, ?, ?, ?)
on conflict(id) do update set
target=excluded.target,
actor_id=excluded.actor_id,
correlation_id=excluded.correlation_id,
outcome=excluded.outcome,
occurred_at=excluded.occurred_at,
payload=excluded.payload
""",
(
event.event_id,
event.target,
event.actor_id,
event.correlation_id,
event.outcome.value,
event.occurred_at,
_json(event.to_dict()),
),
)
return event
def list_audit_events(
self,
*,
target: str | None = None,
correlation_id: str | None = None,
) -> list[AuditEvent]:
clauses = []
params: list[Any] = []
if target is not None:
clauses.append("target = ?")
params.append(target)
if correlation_id is not None:
clauses.append("correlation_id = ?")
params.append(correlation_id)
where = f" where {' and '.join(clauses)}" if clauses else ""
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 _initialize(self) -> None:
with self._connect() as conn:
conn.executescript(
"""
create table if not exists actors (
id text primary key,
actor_type text not null,
payload text not null
);
create table if not exists assets (
id text primary key,
title text not null,
asset_type text not null,
lifecycle text not null,
payload text not null
);
create table if not exists representations (
id text primary key,
asset_id text not null references assets(id) on delete cascade,
kind text not null,
digest text not null,
payload text not null
);
create table if not exists metadata_records (
id text primary key,
asset_id text not null references assets(id) on delete cascade,
key 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,
sequence integer not null,
change_type text not null,
payload text not null,
unique(asset_id, sequence)
);
create table if not exists audit_events (
id text primary key,
target text not null,
actor_id text not null,
correlation_id text not null,
outcome text not null,
occurred_at text not null,
payload text not null,
foreign key(actor_id) references actors(id)
);
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_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);
"""
)
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.path)
conn.row_factory = sqlite3.Row
conn.execute("pragma foreign_keys = on")
return conn
def _one(self, query: str, params: tuple[Any, ...]) -> sqlite3.Row | None:
with self._connect() as conn:
return conn.execute(query, params).fetchone()
def _all(self, query: str, params: tuple[Any, ...]) -> list[sqlite3.Row]:
with self._connect() as conn:
return list(conn.execute(query, params).fetchall())
def _json(value: dict[str, Any]) -> str:
return json.dumps(value, sort_keys=True, separators=(",", ":"))
def _loads(value: str) -> dict[str, Any]:
return json.loads(value)

View File

@@ -76,6 +76,22 @@ class AssetRepresentation:
}
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "AssetRepresentation":
return cls(
representation_id=data["representation_id"],
asset_id=data["asset_id"],
kind=RepresentationKind(data["kind"]),
media_type=data["media_type"],
digest=data["digest"],
size_bytes=int(data["size_bytes"]),
storage_ref=data.get("storage_ref"),
producer=data.get("producer"),
source_ref_id=data.get("source_ref_id"),
metadata=dict(data.get("metadata", {})),
created_at=data["created_at"],
)
@dataclass(frozen=True)
class KnowledgeAsset:
@@ -119,6 +135,9 @@ class KnowledgeAsset:
return self
return replace(self, aliases=self.aliases + (alias,), updated_at=utc_now().isoformat())
def with_current_version(self, version_id: str) -> "KnowledgeAsset":
return replace(self, current_version_id=version_id, updated_at=utc_now().isoformat())
def transition_lifecycle(self, lifecycle: LifecycleState | str) -> "KnowledgeAsset":
lifecycle_state = LifecycleState(lifecycle)
classification = replace(self.classification, lifecycle=lifecycle_state)
@@ -145,3 +164,17 @@ class KnowledgeAsset:
}
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "KnowledgeAsset":
return cls(
id=data["id"],
title=data["title"],
classification=Classification.from_dict(data["classification"]),
source_refs=tuple(SourceReference.from_dict(item) for item in data.get("source_refs", [])),
aliases=tuple(data.get("aliases", [])),
current_version_id=data.get("current_version_id"),
lifecycle=LifecycleState(data.get("lifecycle", LifecycleState.ACTIVE.value)),
metadata=dict(data.get("metadata", {})),
created_at=data["created_at"],
updated_at=data["updated_at"],
)

View File

@@ -70,3 +70,18 @@ class AuditEvent:
}
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "AuditEvent":
return cls(
event_id=data["event_id"],
operation=data["operation"],
target=data["target"],
outcome=AuditOutcome(data["outcome"]),
actor_id=data["actor_id"],
correlation_id=data["correlation_id"],
policy_decision=PolicyDecision.from_dict(data["policy_decision"])
if data.get("policy_decision")
else None,
details=dict(data.get("details", {})),
occurred_at=data["occurred_at"],
)

View File

@@ -48,6 +48,18 @@ class Classification:
}
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Classification":
return cls(
asset_type=data["asset_type"],
sensitivity=Sensitivity(data.get("sensitivity", Sensitivity.INTERNAL.value)),
lifecycle=LifecycleState(data.get("lifecycle", LifecycleState.ACTIVE.value)),
topics=tuple(data.get("topics", [])),
owner=data.get("owner"),
review_state=data.get("review_state"),
metadata=dict(data.get("metadata", {})),
)
@dataclass(frozen=True)
class MetadataRecord:
@@ -72,3 +84,14 @@ class MetadataRecord:
}
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "MetadataRecord":
return cls(
record_id=data["record_id"],
key=data["key"],
value=data.get("value"),
provenance=dict(data.get("provenance", {})),
confidence=data.get("confidence"),
confirmed=bool(data.get("confirmed", False)),
created_at=data["created_at"],
)

View File

@@ -77,3 +77,16 @@ class PolicyDecision:
}
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "PolicyDecision":
return cls(
decision_id=data["decision_id"],
effect=PolicyEffect(data["effect"]),
subject_id=data["subject_id"],
action=data["action"],
resource=data["resource"],
reason=data.get("reason", ""),
obligations=dict(data.get("obligations", {})),
context=dict(data.get("context", {})),
decided_at=data["decided_at"],
)

View File

@@ -48,6 +48,19 @@ class SourceReference:
}
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "SourceReference":
return cls(
id=data["id"],
source_system=data["source_system"],
path=data.get("path"),
uri=data.get("uri"),
external_id=data.get("external_id"),
checksum=data.get("checksum"),
connector_ref=data.get("connector_ref"),
metadata=dict(data.get("metadata", {})),
)
class VersionChangeType(str, Enum):
CREATED = "created"
@@ -93,6 +106,23 @@ class AssetVersion:
}
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "AssetVersion":
return cls(
version_id=data["version_id"],
asset_id=data["asset_id"],
sequence=int(data["sequence"]),
change_type=VersionChangeType(data["change_type"]),
representation_ids=tuple(data.get("representation_ids", [])),
actor_id=data.get("actor_id"),
operation_id=data.get("operation_id"),
parent_version_id=data.get("parent_version_id"),
metadata_delta=dict(data.get("metadata_delta", {})),
relationship_delta=dict(data.get("relationship_delta", {})),
lifecycle=data.get("lifecycle"),
created_at=data["created_at"],
)
@dataclass(frozen=True)
class DerivedArtifactLineage:
@@ -129,4 +159,3 @@ class DerivedArtifactLineage:
if include_hash:
data["lineage_hash"] = self.lineage_hash
return data

View File

@@ -54,6 +54,9 @@ class ValidationError(KontextualError):
code = "kontextual.validation"
class AuthorizationError(KontextualError):
code = "kontextual.authorization"
class AdapterUnavailableError(KontextualError):
code = "kontextual.adapter_unavailable"

View File

@@ -0,0 +1,11 @@
"""Stable ports owned by the engine."""
from .policy import AllowAllPolicyGateway, PolicyGateway
from .repositories import AssetRegistryRepository
__all__ = [
"AllowAllPolicyGateway",
"AssetRegistryRepository",
"PolicyGateway",
]

View File

@@ -0,0 +1,38 @@
"""Policy decision ports for application services."""
from __future__ import annotations
from typing import Any, Protocol
from kontextual_engine.core import OperationContext, PolicyDecision
class PolicyGateway(Protocol):
def authorize(
self,
context: OperationContext,
action: str,
resource: str,
*,
resource_metadata: dict[str, Any] | None = None,
) -> PolicyDecision: ...
class AllowAllPolicyGateway:
"""Deterministic default for local development and tests."""
def authorize(
self,
context: OperationContext,
action: str,
resource: str,
*,
resource_metadata: dict[str, Any] | None = None,
) -> PolicyDecision:
return PolicyDecision.allow(
context.actor.id,
action,
resource,
context={"gateway": "allow-all", "resource_metadata": resource_metadata or {}},
)

View File

@@ -0,0 +1,54 @@
"""Repository ports for governed asset registry state."""
from __future__ import annotations
from typing import Protocol
from kontextual_engine.core import (
Actor,
AssetRepresentation,
AssetVersion,
AuditEvent,
KnowledgeAsset,
LifecycleState,
MetadataRecord,
RepresentationKind,
)
class AssetRegistryRepository(Protocol):
def save_actor(self, actor: Actor) -> Actor: ...
def get_actor(self, actor_id: str) -> Actor: ...
def save_asset(self, asset: KnowledgeAsset) -> KnowledgeAsset: ...
def get_asset(self, asset_id: str) -> KnowledgeAsset: ...
def list_assets(
self,
*,
lifecycle: LifecycleState | None = None,
asset_type: str | None = None,
) -> list[KnowledgeAsset]: ...
def save_representation(self, representation: AssetRepresentation) -> AssetRepresentation: ...
def get_representation(self, representation_id: str) -> AssetRepresentation: ...
def list_representations(
self,
*,
asset_id: str | None = None,
kind: RepresentationKind | None = None,
) -> list[AssetRepresentation]: ...
def save_metadata_record(self, asset_id: str, record: MetadataRecord) -> MetadataRecord: ...
def list_metadata_records(self, asset_id: str) -> list[MetadataRecord]: ...
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 list_audit_events(
self,
*,
target: str | None = None,
correlation_id: str | None = None,
) -> list[AuditEvent]: ...

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