From 286ebc3cb68cf358812985d8fe1eef35f8e8d37b Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 6 May 2026 02:09:23 +0200 Subject: [PATCH] relationship persistence, context entities, idempotent asset creation, audit/version handling for relationship changes --- docs/asset-registry-implementation.md | 23 ++- src/kontextual_engine/__init__.py | 7 +- .../adapters/memory/asset_registry.py | 62 ++++++ .../adapters/sqlite/asset_registry.py | 146 ++++++++++++++ src/kontextual_engine/core/__init__.py | 4 +- src/kontextual_engine/core/idempotency.py | 48 +++++ src/kontextual_engine/core/relationships.py | 26 +++ src/kontextual_engine/ports/repositories.py | 19 ++ src/kontextual_engine/services/__init__.py | 5 +- .../services/asset_service.py | 184 +++++++++++++++++- tests/test_asset_registry.py | 119 ++++++++++- ...WP-0005-asset-registry-governance-state.md | 32 +-- 12 files changed, 651 insertions(+), 24 deletions(-) create mode 100644 src/kontextual_engine/core/idempotency.py diff --git a/docs/asset-registry-implementation.md b/docs/asset-registry-implementation.md index 84aac36..a25675c 100644 --- a/docs/asset-registry-implementation.md +++ b/docs/asset-registry-implementation.md @@ -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. diff --git a/src/kontextual_engine/__init__.py b/src/kontextual_engine/__init__.py index e6993b2..363ca99 100644 --- a/src/kontextual_engine/__init__.py +++ b/src/kontextual_engine/__init__.py @@ -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", diff --git a/src/kontextual_engine/adapters/memory/asset_registry.py b/src/kontextual_engine/adapters/memory/asset_registry.py index 93c0041..d412950 100644 --- a/src/kontextual_engine/adapters/memory/asset_registry.py +++ b/src/kontextual_engine/adapters/memory/asset_registry.py @@ -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) diff --git a/src/kontextual_engine/adapters/sqlite/asset_registry.py b/src/kontextual_engine/adapters/sqlite/asset_registry.py index 4b2cc44..2ae7898 100644 --- a/src/kontextual_engine/adapters/sqlite/asset_registry.py +++ b/src/kontextual_engine/adapters/sqlite/asset_registry.py @@ -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); diff --git a/src/kontextual_engine/core/__init__.py b/src/kontextual_engine/core/__init__.py index f00a0b5..a8098ff 100644 --- a/src/kontextual_engine/core/__init__.py +++ b/src/kontextual_engine/core/__init__.py @@ -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", ] - diff --git a/src/kontextual_engine/core/idempotency.py b/src/kontextual_engine/core/idempotency.py new file mode 100644 index 0000000..2ce8c47 --- /dev/null +++ b/src/kontextual_engine/core/idempotency.py @@ -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"], + ) + diff --git a/src/kontextual_engine/core/relationships.py b/src/kontextual_engine/core/relationships.py index 78f74d1..732da67 100644 --- a/src/kontextual_engine/core/relationships.py +++ b/src/kontextual_engine/core/relationships.py @@ -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"], + ) diff --git a/src/kontextual_engine/ports/repositories.py b/src/kontextual_engine/ports/repositories.py index 90758c6..1a75442 100644 --- a/src/kontextual_engine/ports/repositories.py +++ b/src/kontextual_engine/ports/repositories.py @@ -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: ... diff --git a/src/kontextual_engine/services/__init__.py b/src/kontextual_engine/services/__init__.py index 335b84a..5faca01 100644 --- a/src/kontextual_engine/services/__init__.py +++ b/src/kontextual_engine/services/__init__.py @@ -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"] diff --git a/src/kontextual_engine/services/asset_service.py b/src/kontextual_engine/services/asset_service.py index db151e6..c7d0dbe 100644 --- a/src/kontextual_engine/services/asset_service.py +++ b/src/kontextual_engine/services/asset_service.py @@ -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}, + ) diff --git a/tests/test_asset_registry.py b/tests/test_asset_registry.py index dcd77aa..48d0f51 100644 --- a/tests/test_asset_registry.py +++ b/tests/test_asset_registry.py @@ -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) - diff --git a/workplans/KONT-WP-0005-asset-registry-governance-state.md b/workplans/KONT-WP-0005-asset-registry-governance-state.md index 50acb75..3a0281a 100644 --- a/workplans/KONT-WP-0005-asset-registry-governance-state.md +++ b/workplans/KONT-WP-0005-asset-registry-governance-state.md @@ -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" ```