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

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

View File

@@ -1,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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
"""Application services for the engine."""
from .asset_service import AssetChangeResult, AssetRegistryService
__all__ = ["AssetChangeResult", "AssetRegistryService"]
from .asset_service import AssetChangeResult, AssetRegistryService, RelationshipChangeResult
__all__ = ["AssetChangeResult", "AssetRegistryService", "RelationshipChangeResult"]

View File

@@ -10,15 +10,20 @@ from kontextual_engine.core import (
AuditEvent,
AuditOutcome,
Classification,
ContextEntity,
CoreRelationship,
IdempotencyRecord,
KnowledgeAsset,
LifecycleState,
mapping_digest,
MetadataRecord,
OperationContext,
PolicyDecision,
RelationshipTargetKind,
SourceReference,
VersionChangeType,
)
from kontextual_engine.errors import AuthorizationError
from kontextual_engine.errors import AuthorizationError, ValidationError
from kontextual_engine.ports import AllowAllPolicyGateway, AssetRegistryRepository, PolicyGateway
@@ -30,6 +35,14 @@ class AssetChangeResult:
policy_decision: PolicyDecision
@dataclass(frozen=True)
class RelationshipChangeResult:
relationship: CoreRelationship
version: AssetVersion
audit_event: AuditEvent
policy_decision: PolicyDecision
class AssetRegistryService:
def __init__(
self,
@@ -50,7 +63,23 @@ class AssetRegistryService:
representations: list[AssetRepresentation] | None = None,
metadata_records: list[MetadataRecord] | None = None,
asset_id: str | None = None,
idempotency_key: str | None = None,
) -> AssetChangeResult:
request_hash = mapping_digest(
{
"title": title,
"classification": classification.to_dict(),
"source_refs": [source_ref.to_dict() for source_ref in source_refs or []],
"representations": [representation.to_dict() for representation in representations or []],
"metadata_records": [record.to_dict() for record in metadata_records or []],
"asset_id": asset_id,
}
)
if idempotency_key:
existing = self._idempotent_lookup("asset.create", idempotency_key, request_hash)
if existing:
return self._asset_change_from_idempotency(existing)
asset = KnowledgeAsset.create(
title,
classification,
@@ -92,6 +121,19 @@ class AssetRegistryService:
decision,
details={"version_id": version.version_id},
)
if idempotency_key:
self.repository.save_idempotency_record(
IdempotencyRecord(
key=idempotency_key,
operation="asset.create",
request_hash=request_hash,
result_refs={
"asset_id": asset.id,
"version_id": version.version_id,
"audit_event_id": event.event_id,
},
)
)
return AssetChangeResult(asset, version, event, decision)
def add_metadata_record(
@@ -206,6 +248,110 @@ class AssetRegistryService:
def get_asset(self, asset_id: str) -> KnowledgeAsset:
return self.repository.get_asset(asset_id)
def register_context_entity(self, entity: ContextEntity, context: OperationContext) -> ContextEntity:
decision = self._authorize(
context,
"context_entity.register",
f"context_entity:{entity.entity_id}",
resource_metadata={"entity_type": entity.entity_type.value},
)
saved = self.repository.save_context_entity(entity)
self._audit(
"context_entity.register",
f"context_entity:{entity.entity_id}",
AuditOutcome.SUCCESS,
context,
decision,
details={"entity_id": entity.entity_id},
)
return saved
def link_asset_to_asset(
self,
source_asset_id: str,
target_asset_id: str,
predicate: str,
context: OperationContext,
*,
confidence: float | None = None,
provenance: dict[str, str] | None = None,
) -> RelationshipChangeResult:
relationship = CoreRelationship(
source_id=source_asset_id,
target_id=target_asset_id,
predicate=predicate,
target_kind=RelationshipTargetKind.ASSET,
confidence=confidence,
actor_id=context.actor.id,
provenance=dict(provenance or {}),
)
return self._save_relationship(relationship, context)
def link_asset_to_context_entity(
self,
source_asset_id: str,
entity: ContextEntity,
predicate: str,
context: OperationContext,
*,
confidence: float | None = None,
provenance: dict[str, str] | None = None,
) -> RelationshipChangeResult:
self.repository.save_context_entity(entity)
relationship = CoreRelationship(
source_id=source_asset_id,
target_id=entity.entity_id,
predicate=predicate,
target_kind=RelationshipTargetKind.CONTEXT_ENTITY,
confidence=confidence,
actor_id=context.actor.id,
provenance=dict(provenance or {}),
)
return self._save_relationship(relationship, context)
def _save_relationship(
self,
relationship: CoreRelationship,
context: OperationContext,
) -> RelationshipChangeResult:
source_asset = self.repository.get_asset(relationship.source_id)
decision = self._authorize(
context,
"asset.relationship.add",
f"asset:{source_asset.id}",
resource_metadata={
"target_id": relationship.target_id,
"target_kind": relationship.target_kind.value,
"predicate": relationship.predicate,
},
)
saved = self.repository.save_relationship(relationship)
version = AssetVersion(
asset_id=source_asset.id,
sequence=self._next_sequence(source_asset.id),
change_type=VersionChangeType.RELATIONSHIP_CHANGED,
actor_id=context.actor.id,
parent_version_id=source_asset.current_version_id,
relationship_delta={"added": saved.to_dict()},
lifecycle=source_asset.lifecycle.value,
)
updated_asset = source_asset.with_current_version(version.version_id)
self.repository.save_asset(updated_asset)
self.repository.save_version(version)
event = self._audit(
"asset.relationship.add",
f"asset:{source_asset.id}",
AuditOutcome.SUCCESS,
context,
decision,
details={
"relationship_id": saved.relationship_id,
"target_id": saved.target_id,
"version_id": version.version_id,
},
)
return RelationshipChangeResult(saved, version, event, decision)
def _authorize(
self,
context: OperationContext,
@@ -258,3 +404,39 @@ class AssetRegistryService:
versions = self.repository.list_versions(asset_id)
return len(versions) + 1
def _idempotent_lookup(
self,
operation: str,
idempotency_key: str,
request_hash: str,
) -> IdempotencyRecord | None:
existing = self.repository.get_idempotency_record(idempotency_key)
if existing is None:
return None
if existing.operation != operation or existing.request_hash != request_hash:
raise ValidationError(
"Idempotency key was reused with a different request",
details={
"idempotency_key": idempotency_key,
"operation": operation,
"existing_operation": existing.operation,
},
)
return existing
def _asset_change_from_idempotency(self, record: IdempotencyRecord) -> AssetChangeResult:
refs = record.result_refs
asset = self.repository.get_asset(str(refs["asset_id"]))
version = self._version_by_id(asset.id, str(refs["version_id"]))
event = self.repository.get_audit_event(str(refs["audit_event_id"]))
decision = event.policy_decision or PolicyDecision.allow("unknown", record.operation, f"asset:{asset.id}")
return AssetChangeResult(asset, version, event, decision)
def _version_by_id(self, asset_id: str, version_id: str) -> AssetVersion:
for version in self.repository.list_versions(asset_id):
if version.version_id == version_id:
return version
raise ValidationError(
"Idempotency record references an unknown asset version",
details={"asset_id": asset_id, "version_id": version_id},
)

View File

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

View File

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