Structured OperationFailure, BatchItemResult, and BatchOperationResult envelopes

This commit is contained in:
2026-05-06 10:26:37 +02:00
parent df3b43d311
commit 48dffedc09
9 changed files with 603 additions and 62 deletions

View File

@@ -7,11 +7,15 @@ from kontextual_engine import (
ActorType,
AssetRegistryService,
AssetRepresentation,
AssetVersion,
AuthorizationError,
AuditEvent,
AuditOutcome,
Classification,
ContextEntity,
ContextEntityType,
InMemoryAssetRegistryRepository,
IngestionJob,
LifecycleState,
MetadataFieldDefinition,
MetadataRecord,
@@ -25,6 +29,7 @@ from kontextual_engine import (
SourceReference,
SQLiteAssetRegistryRepository,
ValidationError,
VersionChangeType,
)
@@ -343,6 +348,117 @@ def test_asset_registry_validates_metadata_schema_before_writes() -> None:
assert [record.key for record in repo.list_metadata_records(created.asset.id)] == ["owner"]
def test_asset_registry_metadata_batch_reports_partial_failures() -> None:
repo = InMemoryAssetRegistryRepository()
schema = MetadataSchema(
schema_id="schema-batch-note-v1",
name="Batch Note Metadata",
asset_types=("batch-note",),
allow_unknown=False,
fields=(
MetadataFieldDefinition("owner", MetadataValueType.STRING, required=True, require_confirmed=True),
MetadataFieldDefinition("priority", MetadataValueType.INTEGER, allow_multiple=True, min_value=1, max_value=5),
),
)
service = AssetRegistryService(repo, metadata_schemas=[schema])
context = operation_context()
created = service.create_asset(
"Batch Note",
Classification(asset_type="batch-note", sensitivity=Sensitivity.INTERNAL),
context,
asset_id="asset-batch-note",
metadata_records=[MetadataRecord("owner", "Platform Knowledge", confirmed=True)],
)
result = service.add_metadata_records_batch(
created.asset.id,
(
MetadataRecord("priority", 3, record_id="meta-priority-ok"),
MetadataRecord("priority", 9, record_id="meta-priority-too-large"),
MetadataRecord("phase", "beta", record_id="meta-phase-unknown"),
),
context,
expected_current_version_id=created.version.version_id,
)
assert result.total == 3
assert result.succeeded == 1
assert result.failed == 2
assert result.partial is True
assert result.outcome == "partial"
assert [item.success for item in result.items] == [True, False, False]
assert result.items[0].result_ref["record_id"] == "meta-priority-ok"
assert result.items[1].error is not None
assert result.items[1].error.code == "kontextual.validation"
assert result.items[1].error.correlation_id == "corr-test"
assert "metadata schema" in result.items[1].error.remediation
assert {issue["code"] for issue in result.items[1].error.details["issues"]} == {
"metadata.value_too_large"
}
assert result.items[2].error is not None
assert {issue["code"] for issue in result.items[2].error.details["issues"]} == {
"metadata.unknown_field"
}
assert result.to_dict()["audit_event_id"] == result.audit_event_id
metadata_records = repo.list_metadata_records(created.asset.id)
versions = repo.list_versions(created.asset.id)
events = repo.list_audit_events(target=f"asset:{created.asset.id}")
assert [record.key for record in metadata_records] == ["owner", "priority"]
assert metadata_records[1].record_id == "meta-priority-ok"
assert [version.sequence for version in versions] == [1, 2]
assert versions[-1].metadata_delta == {"priority": 3}
assert [event.operation for event in events] == [
"asset.create",
"asset.metadata.add",
"asset.metadata.batch_add",
]
assert events[-1].outcome.value == "partial"
assert events[-1].correlation_id == "corr-test"
assert events[-1].details["total"] == 3
assert events[-1].details["succeeded"] == 1
assert events[-1].details["failed"] == 2
assert events[-1].details["failed_item_ids"] == [
"meta-priority-too-large",
"meta-phase-unknown",
]
def test_asset_registry_metadata_batch_rejects_stale_expected_version_before_writes() -> None:
repo = InMemoryAssetRegistryRepository()
service = AssetRegistryService(repo)
context = operation_context()
created = service.create_asset(
"Batch Conflict",
Classification(asset_type="note", sensitivity=Sensitivity.INTERNAL),
context,
asset_id="asset-batch-conflict",
)
service.add_metadata_record(
created.asset.id,
MetadataRecord("owner", "Platform Knowledge", confirmed=True),
context,
expected_current_version_id=created.version.version_id,
)
with pytest.raises(ValidationError) as exc_info:
service.add_metadata_records_batch(
created.asset.id,
(MetadataRecord("topic", "architecture"),),
context,
expected_current_version_id=created.version.version_id,
)
assert exc_info.value.details["code"] == "asset.version_conflict"
assert exc_info.value.details["operation"] == "asset.metadata.batch_add"
assert [record.key for record in repo.list_metadata_records(created.asset.id)] == ["owner"]
assert [event.operation for event in repo.list_audit_events(target=f"asset:{created.asset.id}")] == [
"asset.create",
"asset.metadata.add",
]
def test_asset_registry_applies_persisted_metadata_schema_assignments() -> None:
repo = InMemoryAssetRegistryRepository()
service = AssetRegistryService(repo)
@@ -662,6 +778,54 @@ def test_sqlite_registry_filters_assets_after_reload(tmp_path: Path) -> None:
] == ["asset-guide"]
def test_sqlite_registry_persists_metadata_batch_partial_audit_after_reload(tmp_path: Path) -> None:
db_path = tmp_path / "registry.sqlite"
repo = SQLiteAssetRegistryRepository(db_path)
schema = MetadataSchema(
schema_id="schema-batch-ticket-v1",
name="Batch Ticket Metadata",
asset_types=("ticket",),
allow_unknown=False,
fields=(
MetadataFieldDefinition("owner", MetadataValueType.STRING, required=True, require_confirmed=True),
MetadataFieldDefinition("severity", MetadataValueType.INTEGER, allow_multiple=True, min_value=1, max_value=5),
),
)
service = AssetRegistryService(repo, metadata_schemas=[schema])
context = operation_context()
created = service.create_asset(
"Batch Ticket",
Classification(asset_type="ticket", sensitivity=Sensitivity.INTERNAL),
context,
asset_id="asset-batch-ticket",
metadata_records=[MetadataRecord("owner", "Operations", confirmed=True)],
)
result = service.add_metadata_records_batch(
created.asset.id,
(
MetadataRecord("severity", 4, record_id="meta-severity-ok"),
MetadataRecord("severity", 9, record_id="meta-severity-too-large"),
),
context,
expected_current_version_id=created.version.version_id,
)
reloaded = SQLiteAssetRegistryRepository(db_path)
assert result.outcome == "partial"
assert result.audit_event_id is not None
assert [record.key for record in reloaded.list_metadata_records(created.asset.id)] == [
"owner",
"severity",
]
assert reloaded.list_metadata_records(created.asset.id)[1].record_id == "meta-severity-ok"
assert [version.sequence for version in reloaded.list_versions(created.asset.id)] == [1, 2]
assert reloaded.get_audit_event(result.audit_event_id).outcome == AuditOutcome.PARTIAL
assert reloaded.get_audit_event(result.audit_event_id).details["failed_item_ids"] == [
"meta-severity-too-large"
]
def test_sqlite_registry_enforces_representation_asset_reference(tmp_path: Path) -> None:
repo = SQLiteAssetRegistryRepository(tmp_path / "registry.sqlite")
representation = AssetRepresentation.from_content(
@@ -675,6 +839,39 @@ def test_sqlite_registry_enforces_representation_asset_reference(tmp_path: Path)
repo.save_representation(representation)
def test_sqlite_registry_enforces_durable_reference_integrity(tmp_path: Path) -> None:
repo = SQLiteAssetRegistryRepository(tmp_path / "registry.sqlite")
with pytest.raises(ValidationError, match="unknown asset"):
repo.save_version(
AssetVersion(
asset_id="asset-missing",
sequence=1,
change_type=VersionChangeType.CREATED,
)
)
with pytest.raises(ValidationError, match="unknown actor"):
repo.save_audit_event(
AuditEvent(
operation="asset.create",
target="asset:asset-missing",
outcome=AuditOutcome.SUCCESS,
actor_id="actor-missing",
correlation_id="corr-missing",
)
)
with pytest.raises(ValidationError, match="unknown actor"):
repo.save_ingestion_job(
IngestionJob.create(
input={"connector": "local_file", "source_uri": "missing.txt"},
actor_id="actor-missing",
correlation_id="corr-missing",
)
)
def operation_context() -> OperationContext:
actor = Actor.create(
ActorType.HUMAN,