generated from coulomb/repo-seed
relationship persistence, context entities, idempotent asset creation, audit/version handling for relationship changes
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
# Asset Registry Implementation Note
|
||||
|
||||
Date: 2026-05-05
|
||||
Date: 2026-05-06
|
||||
|
||||
Status: first implementation slice for `KONT-WP-0005`.
|
||||
Status: active implementation note for `KONT-WP-0005`.
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -40,6 +40,12 @@ and SQLite repositories are adapters behind those ports.
|
||||
transition, and denied mutations.
|
||||
- Asset version records for create, content/representation changes, metadata
|
||||
changes, and lifecycle changes.
|
||||
- Context entity persistence.
|
||||
- Relationship persistence for asset-to-asset and asset-to-context-entity
|
||||
links.
|
||||
- Relationship changes create source-asset version records and audit events.
|
||||
- Idempotency records for safe asset creation retries.
|
||||
- Idempotency-key reuse with a different payload raises a validation error.
|
||||
- In-memory repository for deterministic tests.
|
||||
- SQLite repository for local-first durable asset registry state.
|
||||
- SQLite foreign-key enforcement for representation and metadata asset
|
||||
@@ -51,19 +57,21 @@ and SQLite repositories are adapters behind those ports.
|
||||
- `assets`
|
||||
- `representations`
|
||||
- `metadata_records`
|
||||
- `context_entities`
|
||||
- `core_relationships`
|
||||
- `asset_versions`
|
||||
- `audit_events`
|
||||
- `idempotency_records`
|
||||
|
||||
Payloads are stored as compact JSON envelopes while indexed columns carry
|
||||
stable lookup fields such as asset ID, lifecycle, representation kind, digest,
|
||||
sequence, actor ID, target, and correlation ID.
|
||||
sequence, relationship source/target, actor ID, target, correlation ID, and
|
||||
idempotency key.
|
||||
|
||||
## Not Yet Implemented
|
||||
|
||||
- Full custom metadata schema validation.
|
||||
- Relationship persistence in the new core registry.
|
||||
- Policy assignment storage and enterprise policy adapters.
|
||||
- Idempotency-key persistence for mutation deduplication.
|
||||
- Conflict detection beyond version-sequence uniqueness.
|
||||
- Restore and supersession service operations.
|
||||
- Batch partial-failure envelopes.
|
||||
@@ -80,4 +88,7 @@ These remain in scope for later `KONT-WP-0005` tasks or adjacent workplans.
|
||||
- SQLite reload preserving asset lifecycle, representation, metadata, versions,
|
||||
and audit history,
|
||||
- SQLite referential integrity for representation asset references.
|
||||
|
||||
- idempotent asset creation and conflicting idempotency-key reuse,
|
||||
- relationship creation with source-asset versioning and audit,
|
||||
- SQLite reload preserving context entities, relationships, and idempotency
|
||||
records.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
48
src/kontextual_engine/core/idempotency.py
Normal file
48
src/kontextual_engine/core/idempotency.py
Normal 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"],
|
||||
)
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
@@ -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: ...
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
@@ -9,6 +9,8 @@ from kontextual_engine import (
|
||||
AssetRepresentation,
|
||||
AuthorizationError,
|
||||
Classification,
|
||||
ContextEntity,
|
||||
ContextEntityType,
|
||||
InMemoryAssetRegistryRepository,
|
||||
LifecycleState,
|
||||
MetadataRecord,
|
||||
@@ -93,6 +95,86 @@ def test_asset_registry_lifecycle_policy_denial_fails_closed_and_audits() -> Non
|
||||
assert repo.get_asset("asset-governed").lifecycle == LifecycleState.ACTIVE
|
||||
|
||||
|
||||
def test_asset_registry_create_is_idempotent_for_same_key_and_payload() -> None:
|
||||
repo = InMemoryAssetRegistryRepository()
|
||||
service = AssetRegistryService(repo)
|
||||
context = operation_context()
|
||||
classification = Classification(asset_type="document", sensitivity=Sensitivity.PUBLIC)
|
||||
source_ref = SourceReference(source_system="repo", path="README.md")
|
||||
representation = AssetRepresentation.from_content(
|
||||
"asset-readme",
|
||||
RepresentationKind.SOURCE,
|
||||
"text/markdown",
|
||||
"# Readme\n",
|
||||
)
|
||||
|
||||
first = service.create_asset(
|
||||
"Readme",
|
||||
classification,
|
||||
context,
|
||||
asset_id="asset-readme",
|
||||
source_refs=[source_ref],
|
||||
representations=[representation],
|
||||
idempotency_key="create-readme",
|
||||
)
|
||||
second = service.create_asset(
|
||||
"Readme",
|
||||
classification,
|
||||
context,
|
||||
asset_id="asset-readme",
|
||||
source_refs=[source_ref],
|
||||
representations=[representation],
|
||||
idempotency_key="create-readme",
|
||||
)
|
||||
|
||||
assert second.asset.id == first.asset.id
|
||||
assert second.version.version_id == first.version.version_id
|
||||
assert second.audit_event.event_id == first.audit_event.event_id
|
||||
assert len(repo.list_versions("asset-readme")) == 1
|
||||
assert len(repo.list_audit_events(target="asset:asset-readme")) == 1
|
||||
|
||||
with pytest.raises(ValidationError, match="Idempotency key"):
|
||||
service.create_asset(
|
||||
"Readme renamed",
|
||||
classification,
|
||||
context,
|
||||
asset_id="asset-readme",
|
||||
source_refs=[source_ref],
|
||||
representations=[representation],
|
||||
idempotency_key="create-readme",
|
||||
)
|
||||
|
||||
|
||||
def test_asset_registry_relationships_create_versions_and_audit() -> None:
|
||||
repo = InMemoryAssetRegistryRepository()
|
||||
service = AssetRegistryService(repo)
|
||||
context = operation_context()
|
||||
classification = Classification(asset_type="document", sensitivity=Sensitivity.INTERNAL)
|
||||
source = service.create_asset("Source", classification, context, asset_id="asset-source")
|
||||
target = service.create_asset("Target", classification, context, asset_id="asset-target")
|
||||
|
||||
result = service.link_asset_to_asset(
|
||||
source.asset.id,
|
||||
target.asset.id,
|
||||
"depends_on",
|
||||
context,
|
||||
confidence=0.91,
|
||||
provenance={"producer": "test"},
|
||||
)
|
||||
|
||||
relationships = repo.list_relationships(source_id=source.asset.id)
|
||||
versions = repo.list_versions(source.asset.id)
|
||||
|
||||
assert relationships == [result.relationship]
|
||||
assert result.relationship.target_id == target.asset.id
|
||||
assert result.relationship.confidence == 0.91
|
||||
assert versions[-1].change_type.value == "relationship_changed"
|
||||
assert versions[-1].relationship_delta["added"]["predicate"] == "depends_on"
|
||||
assert repo.get_asset(source.asset.id).current_version_id == result.version.version_id
|
||||
assert repo.get_asset(target.asset.id).current_version_id == target.version.version_id
|
||||
assert repo.list_audit_events(target=f"asset:{source.asset.id}")[-1].operation == "asset.relationship.add"
|
||||
|
||||
|
||||
def test_sqlite_asset_registry_survives_reinstantiation(tmp_path: Path) -> None:
|
||||
db_path = tmp_path / "registry.sqlite"
|
||||
repo = SQLiteAssetRegistryRepository(db_path)
|
||||
@@ -138,6 +220,42 @@ def test_sqlite_asset_registry_survives_reinstantiation(tmp_path: Path) -> None:
|
||||
]
|
||||
|
||||
|
||||
def test_sqlite_registry_persists_context_entities_relationships_and_idempotency(tmp_path: Path) -> None:
|
||||
db_path = tmp_path / "registry.sqlite"
|
||||
repo = SQLiteAssetRegistryRepository(db_path)
|
||||
service = AssetRegistryService(repo)
|
||||
context = operation_context()
|
||||
created = service.create_asset(
|
||||
"Knowledge Policy",
|
||||
Classification(asset_type="policy", sensitivity=Sensitivity.INTERNAL),
|
||||
context,
|
||||
asset_id="asset-policy",
|
||||
idempotency_key="create-policy",
|
||||
)
|
||||
entity = ContextEntity(
|
||||
entity_type=ContextEntityType.PROJECT,
|
||||
name="Kontextual Engine",
|
||||
entity_id="entity-kontextual",
|
||||
)
|
||||
linked = service.link_asset_to_context_entity(
|
||||
created.asset.id,
|
||||
entity,
|
||||
"about_project",
|
||||
context,
|
||||
)
|
||||
|
||||
reloaded = SQLiteAssetRegistryRepository(db_path)
|
||||
|
||||
assert reloaded.get_idempotency_record("create-policy").result_refs["asset_id"] == "asset-policy"
|
||||
assert reloaded.list_context_entities()[0].entity_id == "entity-kontextual"
|
||||
assert reloaded.list_relationships(source_id="asset-policy")[0].relationship_id == linked.relationship.relationship_id
|
||||
assert reloaded.list_versions("asset-policy")[-1].relationship_delta["added"]["target_kind"] == "context_entity"
|
||||
assert [event.operation for event in reloaded.list_audit_events(target="asset:asset-policy")] == [
|
||||
"asset.create",
|
||||
"asset.relationship.add",
|
||||
]
|
||||
|
||||
|
||||
def test_sqlite_registry_enforces_representation_asset_reference(tmp_path: Path) -> None:
|
||||
repo = SQLiteAssetRegistryRepository(tmp_path / "registry.sqlite")
|
||||
representation = AssetRepresentation.from_content(
|
||||
@@ -179,4 +297,3 @@ class DenyLifecyclePolicy:
|
||||
context={"resource_metadata": resource_metadata or {}},
|
||||
)
|
||||
return PolicyDecision.allow(context.actor.id, action, resource)
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@ type: workplan
|
||||
title: "Asset Registry Governance And Durable State"
|
||||
domain: markitect
|
||||
repo: kontextual-engine
|
||||
status: todo
|
||||
status: active
|
||||
owner: codex
|
||||
topic_slug: markitect
|
||||
planning_priority: high
|
||||
planning_order: 5
|
||||
created: "2026-05-05"
|
||||
updated: "2026-05-05"
|
||||
updated: "2026-05-06"
|
||||
state_hub_workstream_id: "231a7794-aa3b-4763-a556-80b4cea731c8"
|
||||
---
|
||||
|
||||
@@ -50,14 +50,24 @@ independent of Markitect snapshot identity.
|
||||
The first registry slice is recorded in
|
||||
`docs/asset-registry-implementation.md`. It establishes repository ports,
|
||||
memory and SQLite adapters, and the asset registry service for create,
|
||||
metadata, representation, lifecycle, policy, audit, versions, and durable
|
||||
reload behavior.
|
||||
metadata, representation, lifecycle, relationship, idempotency, policy, audit,
|
||||
versions, and durable reload behavior.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
As of 2026-05-06, the registry core has a working asset service, in-memory and
|
||||
SQLite repositories, policy gateway boundary, audit events, versions,
|
||||
representations, metadata records, context entities, asset/context
|
||||
relationships, and idempotent asset creation. Remaining work in this workplan
|
||||
is concentrated on deeper metadata schema validation, policy assignment
|
||||
persistence, restore/supersession operations, conflict semantics beyond
|
||||
sequence/idempotency checks, and batch partial-failure envelopes.
|
||||
|
||||
## G5.1 - Implement stable asset identity and source references
|
||||
|
||||
```task
|
||||
id: KONT-WP-0005-T001
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "7d61a11c-ca14-4075-ab0b-897bdfe57cb1"
|
||||
```
|
||||
@@ -78,7 +88,7 @@ Acceptance:
|
||||
|
||||
```task
|
||||
id: KONT-WP-0005-T002
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "cd0a2b0a-a2a0-426e-8b8c-6013cd6b9303"
|
||||
```
|
||||
@@ -99,7 +109,7 @@ Acceptance:
|
||||
|
||||
```task
|
||||
id: KONT-WP-0005-T003
|
||||
status: todo
|
||||
status: in_progress
|
||||
priority: high
|
||||
state_hub_task_id: "b06c5124-ce54-4241-b712-2fbab856877b"
|
||||
```
|
||||
@@ -117,7 +127,7 @@ Acceptance:
|
||||
|
||||
```task
|
||||
id: KONT-WP-0005-T004
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "c86e24ee-7e3f-488d-a649-d17a8689f0af"
|
||||
```
|
||||
@@ -136,7 +146,7 @@ Acceptance:
|
||||
|
||||
```task
|
||||
id: KONT-WP-0005-T005
|
||||
status: todo
|
||||
status: in_progress
|
||||
priority: high
|
||||
state_hub_task_id: "3d2e98a1-3312-452a-a5f1-f7a73234b45b"
|
||||
```
|
||||
@@ -156,7 +166,7 @@ Acceptance:
|
||||
|
||||
```task
|
||||
id: KONT-WP-0005-T006
|
||||
status: todo
|
||||
status: in_progress
|
||||
priority: high
|
||||
state_hub_task_id: "de155d02-3123-42da-8ede-f111bec62747"
|
||||
```
|
||||
@@ -175,7 +185,7 @@ Acceptance:
|
||||
|
||||
```task
|
||||
id: KONT-WP-0005-T007
|
||||
status: todo
|
||||
status: in_progress
|
||||
priority: medium
|
||||
state_hub_task_id: "5288b136-05c1-449c-9215-f8b34db8b274"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user