From bb84122061d5ba0a4b6a773499ec974eea41f530 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 3 Aug 2026 09:23:15 +0200 Subject: [PATCH] Expose object modification evidence --- docs/STATE_AND_RECOVERY_CONTRACT.md | 5 +++ src/govoplan_core/core/object_storage.py | 31 ++++++++++++++++-- tests/test_object_storage.py | 40 +++++++++++++++++++++++- 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/docs/STATE_AND_RECOVERY_CONTRACT.md b/docs/STATE_AND_RECOVERY_CONTRACT.md index 0f9b87e..4101c1c 100644 --- a/docs/STATE_AND_RECOVERY_CONTRACT.md +++ b/docs/STATE_AND_RECOVERY_CONTRACT.md @@ -28,6 +28,11 @@ module artifacts. It provides bounded read/write/list/stat/delete operations for local and S3-compatible storage. Modules own their object-key namespace and business metadata; Core does not interpret module files. +`stat` and `list_objects` return object size plus a UTC `modified_at` value when +the backend can prove it. Reconciliation and retention code may use that value +for conservative grace periods, but must treat a missing timestamp as +ineligible for automatic deletion rather than guessing an age. + Rules for modules: - Store only opaque object keys in business records, never local absolute diff --git a/src/govoplan_core/core/object_storage.py b/src/govoplan_core/core/object_storage.py index 241651b..cdf206d 100644 --- a/src/govoplan_core/core/object_storage.py +++ b/src/govoplan_core/core/object_storage.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from datetime import datetime, timezone from heapq import nsmallest import os from pathlib import Path @@ -27,6 +28,7 @@ class StorageObjectMissing(StorageBackendError): class StorageObjectInfo: key: str size_bytes: int + modified_at: datetime | None = None @dataclass(frozen=True, slots=True) @@ -165,9 +167,14 @@ class LocalFilesystemStorageBackend: def stat(self, key: str) -> StorageObjectInfo: path = self._readable_path(key) + metadata = path.stat() return StorageObjectInfo( key=normalize_storage_key(key), - size_bytes=path.stat().st_size, + size_bytes=metadata.st_size, + modified_at=datetime.fromtimestamp( + metadata.st_mtime, + tz=timezone.utc, + ), ) def list_objects( @@ -188,9 +195,14 @@ class LocalFilesystemStorageBackend: normalized_after is not None and key <= normalized_after ): continue + metadata = path.stat() yield StorageObjectInfo( key=key, - size_bytes=path.stat().st_size, + size_bytes=metadata.st_size, + modified_at=datetime.fromtimestamp( + metadata.st_mtime, + tz=timezone.utc, + ), ) candidates = nsmallest( @@ -381,7 +393,11 @@ class S3StorageBackend: raise StorageBackendError( "S3 object metadata did not include a valid size" ) from exc - return StorageObjectInfo(key=normalized, size_bytes=size) + return StorageObjectInfo( + key=normalized, + size_bytes=size, + modified_at=_storage_modified_at(response.get("LastModified")), + ) def list_objects( self, @@ -407,6 +423,7 @@ class S3StorageBackend: StorageObjectInfo( key=str(item["Key"]), size_bytes=int(item.get("Size") or 0), + modified_at=_storage_modified_at(item.get("LastModified")), ) for item in response.get("Contents", ()) if isinstance(item, dict) and item.get("Key") @@ -418,6 +435,14 @@ class S3StorageBackend: ) +def _storage_modified_at(value: object) -> datetime | None: + if not isinstance(value, datetime): + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + def configured_storage_backend(settings: object) -> StorageBackend: """Build the deployment-wide object store from Core settings. diff --git a/tests/test_object_storage.py b/tests/test_object_storage.py index a2a402d..a44b1a0 100644 --- a/tests/test_object_storage.py +++ b/tests/test_object_storage.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import datetime, timezone from io import BytesIO from pathlib import Path import tempfile @@ -26,10 +27,12 @@ class _S3Error(RuntimeError): class _FakeS3Client: def __init__(self) -> None: self.objects: dict[str, bytes] = {} + self.modified_at: dict[str, datetime] = {} self.head_error: Exception | None = None def put_object(self, *, Key: str, Body: bytes, **_kwargs) -> None: self.objects[Key] = Body + self.modified_at[Key] = datetime.now(timezone.utc) def get_object(self, *, Key: str, **_kwargs): try: @@ -45,10 +48,40 @@ class _FakeS3Client: payload = self.objects[Key] except KeyError as exc: raise _S3Error("NotFound", 404) from exc - return {"ContentLength": len(payload)} + return { + "ContentLength": len(payload), + "LastModified": self.modified_at[Key], + } def delete_object(self, *, Key: str, **_kwargs) -> None: self.objects.pop(Key, None) + self.modified_at.pop(Key, None) + + def list_objects_v2( + self, + *, + Prefix: str, + MaxKeys: int, + StartAfter: str | None = None, + **_kwargs, + ): + keys = [ + key + for key in sorted(self.objects) + if key.startswith(Prefix) and (StartAfter is None or key > StartAfter) + ] + selected = keys[:MaxKeys] + return { + "Contents": [ + { + "Key": key, + "Size": len(self.objects[key]), + "LastModified": self.modified_at[key], + } + for key in selected + ], + "IsTruncated": len(keys) > len(selected), + } class ObjectStorageTests(unittest.TestCase): @@ -67,6 +100,7 @@ class ObjectStorageTests(unittest.TestCase): self.assertEqual(b"a", backend.get_bytes("campaign/a.eml")) self.assertEqual(1, backend.stat("campaign/a.eml").size_bytes) + self.assertIsNotNone(backend.stat("campaign/a.eml").modified_at) self.assertEqual( ("campaign/a.eml",), tuple(item.key for item in first.objects) ) @@ -134,6 +168,10 @@ class ObjectStorageTests(unittest.TestCase): backend.put_bytes("campaign/message.eml", b"message/rfc822") self.assertTrue(backend.exists("campaign/message.eml")) self.assertEqual(b"message/rfc822", backend.get_bytes("campaign/message.eml")) + self.assertIsNotNone(backend.stat("campaign/message.eml").modified_at) + self.assertIsNotNone( + backend.list_objects(prefix="campaign/").objects[0].modified_at + ) self.assertFalse(backend.exists("campaign/missing.eml")) client.head_error = _S3Error("AccessDenied", 403)