From ce2df9680d767a5c5bd6771c81c7e4e79ec75ea2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 14:15:18 +0900 Subject: [PATCH 01/17] test(audit): specify bounded export pagination --- tests/test_checkpoint_audit_pagination.py | 185 ++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 tests/test_checkpoint_audit_pagination.py diff --git a/tests/test_checkpoint_audit_pagination.py b/tests/test_checkpoint_audit_pagination.py new file mode 100644 index 000000000..25d049702 --- /dev/null +++ b/tests/test_checkpoint_audit_pagination.py @@ -0,0 +1,185 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Behavior contracts for bounded stable checkpoint-audit export pages.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +import pytest + +import pg_llm_batch.checkpoint_audit as checkpoint_audit +from pg_llm_batch.checkpoint_audit import ( + MAX_CHECKPOINT_AUDIT_EVENT_ID, + CheckpointAuditEvent, + CheckpointAuditPage, + validate_checkpoint_audit_cursor, +) +from pg_llm_batch.exceptions import ValidationError + + +def _event(event_id: int) -> CheckpointAuditEvent: + """Build one valid audit event with a selectable monotonically increasing identity.""" + return CheckpointAuditEvent( + audit_event_id=event_id, + tenant_scope="tenant-a", + consumer_name="worker-a", + endpoint_alias="default", + batch_id="batch-1", + action="checkpoint_save_accepted", + schema_version=1, + file_kind="result", + file_id="file-1", + file_line_number=event_id, + batch_line_count=event_id, + record_count=event_id, + prefix_sha256=f"{event_id:064x}", + recorded_at=datetime(2026, 8, 7, event_id % 24, tzinfo=timezone.utc), + ) + + +def _row(event_id: int) -> tuple[Any, ...]: + """Return one database-shaped row matching :func:`_event`.""" + event = _event(event_id) + return ( + event.audit_event_id, + event.tenant_scope, + event.consumer_name, + event.endpoint_alias, + event.batch_id, + event.action, + event.schema_version, + event.file_kind, + event.file_id, + event.file_line_number, + event.batch_line_count, + event.record_count, + event.prefix_sha256, + event.recorded_at, + ) + + +class FakeCursor: + """Capture audit-page SQL and return deterministic rows.""" + + def __init__(self, rows: Any) -> None: + self.rows = rows + self.calls: list[tuple[str, tuple[Any, ...]]] = [] + + def execute(self, sql: str, params: tuple[Any, ...] | None = None) -> None: + """Capture one normalized SQL statement and its parameters.""" + self.calls.append((" ".join(sql.split()), params or ())) + + def fetchall(self) -> Any: + """Return the configured database rows.""" + return self.rows + + +def test_audit_cursor_is_strict_positive_postgres_bigint_or_none() -> None: + """Export cursors reject coercion and values PostgreSQL cannot represent.""" + assert validate_checkpoint_audit_cursor(None) is None + assert validate_checkpoint_audit_cursor(1) == 1 + assert ( + validate_checkpoint_audit_cursor(MAX_CHECKPOINT_AUDIT_EVENT_ID) + == 9_223_372_036_854_775_807 + ) + for value in (True, 0, -1, 9_223_372_036_854_775_808, 1.0, "7", object()): + with pytest.raises(ValidationError): + validate_checkpoint_audit_cursor(value) + + +def test_audit_page_is_immutable_descending_and_cursor_bound() -> None: + """A public page cannot advertise an invalid continuation boundary.""" + first = _event(9) + second = _event(8) + page = CheckpointAuditPage(events=(first, second), next_before_audit_event_id=8) + assert page.events == (first, second) + assert page.next_before_audit_event_id == 8 + + invalid_pages = ( + ((second, first), None), + ((first, first), None), + ((first, second), 7), + ((), 1), + ) + for events, cursor in invalid_pages: + with pytest.raises(ValueError): + CheckpointAuditPage(events=events, next_before_audit_event_id=cursor) + + +def test_first_export_page_uses_one_bounded_lookahead_row( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A first page returns at most the requested rows and one stable next cursor.""" + cursor = FakeCursor(rows=(_row(9), _row(8), _row(7))) + scopes: list[str] = [] + monkeypatch.setattr( + checkpoint_audit, + "_set_transaction_tenant_scope", + lambda _cursor, tenant: scopes.append(tenant), + ) + store = checkpoint_audit.AuditedPostgresBatchResultCheckpointStore( + "postgresql://unit", + tenant_scope="tenant-a", + ) + + page = store.list_audit_event_page_in_transaction( + cursor, + "worker-a", + "batch-1", + "default", + limit=2, + ) + + assert tuple(event.audit_event_id for event in page.events) == (9, 8) + assert page.next_before_audit_event_id == 8 + assert scopes == ["tenant-a"] + query, params = cursor.calls[-1] + assert "checkpoint_audit_event_id < %s" not in query + assert "ORDER BY checkpoint_audit_event_id DESC LIMIT %s" in query + assert params == ("tenant-a", "worker-a", "default", "batch-1", 3) + + +def test_next_export_page_is_keyset_bound_and_ignores_newer_concurrent_rows( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A continuation cursor advances toward older rows without OFFSET drift.""" + cursor = FakeCursor(rows=(_row(7), _row(6))) + monkeypatch.setattr(checkpoint_audit, "_set_transaction_tenant_scope", lambda *_: None) + store = checkpoint_audit.AuditedPostgresBatchResultCheckpointStore( + "postgresql://unit", + tenant_scope="tenant-a", + ) + + page = store.list_audit_event_page_in_transaction( + cursor, + "worker-a", + "batch-1", + "default", + before_audit_event_id=8, + limit=2, + ) + + assert tuple(event.audit_event_id for event in page.events) == (7, 6) + assert page.next_before_audit_event_id is None + query, params = cursor.calls[-1] + assert "checkpoint_audit_event_id < %s" in query + assert params == ("tenant-a", "worker-a", "default", "batch-1", 8, 3) + + +def test_export_page_fails_closed_on_impossible_driver_overrun( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A driver returning more rows than the SQL bound cannot expand page memory silently.""" + cursor = FakeCursor(rows=(_row(9), _row(8), _row(7), _row(6))) + monkeypatch.setattr(checkpoint_audit, "_set_transaction_tenant_scope", lambda *_: None) + store = checkpoint_audit.AuditedPostgresBatchResultCheckpointStore("postgresql://unit") + + with pytest.raises(RuntimeError, match="exceeded its bounded query size"): + store.list_audit_event_page_in_transaction( + cursor, + "worker-a", + "batch-1", + "default", + limit=2, + ) From 3be0e4afe133e54e8224fdc5aa3453eb7d3a45b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 14:16:40 +0900 Subject: [PATCH 02/17] test(audit): complete pagination fail-closed contracts --- tests/test_checkpoint_audit_pagination.py | 131 +++++++++++++++++++++- 1 file changed, 127 insertions(+), 4 deletions(-) diff --git a/tests/test_checkpoint_audit_pagination.py b/tests/test_checkpoint_audit_pagination.py index 25d049702..fff642e19 100644 --- a/tests/test_checkpoint_audit_pagination.py +++ b/tests/test_checkpoint_audit_pagination.py @@ -11,6 +11,7 @@ import pg_llm_batch.checkpoint_audit as checkpoint_audit from pg_llm_batch.checkpoint_audit import ( MAX_CHECKPOINT_AUDIT_EVENT_ID, + MAX_CHECKPOINT_AUDIT_EVENTS, CheckpointAuditEvent, CheckpointAuditPage, validate_checkpoint_audit_cursor, @@ -66,6 +67,14 @@ def __init__(self, rows: Any) -> None: self.rows = rows self.calls: list[tuple[str, tuple[Any, ...]]] = [] + def __enter__(self) -> "FakeCursor": + """Enter the fake cursor context.""" + return self + + def __exit__(self, *_exc: Any) -> None: + """Exit the fake cursor context.""" + return None + def execute(self, sql: str, params: tuple[Any, ...] | None = None) -> None: """Capture one normalized SQL statement and its parameters.""" self.calls.append((" ".join(sql.split()), params or ())) @@ -75,6 +84,43 @@ def fetchall(self) -> Any: return self.rows +class FakeConnection: + """Expose one cursor without inventing a commit boundary for audit reads.""" + + def __init__(self, cursor: FakeCursor) -> None: + self.fake_cursor = cursor + self.commits = 0 + + def __enter__(self) -> "FakeConnection": + """Enter the fake connection context.""" + return self + + def __exit__(self, *_exc: Any) -> None: + """Exit the fake connection context.""" + return None + + def cursor(self) -> FakeCursor: + """Return the deterministic fake cursor.""" + return self.fake_cursor + + def commit(self) -> None: + """Record an unexpected package commit if production attempts one.""" + self.commits += 1 + + +class FakePsycopg: + """Return one deterministic connection and record requested DSNs.""" + + def __init__(self, connection: FakeConnection) -> None: + self.connection = connection + self.dsns: list[str] = [] + + def connect(self, dsn: str) -> FakeConnection: + """Record one DSN and return the configured connection.""" + self.dsns.append(dsn) + return self.connection + + def test_audit_cursor_is_strict_positive_postgres_bigint_or_none() -> None: """Export cursors reject coercion and values PostgreSQL cannot represent.""" assert validate_checkpoint_audit_cursor(None) is None @@ -107,6 +153,17 @@ def test_audit_page_is_immutable_descending_and_cursor_bound() -> None: CheckpointAuditPage(events=events, next_before_audit_event_id=cursor) +def test_audit_page_rejects_mutable_wrong_type_and_oversized_event_collections() -> None: + """Direct page construction cannot bypass tuple, type, or public-size contracts.""" + with pytest.raises(ValueError, match="tuple"): + CheckpointAuditPage(events=[_event(1)], next_before_audit_event_id=None) # type: ignore[arg-type] + with pytest.raises(ValueError, match="CheckpointAuditEvent"): + CheckpointAuditPage(events=(object(),), next_before_audit_event_id=None) # type: ignore[arg-type] + oversized = tuple(_event(index + 1) for index in range(MAX_CHECKPOINT_AUDIT_EVENTS + 1)) + with pytest.raises(ValueError, match="at most"): + CheckpointAuditPage(events=oversized, next_before_audit_event_id=None) + + def test_first_export_page_uses_one_bounded_lookahead_row( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -167,19 +224,85 @@ def test_next_export_page_is_keyset_bound_and_ignores_newer_concurrent_rows( assert params == ("tenant-a", "worker-a", "default", "batch-1", 8, 3) -def test_export_page_fails_closed_on_impossible_driver_overrun( +def test_export_page_revalidates_database_key_and_descending_order( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Corrupt or cross-key database rows cannot become trusted export evidence.""" + monkeypatch.setattr(checkpoint_audit, "_set_transaction_tenant_scope", lambda *_: None) + store = checkpoint_audit.AuditedPostgresBatchResultCheckpointStore( + "postgresql://unit", + tenant_scope="tenant-a", + ) + + wrong_key = list(_row(9)) + wrong_key[1] = "tenant-b" + with pytest.raises(RuntimeError, match="outside the requested key"): + store.list_audit_event_page_in_transaction( + FakeCursor(rows=(tuple(wrong_key),)), + "worker-a", + "batch-1", + "default", + ) + + with pytest.raises(RuntimeError, match="strictly descending"): + store.list_audit_event_page_in_transaction( + FakeCursor(rows=(_row(8), _row(9))), + "worker-a", + "batch-1", + "default", + ) + + +def test_export_page_fails_closed_on_invalid_collection_or_driver_overrun( monkeypatch: pytest.MonkeyPatch, ) -> None: - """A driver returning more rows than the SQL bound cannot expand page memory silently.""" - cursor = FakeCursor(rows=(_row(9), _row(8), _row(7), _row(6))) + """Unexpected driver output cannot bypass bounded export pagination.""" monkeypatch.setattr(checkpoint_audit, "_set_transaction_tenant_scope", lambda *_: None) store = checkpoint_audit.AuditedPostgresBatchResultCheckpointStore("postgresql://unit") + with pytest.raises(RuntimeError, match="invalid row collection"): + store.list_audit_event_page_in_transaction( + FakeCursor(rows=object()), + "worker-a", + "batch-1", + "default", + limit=2, + ) + with pytest.raises(RuntimeError, match="exceeded its bounded query size"): store.list_audit_event_page_in_transaction( - cursor, + FakeCursor(rows=(_row(9), _row(8), _row(7), _row(6))), "worker-a", "batch-1", "default", limit=2, ) + + +def test_owned_export_page_uses_one_connection_without_committing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The package-owned page read preserves the existing read-only transaction boundary.""" + cursor = FakeCursor(rows=(_row(3),)) + connection = FakeConnection(cursor) + fake_psycopg = FakePsycopg(connection) + monkeypatch.setattr(checkpoint_audit, "psycopg", fake_psycopg) + monkeypatch.setattr(checkpoint_audit, "_require_psycopg", lambda: None) + monkeypatch.setattr(checkpoint_audit, "_set_transaction_tenant_scope", lambda *_: None) + store = checkpoint_audit.AuditedPostgresBatchResultCheckpointStore( + "postgresql://unit", + tenant_scope="tenant-a", + ) + + page = store.list_audit_event_page( + "worker-a", + "batch-1", + "default", + before_audit_event_id=4, + limit=2, + ) + + assert tuple(event.audit_event_id for event in page.events) == (3,) + assert page.next_before_audit_event_id is None + assert fake_psycopg.dsns == ["postgresql://unit"] + assert connection.commits == 0 From 20ef91af711ba17f75b12fef559d6fc11cca041a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 14:17:32 +0900 Subject: [PATCH 03/17] feat(audit): implement bounded keyset export pages --- pg_llm_batch/checkpoint_audit.py | 152 +++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/pg_llm_batch/checkpoint_audit.py b/pg_llm_batch/checkpoint_audit.py index 49d0f4302..041dddd2d 100644 --- a/pg_llm_batch/checkpoint_audit.py +++ b/pg_llm_batch/checkpoint_audit.py @@ -22,6 +22,7 @@ AUDIT_ACTION_CHECKPOINT_SAVE_ACCEPTED = "checkpoint_save_accepted" MAX_CHECKPOINT_AUDIT_EVENTS = 1000 DEFAULT_CHECKPOINT_AUDIT_EVENTS = 100 +MAX_CHECKPOINT_AUDIT_EVENT_ID = 9_223_372_036_854_775_807 AUDIT_MIGRATION_PATH = ( Path(__file__).with_name("migrations") / "0008_result_checkpoint_audit_events.sql" ) @@ -81,6 +82,35 @@ def __post_init__(self) -> None: raise ValueError("recorded_at must be a timezone-aware datetime") +@dataclass(frozen=True, slots=True) +class CheckpointAuditPage: + """One immutable newest-first bounded audit page and its older-row cursor.""" + + events: tuple[CheckpointAuditEvent, ...] + next_before_audit_event_id: Optional[int] + + def __post_init__(self) -> None: + """Reject mutable, oversized, unordered, or cursor-inconsistent pages.""" + if not isinstance(self.events, tuple): + raise ValueError("events must be an immutable tuple") + if len(self.events) > MAX_CHECKPOINT_AUDIT_EVENTS: + raise ValueError( + f"events must contain at most {MAX_CHECKPOINT_AUDIT_EVENTS} records" + ) + if any(not isinstance(event, CheckpointAuditEvent) for event in self.events): + raise ValueError("events must contain only CheckpointAuditEvent values") + if any( + current.audit_event_id >= previous.audit_event_id + for previous, current in zip(self.events, self.events[1:]) + ): + raise ValueError("events must be strictly descending by audit_event_id") + cursor = validate_checkpoint_audit_cursor(self.next_before_audit_event_id) + if cursor is not None and ( + not self.events or cursor != self.events[-1].audit_event_id + ): + raise ValueError("next_before_audit_event_id must equal the final event id") + + def validate_checkpoint_audit_limit(value: Any) -> int: """Validate the bounded number of audit rows one public read may return.""" if ( @@ -97,6 +127,26 @@ def validate_checkpoint_audit_limit(value: Any) -> int: return value +def validate_checkpoint_audit_cursor(value: Any) -> Optional[int]: + """Validate an optional positive PostgreSQL BIGINT audit-event keyset cursor.""" + if value is None: + return None + if ( + isinstance(value, bool) + or not isinstance(value, int) + or value < 1 + or value > MAX_CHECKPOINT_AUDIT_EVENT_ID + ): + raise ValidationError( + field="before_audit_event_id", + value=value, + reason=( + "must be a positive integer no greater than PostgreSQL BIGINT maximum" + ), + ) + return value + + def apply_result_checkpoint_audit_schema( postgres_dsn: str, migration_path: Optional[str] = None, @@ -267,3 +317,105 @@ def list_audit_events_in_transaction( if not isinstance(rows, (tuple, list)): raise RuntimeError("checkpoint audit query returned an invalid row collection") return tuple(_audit_event_from_row(row) for row in rows) + + def list_audit_event_page( + self, + consumer_name: str, + batch_id: str, + endpoint_alias: str, + *, + before_audit_event_id: Optional[int] = None, + limit: int = DEFAULT_CHECKPOINT_AUDIT_EVENTS, + ) -> CheckpointAuditPage: + """Return one stable bounded newest-first page for durable audit export.""" + _require_psycopg() + with psycopg.connect(self.postgres_dsn) as conn: + with conn.cursor() as cur: + return self.list_audit_event_page_in_transaction( + cur, + consumer_name, + batch_id, + endpoint_alias, + before_audit_event_id=before_audit_event_id, + limit=limit, + ) + + def list_audit_event_page_in_transaction( + self, + cursor: Any, + consumer_name: str, + batch_id: str, + endpoint_alias: str, + *, + before_audit_event_id: Optional[int] = None, + limit: int = DEFAULT_CHECKPOINT_AUDIT_EVENTS, + ) -> CheckpointAuditPage: + """Read one keyset page through a caller-owned PostgreSQL transaction. + + The continuation cursor is the final returned audit-event identity. A + subsequent page uses a strict ``<`` predicate, so rows inserted later + with larger identities cannot shift already traversed older rows. Hosts + needing one database snapshot across multiple pages should call this + method repeatedly inside their own PostgreSQL REPEATABLE READ or stricter + transaction. + """ + consumer = validate_checkpoint_consumer_name(consumer_name) + remote_batch_id = _validated_batch_id(batch_id) + alias = _validated_exact_endpoint_alias(endpoint_alias) + bounded_limit = validate_checkpoint_audit_limit(limit) + before = validate_checkpoint_audit_cursor(before_audit_event_id) + fetch_size = bounded_limit + 1 + _set_transaction_tenant_scope(cursor, self.tenant_scope) + + query = ( + f"SELECT {_AUDIT_COLUMNS} " + "FROM llm_result_checkpoint_audit_events " + "WHERE tenant_scope = %s " + "AND checkpoint_consumer_name = %s " + "AND endpoint_alias = %s " + "AND remote_batch_id = %s " + ) + params: list[Any] = [ + self.tenant_scope, + consumer, + alias, + remote_batch_id, + ] + if before is not None: + query += "AND checkpoint_audit_event_id < %s " + params.append(before) + query += "ORDER BY checkpoint_audit_event_id DESC LIMIT %s" + params.append(fetch_size) + cursor.execute(query, tuple(params)) + + rows = cursor.fetchall() + if not isinstance(rows, (tuple, list)): + raise RuntimeError("checkpoint audit query returned an invalid row collection") + if len(rows) > fetch_size: + raise RuntimeError("checkpoint audit query exceeded its bounded query size") + + events = tuple(_audit_event_from_row(row) for row in rows) + for event in events: + if ( + event.tenant_scope != self.tenant_scope + or event.consumer_name != consumer + or event.endpoint_alias != alias + or event.batch_id != remote_batch_id + ): + raise RuntimeError("checkpoint audit query returned a row outside the requested key") + if any( + current.audit_event_id >= previous.audit_event_id + for previous, current in zip(events, events[1:]) + ): + raise RuntimeError("checkpoint audit query was not strictly descending") + + page_events = events[:bounded_limit] + next_before = ( + page_events[-1].audit_event_id + if len(events) > bounded_limit and page_events + else None + ) + return CheckpointAuditPage( + events=page_events, + next_before_audit_event_id=next_before, + ) From 3d458040a7647319267eec4ca8b4a7cc0510ff09 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 14:18:04 +0900 Subject: [PATCH 04/17] feat(audit): export bounded audit page contracts --- pg_llm_batch/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pg_llm_batch/__init__.py b/pg_llm_batch/__init__.py index e88f354e4..b9dc82d99 100644 --- a/pg_llm_batch/__init__.py +++ b/pg_llm_batch/__init__.py @@ -10,6 +10,7 @@ BatchResultCheckpoint -- host-persistable resume evidence PostgresBatchResultCheckpointStore -- tenant-isolated durable checkpoints AuditedPostgresBatchResultCheckpointStore -- append-only accepted-save audit + CheckpointAuditPage -- stable bounded audit export page OpenTelemetryCheckpointStore -- confidential checkpoint observability DurableBatchAPIClient -- standalone durable lifecycle state TenantDurableBatchAPIClient -- tenant-isolated lifecycle state @@ -24,9 +25,12 @@ config_credentials_provider, ) from .checkpoint_audit import ( + MAX_CHECKPOINT_AUDIT_EVENT_ID, AuditedPostgresBatchResultCheckpointStore, CheckpointAuditEvent, + CheckpointAuditPage, apply_result_checkpoint_audit_schema, + validate_checkpoint_audit_cursor, validate_checkpoint_audit_limit, ) from .checkpoint_store import ( @@ -76,12 +80,15 @@ "PostgresBatchResultCheckpointStore", "AuditedPostgresBatchResultCheckpointStore", "CheckpointAuditEvent", + "CheckpointAuditPage", + "MAX_CHECKPOINT_AUDIT_EVENT_ID", "OpenTelemetryCheckpointStore", "CheckpointConflictError", "apply_result_checkpoint_schema", "apply_result_checkpoint_audit_schema", "validate_checkpoint_consumer_name", "validate_checkpoint_audit_limit", + "validate_checkpoint_audit_cursor", "DurableBatchAPIClient", "TenantDurableBatchAPIClient", "DEFAULT_TENANT_SCOPE", From 23b234e61dc7d87a1192fe7cbfcfe61675d74adb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 14:19:39 +0900 Subject: [PATCH 05/17] docs(audit): document stable bounded export pages --- docs/checkpoint-audit.md | 59 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/docs/checkpoint-audit.md b/docs/checkpoint-audit.md index 8425b797e..54c6eea14 100644 --- a/docs/checkpoint-audit.md +++ b/docs/checkpoint-audit.md @@ -89,6 +89,50 @@ action, a database-generated event identity, and an insert-time database wall-clock timestamp. It does not include provider bodies, prompts, model output, credentials, DSNs, transport headers, or exception text. +## Export more than one bounded page + +Use keyset pagination rather than `OFFSET` when an operator needs to walk older +audit evidence for export to separately governed retention storage: + +```python +before = None +while True: + page = store.list_audit_event_page( + "invoice-worker", + "batch-123", + "default", + before_audit_event_id=before, + limit=500, + ) + export_to_governed_storage(page.events) + before = page.next_before_audit_event_id + if before is None: + break +``` + +`before_audit_event_id` is either `None` or a strict positive PostgreSQL `BIGINT` +identity. The query requests at most `limit + 1` rows; the extra row is used only +to determine whether a continuation exists. At most `limit` events are exposed. +The next cursor, when present, is exactly the final returned event identity, and +the following query uses `checkpoint_audit_event_id < before_audit_event_id`. +Rows inserted later with larger identities therefore cannot shift rows already +walked or create the duplicate/skip behavior associated with offset pagination. + +This is **not** a multi-page database snapshot. Package-owned calls may observe +changes committed between page requests. A caller that requires all pages from +one PostgreSQL snapshot should start a caller-owned `REPEATABLE READ` or stricter +transaction and repeatedly call `list_audit_event_page_in_transaction()` on that +same cursor. PostgreSQL documents that `REPEATABLE READ` keeps statements in the +transaction on the snapshot established by its first query. The package does not +silently change caller transaction isolation. + +The cursor is a navigation boundary, not evidence of completeness, authenticity, +retention, or non-repudiation. Sequence identities can contain gaps and commit +order need not equal identity allocation order. An export consumer must persist +its own export receipt or manifest if it needs evidence that a particular export +set was delivered to immutable storage. A later row that was not visible during +an earlier read belongs to a later export/reconciliation pass. + ## Retention and rollback The database blocks ordinary `UPDATE`, `DELETE`, and `TRUNCATE` against the audit @@ -101,12 +145,15 @@ These controls are not cryptographic non-repudiation and do not protect against PostgreSQL owner, superuser, `BYPASSRLS` role, disabled triggers, or physical storage administrator. Deployments that require administrator-tamper detection should replicate or export events to separately governed immutable storage or a -signed/hash-chained evidence system. +signed/hash-chained evidence system. Stable bounded pagination makes that export +operationally tractable but does not itself strengthen the database tamper model. ## Standards and evidence -See [ADR 0009](adr/0009-append-only-checkpoint-audit-trail.md) for the decision -boundary and -[the assurance record](doctoring/checkpoint-audit-trail.md) for threat model, -verification evidence, and APA 7 references to NIST SP 800-53 Rev. 5 AU-3, the -OWASP Logging Cheat Sheet, and PostgreSQL 18 trigger and current-time semantics. +See [ADR 0009](adr/0009-append-only-checkpoint-audit-trail.md) for the base audit +decision, [ADR 0010](adr/0010-bounded-checkpoint-audit-export-pagination.md) for +the export pagination boundary, and +[the export assurance record](doctoring/checkpoint-audit-export-pagination.md) +for failure modes, concurrency semantics, verification evidence, and APA 7 +references to NIST SP 800-53 Rev. 5 AU-9 and PostgreSQL 18 transaction-isolation +documentation. From e0ec7445969b6ba5eb745bd35224b2f62752ca8b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 14:20:12 +0900 Subject: [PATCH 06/17] docs(audit): record bounded export pagination decision --- ...nded-checkpoint-audit-export-pagination.md | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 docs/adr/0010-bounded-checkpoint-audit-export-pagination.md diff --git a/docs/adr/0010-bounded-checkpoint-audit-export-pagination.md b/docs/adr/0010-bounded-checkpoint-audit-export-pagination.md new file mode 100644 index 000000000..499b60919 --- /dev/null +++ b/docs/adr/0010-bounded-checkpoint-audit-export-pagination.md @@ -0,0 +1,121 @@ +# ADR 0010: Bounded checkpoint-audit export pagination + +- **Status:** Accepted for the stacked implementation +- **Date:** 2026-08-07 +- **Decision owners:** ContextualWisdomLab +- **Depends on:** ADR 0009 and the append-only checkpoint audit trail + +## Context + +The package-owned audit store previously exposed only one newest-first bounded +read. That is appropriate for an operator console, but not sufficient for a +host that must move a longer retained audit history to separately governed +storage. Leaving multi-page traversal to every embedding product would produce +incompatible cursor semantics and would encourage `OFFSET` pagination, which can +skip or duplicate rows when the visible set changes between requests. + +The existing audit identity is a PostgreSQL `BIGINT GENERATED ALWAYS AS +IDENTITY` primary key. The compound checkpoint-key index already ends in +`checkpoint_audit_event_id DESC`, so the table can support bounded keyset +pagination without a schema migration or a second ordering column. + +## Decision + +Add an opt-in `CheckpointAuditPage` contract and two audit-store methods: + +- `list_audit_event_page()` for package-owned page reads; and +- `list_audit_event_page_in_transaction()` for hosts that own the surrounding + PostgreSQL transaction. + +Each request validates a strict 1..1,000 page size and an optional positive +signed-`BIGINT` `before_audit_event_id`. The first page orders by +`checkpoint_audit_event_id DESC`. Continuations add the strict predicate +`checkpoint_audit_event_id < before_audit_event_id` and preserve the same order. +The SQL query requests only `limit + 1` rows. At most `limit` rows are returned; +the lookahead row only proves that an older continuation exists. When a +continuation exists, `next_before_audit_event_id` is exactly the identity of the +last returned event. + +Every returned row is revalidated as a `CheckpointAuditEvent`, rechecked against +the trusted tenant/consumer/endpoint/batch key, and required to be strictly +descending. A database adapter that returns a non-sequence or more than the +bounded query size fails closed. + +## Concurrency boundary + +Keyset traversal solves offset drift, not snapshot isolation. Rows committed +after page one with identities greater than the cursor cannot move or duplicate +older rows already traversed. However, package-owned calls execute as separate +transactions and therefore do not promise one historic database snapshot. + +A host that requires one snapshot for an export pass must own a PostgreSQL +`REPEATABLE READ` or stricter transaction and call the in-transaction method on +one cursor. The package does not silently alter transaction isolation because +that would change caller-owned database semantics. + +Identity allocation order is not a cryptographic chronology and may contain +gaps. A transaction can allocate an identity before another transaction and +commit later. The cursor is therefore a database navigation key, not a statement +that all real-world events before or after a wall-clock instant have been +captured. + +## Security and acquisition boundary + +The page remains tenant-qualified and inherits forced RLS and ordinary-role +append-only controls from ADR 0009. It does not create a new database object, +credential, network destination, export worker, background process, or write +permission. It never accepts provider or model output as tenant or consumer +identity. + +The feature is intended to make bounded export to separately governed immutable +or write-once retention storage practical. It does not itself provide immutable +external storage, cryptographic non-repudiation, signed completeness evidence, +administrator-proof tamper detection, or delivery acknowledgement. Those remain +host/operator controls. A later cryptographically protected export manifest can +be layered on this stable pagination contract without changing database +navigation semantics. + +## Alternatives rejected + +### SQL OFFSET/LIMIT + +Rejected because additions or visibility changes before an offset can shift the +subsequent window, producing duplicates or omissions during a long-running +export. + +### Unbounded iterator or fetch-all export + +Rejected because acquisition-grade audit volume is not bounded by a single +operator interaction. Materializing arbitrary retained history would weaken the +package's existing memory-safety policy. + +### Implicit REPEATABLE READ in the package-owned API + +Rejected because changing isolation belongs to the owner of the transaction and +must occur before the first transaction query. The in-transaction API gives a +host an explicit way to obtain one snapshot without the package surprising +other database work. + +### Timestamp cursor + +Rejected because timestamps are not unique and database wall-clock values are +not a total order. The existing primary-key identity is already indexed and +provides an unambiguous strict continuation boundary. + +## Verification + +Permanent deterministic tests require strict cursor validation, immutable page +shape, strictly descending identities, one-row lookahead, no `OFFSET`, exact +`<` keyset continuation, trusted-key row revalidation, bounded driver output, +and no package commit during owned page reads. Integration/release gates remain +required after this stack is reconciled onto protected `main`. + +## Consequences + +Operators gain a package-owned bounded traversal primitive suitable for durable +audit export and acquisition diligence. Existing `list_audit_events()` behavior +is unchanged. No migration, release version, or publication authority is added. + +The operational tradeoff is explicit: callers choosing independent page calls +accept normal transaction-to-transaction visibility changes; callers requiring a +single snapshot must own and configure that transaction deliberately. From 77270bbd75177875a10c8bca662feb238869d1a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 14:20:44 +0900 Subject: [PATCH 07/17] docs(audit): add export pagination assurance record --- .../checkpoint-audit-export-pagination.md | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 docs/doctoring/checkpoint-audit-export-pagination.md diff --git a/docs/doctoring/checkpoint-audit-export-pagination.md b/docs/doctoring/checkpoint-audit-export-pagination.md new file mode 100644 index 000000000..819a2075e --- /dev/null +++ b/docs/doctoring/checkpoint-audit-export-pagination.md @@ -0,0 +1,150 @@ +# Checkpoint audit export pagination assurance record + +## Scope + +This record documents the bounded keyset-pagination control used to traverse +`llm_result_checkpoint_audit_events` for export or reconciliation. It supplements +the append-only audit assurance record; it does not replace the database RLS, +mutation-rejection, rollback, or retention controls described there. + +The commercial objective is practical and deliberately narrow: a host must be +able to move more than one bounded page of accepted-save audit evidence into a +separately governed retention system without inventing product-specific +pagination or materializing the entire audit history in memory. + +## Threat and failure model + +The implementation treats tenant scope, consumer name, endpoint alias, batch +identifier, page size, cursor, database row shape, row identity, and row ordering +as validation boundaries. Provider payloads and model output never select audit +scope or cursor state. + +The bounded query uses the existing tenant-qualified checkpoint key and orders by +`checkpoint_audit_event_id DESC`. A continuation uses a strict primary-key +predicate: + +```sql +AND checkpoint_audit_event_id < %s +ORDER BY checkpoint_audit_event_id DESC +LIMIT %s +``` + +The bound passed to SQL is exactly the validated public limit plus one. The extra +row is not returned to the caller; it only determines whether an older page +exists. A database adapter returning more than that bound is treated as an +internal integrity failure rather than silently expanding memory use. + +Every materialized row is reconstructed through the strict +`CheckpointAuditEvent` validator and is then compared to the exact trusted +`(tenant_scope, checkpoint_consumer_name, endpoint_alias, remote_batch_id)` key. +Rows must be strictly descending by audit identity. A cross-key row, duplicate +identity, ascending identity, malformed collection, malformed row, or cursor +outside signed PostgreSQL `BIGINT` range fails closed. + +## Concurrency semantics + +Keyset pagination intentionally avoids `OFFSET`. If page one ends at audit event +`800`, later committed events with larger identities cannot move the continuation +window because page two is anchored by `checkpoint_audit_event_id < 800`. +This prevents the common offset-shift duplicate/skip failure mode for newly +visible higher-key rows. + +This is not equivalent to one database snapshot. PostgreSQL's default isolation +is `READ COMMITTED`, under which each statement sees rows committed before that +statement begins. A package-owned page call therefore may see database changes +that were not visible to the previous call. PostgreSQL documents that +`REPEATABLE READ` instead keeps all statements in one transaction on the snapshot +established by its first query. Hosts that require a single-snapshot export pass +must explicitly begin an appropriate caller-owned transaction before the first +page and repeatedly use `list_audit_event_page_in_transaction()` on that +transaction's cursor. + +The package does not change caller transaction isolation automatically. Doing so +after the first query is prohibited by PostgreSQL, and doing so implicitly would +alter host-owned transaction semantics. + +PostgreSQL identity values are navigation keys, not trusted wall-clock sequence +numbers. Gaps are valid, and allocation/commit ordering can differ across +concurrent transactions. A continuation cursor therefore proves only where the +next keyset query resumes. It does not prove that no later transaction can reveal +an event that was invisible to an earlier export pass. + +## Security and privacy boundary + +NIST SP 800-53 Rev. 5 AU-9 requires protection of audit information against +unauthorized access, modification, and deletion. The underlying audit table +implements tenant-qualified RLS and mutation rejection for ordinary application +roles. This pagination feature does not weaken those controls and does not add a +write-capable database permission. + +NIST also describes stronger audit-protection enhancements such as write-once +media. This package does not claim that PostgreSQL alone provides administrator- +proof immutability. The bounded page API is the transport-neutral extraction +primitive by which an embedding host can copy evidence into a separately governed +immutable or write-once system under its own retention and access-control policy. +No destination, credential, exporter, background scheduler, object-store API, or +network client is introduced here. + +Audit events continue to exclude prompts, provider bodies, model output, +credentials, DSNs, transport headers, and arbitrary exception text. Cursor values +are database identities, not secrets. Diagnostics remain fixed and body-free. + +## Operational workflow + +1. Select a trusted tenant, consumer, endpoint, and batch key after host + authentication and authorization. +2. Choose a strict page limit from 1 through 1,000. +3. For ordinary incremental export, call `list_audit_event_page()` from + `before_audit_event_id=None`, persist each page to the governed destination, + and continue with `page.next_before_audit_event_id` until it is `None`. +4. If one PostgreSQL snapshot is required, begin a caller-owned `REPEATABLE READ` + or stricter transaction before the first read and use the in-transaction page + method for the entire pass. +5. Persist destination-side receipt, manifest, or cryptographic evidence when + the retention requirement needs proof of delivery or completeness. The page + cursor itself is not such proof. +6. Start a later reconciliation pass from the newest page to capture rows that + became visible only after a previous non-snapshot export. + +## Rollback + +No database migration is introduced. Rolling back this feature removes only the +new public pagination API and documentation. The audit table and retained events +are unchanged. Existing `list_audit_events()` callers remain compatible. + +Do not delete audit rows as a feature rollback. The existing rollback migration +continues to refuse removal of non-empty audit evidence. + +## Verification contract + +Permanent tests cover: + +- strict `None` or positive signed-`BIGINT` cursor validation without coercion; +- immutable page tuples and a maximum of 1,000 returned events; +- strictly descending unique audit identities; +- a continuation cursor equal to the final returned identity; +- one-row bounded lookahead and a maximum SQL row request of 1,001; +- first-page and continuation SQL without `OFFSET`; +- strict `<` continuation semantics; +- trusted tenant/consumer/endpoint/batch revalidation on returned rows; +- fail-closed malformed collection, impossible driver overrun, and row-order + behavior; and +- package-owned reads that do not create a commit boundary. + +Final merge evidence is valid only after the full stacked dependency chain has +integrated and all required exact-head quality, security, coverage, packaging, +review, provenance, and release-acceptance gates have been regenerated against +the protected base. + +## APA 7 references + +National Institute of Standards and Technology. (2020, updated 2025). *Security +and privacy controls for information systems and organizations* (NIST Special +Publication 800-53, Revision 5). U.S. Department of Commerce. +https://doi.org/10.6028/NIST.SP.800-53r5 + +PostgreSQL Global Development Group. (2026). *SET TRANSACTION*. PostgreSQL 18 +documentation. https://www.postgresql.org/docs/18/sql-set-transaction.html + +PostgreSQL Global Development Group. (2026). *Concurrency control*. PostgreSQL +18 documentation. https://www.postgresql.org/docs/18/mvcc.html From a85bd29f20852585716e7a94188abc3e6ee82934 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 14:21:14 +0900 Subject: [PATCH 08/17] docs(audit): record bounded export pagination --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed6384f1a..44eeb18fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Stable bounded checkpoint-audit export pages through `CheckpointAuditPage`, + `list_audit_event_page()`, and `list_audit_event_page_in_transaction()`. The + cursor is a strict positive PostgreSQL `BIGINT`; continuation uses + `checkpoint_audit_event_id < before_audit_event_id` rather than `OFFSET`, the + SQL query requests at most one bounded lookahead row, returned rows are + revalidated against the exact trusted audit key and strict descending order, + and package-owned page reads do not create a commit boundary. Hosts that need + one database snapshot across pages must own a `REPEATABLE READ` or stricter + transaction. The cursor is navigation state rather than completeness, + authenticity, or non-repudiation evidence. No migration, exporter credential, + version bump, or release is included. - Optional append-only checkpoint accepted-save audit evidence through `AuditedPostgresBatchResultCheckpointStore` and `llm_result_checkpoint_audit_events`. Successful save calls append a fixed From a5b2772456d92297a12d2e1dc2d82ac0842cc7c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 14:22:32 +0900 Subject: [PATCH 09/17] docs(audit): add export pagination agent contract --- AGENTS.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index f1fd6a1a5..458ff7b2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -237,3 +237,33 @@ add CODEOWNERS-based merge gates until multiple independent maintainers exist. deterministic transaction, event-time, tenant, validation, bounded-read, migration, rollback, immutability-trigger, compatibility, and documentation tests. + +## Checkpoint audit export pagination contract + +- Keep pagination opt-in and preserve `list_audit_events()` behavior. Do not + replace the existing bounded convenience read with an unbounded iterator. +- Use `checkpoint_audit_event_id` as a strict positive signed-PostgreSQL-`BIGINT` + keyset cursor. Do not use `OFFSET` for multi-page audit traversal. +- Query at most the validated public limit plus one lookahead row and expose at + most the requested 1..1,000 events. A driver returning more than the SQL bound + fails closed. +- Continuations must use `checkpoint_audit_event_id < before_audit_event_id` in + exact newest-first order. When more rows exist, the next cursor is exactly the + final returned event identity. +- Revalidate every database row through `CheckpointAuditEvent`, compare its + tenant/consumer/endpoint/batch key to the trusted request, and require strict + descending identities before exposing a page. +- Treat the cursor as navigation state only. It is not proof of completeness, + chronology, authenticity, delivery, or non-repudiation; identity allocation + can contain gaps and commit ordering can differ from allocation ordering. +- Package-owned page calls do not promise a single historic snapshot. Hosts + requiring one export snapshot must begin their own PostgreSQL `REPEATABLE READ` + or stricter transaction before the first query and repeatedly call + `list_audit_event_page_in_transaction()` on the same transaction. +- Keep export destinations, credentials, retention policy, immutable/WORM + storage, delivery receipts, cryptographic manifests, and reconciliation in the + host/operator boundary. This package primitive adds no network exporter or + write authority. +- Maintain 100% production statement, branch, and public-docstring coverage with + strict cursor, page-shape, lookahead, keyset SQL, ordering, row-key, + malformed-driver, concurrency-semantics, and transaction-ownership tests. From 6b9cffe60c376cfda83ab982bf3ddd207f2d4b1a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 14:23:16 +0900 Subject: [PATCH 10/17] docs(audit): add pagination implementation invariants --- CLAUDE.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 23e3959b8..5c3ff2d12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -214,3 +214,34 @@ - Maintain test-first transaction, event-time, validation, bounded-read, migration, rollback, trigger, compatibility, documentation, and 100% production statement/branch/public-docstring coverage. + +## Checkpoint audit export pagination invariants + +- Preserve `list_audit_events()` and make multi-page export explicit through + `CheckpointAuditPage` and the two `list_audit_event_page*` methods. +- Accept only `None` or a positive signed-PostgreSQL-`BIGINT` continuation + identity. Never coerce strings, floats, booleans, or oversized integers. +- Use strict newest-first keyset pagination with + `checkpoint_audit_event_id < before_audit_event_id`; never introduce + `OFFSET` into the export path. +- Fetch only `limit + 1` rows to determine continuation and return at most the + validated 1..1,000 public events. Treat a driver that returns more as an + integrity failure. +- Revalidate each row through `CheckpointAuditEvent`, require the exact trusted + tenant/consumer/endpoint/batch key, and reject duplicate or non-descending + identities before constructing the immutable page. +- Set `next_before_audit_event_id` only when a lookahead row proves an older + continuation, and bind it exactly to the last returned identity. +- Do not call cursor traversal a snapshot, chronology proof, delivery receipt, + completeness proof, or tamper evidence. Identity sequences can contain gaps + and commit order can differ from identity allocation order. +- Leave transaction isolation under caller ownership. A host requiring one + export snapshot must establish `REPEATABLE READ` or stronger before its first + query and reuse one caller-owned transaction with the in-transaction method. +- Keep external immutable/WORM retention, exporter credentials, manifests, + receipts, and reconciliation outside the package. Pagination adds no network + client or database write authority. +- Maintain deterministic tests for cursor validation, immutable page shape, + bounded lookahead, SQL keyset semantics, key/order revalidation, malformed + adapter output, owned-read commit behavior, and 100% production + statement/branch/public-docstring coverage. From 551858960565650635b7705c8c03c7e004afa54f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 14:24:03 +0900 Subject: [PATCH 11/17] test(audit): bound public audit identities to PostgreSQL bigint --- .../test_checkpoint_audit_bigint_contract.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 tests/test_checkpoint_audit_bigint_contract.py diff --git a/tests/test_checkpoint_audit_bigint_contract.py b/tests/test_checkpoint_audit_bigint_contract.py new file mode 100644 index 000000000..7c85e10a3 --- /dev/null +++ b/tests/test_checkpoint_audit_bigint_contract.py @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: Apache-2.0 +"""PostgreSQL BIGINT compatibility tests for public checkpoint-audit identities.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from pg_llm_batch.checkpoint_audit import ( + MAX_CHECKPOINT_AUDIT_EVENT_ID, + CheckpointAuditEvent, +) + + +def _event(event_id: int) -> CheckpointAuditEvent: + """Build one otherwise-valid accepted-save event with a selected identity.""" + return CheckpointAuditEvent( + audit_event_id=event_id, + tenant_scope="tenant-a", + consumer_name="worker-a", + endpoint_alias="default", + batch_id="batch-1", + action="checkpoint_save_accepted", + schema_version=1, + file_kind="result", + file_id="file-1", + file_line_number=1, + batch_line_count=1, + record_count=1, + prefix_sha256="a" * 64, + recorded_at=datetime(2026, 8, 7, tzinfo=timezone.utc), + ) + + +def test_public_audit_event_identity_matches_postgresql_bigint_domain() -> None: + """Direct event construction cannot represent an identity PostgreSQL cannot store.""" + assert _event(MAX_CHECKPOINT_AUDIT_EVENT_ID).audit_event_id == MAX_CHECKPOINT_AUDIT_EVENT_ID + with pytest.raises(ValueError, match="PostgreSQL BIGINT"): + _event(MAX_CHECKPOINT_AUDIT_EVENT_ID + 1) From 2ee755a1fd13057e40c6573186ff985e5578297a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 14:24:43 +0900 Subject: [PATCH 12/17] fix(audit): align page identities with PostgreSQL bigint --- pg_llm_batch/checkpoint_audit.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/pg_llm_batch/checkpoint_audit.py b/pg_llm_batch/checkpoint_audit.py index 041dddd2d..f6f85ad88 100644 --- a/pg_llm_batch/checkpoint_audit.py +++ b/pg_llm_batch/checkpoint_audit.py @@ -59,8 +59,11 @@ def __post_init__(self) -> None: isinstance(self.audit_event_id, bool) or not isinstance(self.audit_event_id, int) or self.audit_event_id <= 0 + or self.audit_event_id > MAX_CHECKPOINT_AUDIT_EVENT_ID ): - raise ValueError("audit_event_id must be a positive integer") + raise ValueError( + "audit_event_id must be a positive PostgreSQL BIGINT-compatible integer" + ) validate_tenant_scope(self.tenant_scope) validate_checkpoint_consumer_name(self.consumer_name) _validated_exact_endpoint_alias(self.endpoint_alias) @@ -395,13 +398,15 @@ def list_audit_event_page_in_transaction( raise RuntimeError("checkpoint audit query exceeded its bounded query size") events = tuple(_audit_event_from_row(row) for row in rows) + expected_key = (self.tenant_scope, consumer, alias, remote_batch_id) for event in events: - if ( - event.tenant_scope != self.tenant_scope - or event.consumer_name != consumer - or event.endpoint_alias != alias - or event.batch_id != remote_batch_id - ): + event_key = ( + event.tenant_scope, + event.consumer_name, + event.endpoint_alias, + event.batch_id, + ) + if event_key != expected_key: raise RuntimeError("checkpoint audit query returned a row outside the requested key") if any( current.audit_event_id >= previous.audit_event_id @@ -411,9 +416,7 @@ def list_audit_event_page_in_transaction( page_events = events[:bounded_limit] next_before = ( - page_events[-1].audit_event_id - if len(events) > bounded_limit and page_events - else None + page_events[-1].audit_event_id if len(events) > bounded_limit else None ) return CheckpointAuditPage( events=page_events, From b10cc7b27ef76ef67a5b2db09ff5b8c0f09c6416 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 14:25:57 +0900 Subject: [PATCH 13/17] docs(audit): define bounded export pagination architecture --- ARCHITECTURE.md | 51 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d9afe42b5..25da24723 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -261,6 +261,43 @@ existing volumes require explicit reviewed migration because Docker entrypoint initialization is not an upgrade mechanism. Audit retention, export, legal hold, and disposal remain host/operator policy. +## Bounded checkpoint-audit export boundary + +`CheckpointAuditPage`, `list_audit_event_page()`, and +`list_audit_event_page_in_transaction()` add an opt-in bounded traversal layer +without changing the existing one-page audit read or database schema. The page +identity remains the exact tenant, consumer, endpoint, and remote-batch key. + +Pagination is primary-key keyset traversal rather than offset traversal. The +first page orders by `checkpoint_audit_event_id DESC`; a continuation adds +`checkpoint_audit_event_id < before_audit_event_id` with the same ordering. The +cursor is strict, non-coercive, positive, and bounded by PostgreSQL signed +`BIGINT`. Each query requests at most the validated public limit plus one +lookahead row and exposes at most 1,000 events. The lookahead is used only to +decide whether an older continuation exists. + +Rows are not trusted merely because PostgreSQL returned them. Every row is +reconstructed through the public event validator, compared to the exact trusted +request key, and required to be strictly descending by audit identity. A malformed +collection, impossible driver overrun, cross-key row, duplicate identity, or +ascending identity fails closed before a page is exposed. + +Keyset traversal prevents later higher-identity inserts from shifting an older +continuation window, but it is not a multi-page database snapshot. Package-owned +page calls may observe different committed database states. A host that requires +one snapshot for a long export pass must begin a caller-owned PostgreSQL +`REPEATABLE READ` or stricter transaction before the first query and repeatedly +call the in-transaction method on that same transaction. The package never +silently changes caller isolation. + +Audit identities are database navigation keys, not cryptographic chronology. +Identity sequences may contain gaps and allocation order can differ from commit +order. A cursor therefore does not prove completeness, delivery, authenticity, +retention, or non-repudiation. Hosts still own external immutable/WORM storage, +export receipts, cryptographic manifests where required, reconciliation, legal +hold, and disposal. This boundary exists so those controls can consume retained +evidence without unbounded package memory or offset drift. + ## Checkpoint OpenTelemetry observability boundary `OpenTelemetryCheckpointStore` is an opt-in wrapper around a durable checkpoint @@ -327,7 +364,10 @@ place local PostgreSQL effects and `save_in_transaction()` on the same caller cursor; cross-system effects remain host-owned recovery boundaries. Hosts that choose the audited store gain transaction-coupled accepted-save evidence but still own identity authorization, retention, export, and stronger tamper-proof -controls where required. +controls where required. Hosts may use bounded audit-page keyset traversal as a +standalone export primitive or embed it into a larger MSA retention workflow; +package pagination itself does not require `contextual-orchestrator`, `naruon`, +or a network export service. ## Verification boundary @@ -367,9 +407,12 @@ insert-time wall-clock semantics, rejected-save no-success evidence, strict even revalidation, tenant-qualified bounded reads, package/container migration identity and idempotent timestamp-default repair, forced RLS, UPDATE/DELETE and TRUNCATE rejection, fresh-image installation order, fail-closed non-empty -rollback, and explicit administrator-tamper exclusions. Checkpoint telemetry -tests additionally prove exact delegation, fixed low-cardinality signal -attributes, storage-agnostic operation spans, seconds-based nonnegative duration, +rollback, explicit administrator-tamper exclusions, strict PostgreSQL-BIGINT +identity compatibility, immutable page shape, bounded lookahead, keyset cursor +validation, trusted-key row revalidation, strict descending order, malformed +driver output, and package-owned no-commit page reads. Checkpoint telemetry tests +additionally prove exact delegation, fixed low-cardinality signal attributes, +storage-agnostic operation spans, seconds-based nonnegative duration, confidential failure classification, explicit Error status without descriptions, Unset success status, disabled exception recording, and preservation of application results and exception identity during ordinary tracer, meter, span, From 6b1fc88a9dba68ee2c135a3080d992c795acd133 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 14:29:16 +0900 Subject: [PATCH 14/17] docs(audit): clarify page transaction ownership --- CHANGELOG.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44eeb18fe..22d32e232 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `list_audit_event_page()`, and `list_audit_event_page_in_transaction()`. The cursor is a strict positive PostgreSQL `BIGINT`; continuation uses `checkpoint_audit_event_id < before_audit_event_id` rather than `OFFSET`, the - SQL query requests at most one bounded lookahead row, returned rows are - revalidated against the exact trusted audit key and strict descending order, - and package-owned page reads do not create a commit boundary. Hosts that need + SQL query requests at most one bounded lookahead row, and returned rows are + revalidated against the exact trusted audit key and strict descending order. + Each package-owned page uses its own database transaction; the in-transaction + method never commits or rolls back the caller's transaction. Hosts that need one database snapshot across pages must own a `REPEATABLE READ` or stricter transaction. The cursor is navigation state rather than completeness, authenticity, or non-repudiation evidence. No migration, exporter credential, From 04a3e482f14c06857acd408ad97ad344f1a682df Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 14:31:58 +0900 Subject: [PATCH 15/17] test(audit): verify live keyset export across new inserts --- tests/test_checkpoint_audit_integration.py | 47 ++++++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/tests/test_checkpoint_audit_integration.py b/tests/test_checkpoint_audit_integration.py index 4660a945c..78d46ab33 100644 --- a/tests/test_checkpoint_audit_integration.py +++ b/tests/test_checkpoint_audit_integration.py @@ -145,9 +145,7 @@ def test_live_audit_is_tenant_isolated_append_only_and_rollback_safe() -> None: raise RuntimeError("audit identity sequence could not be resolved") audit_sequence_parts = tuple(sequence_row[0]) cursor.execute( - sql.SQL( - "GRANT USAGE, SELECT ON SEQUENCE {} TO {}" - ).format( + sql.SQL("GRANT USAGE, SELECT ON SEQUENCE {} TO {}").format( sql.Identifier(*audit_sequence_parts), sql.Identifier(application_role_name), ) @@ -182,6 +180,47 @@ def test_live_audit_is_tenant_isolated_append_only_and_rollback_safe() -> None: assert {event.tenant_scope for event in events_a} == {"tenant-a"} assert {event.tenant_scope for event in events_b} == {"tenant-b"} + # Real separate transactions exercise the operational export case: page + # one is read, a newer accepted-save event commits, and page two continues + # strictly toward older identities without OFFSET drift. + assert tenant_a.save(consumer, first_a) == first_a + assert tenant_a.save(consumer, first_a) == first_a + first_page = tenant_a.list_audit_event_page( + consumer, + batch_id, + "default", + limit=2, + ) + first_page_ids = tuple(event.audit_event_id for event in first_page.events) + assert len(first_page_ids) == 2 + assert first_page_ids[0] > first_page_ids[1] + assert first_page.next_before_audit_event_id == first_page_ids[-1] + + assert tenant_a.save(consumer, first_a) == first_a + newest_after_first_page = tenant_a.list_audit_events( + consumer, + batch_id, + "default", + limit=1, + )[0].audit_event_id + assert newest_after_first_page > first_page_ids[0] + + second_page = tenant_a.list_audit_event_page( + consumer, + batch_id, + "default", + before_audit_event_id=first_page.next_before_audit_event_id, + limit=2, + ) + second_page_ids = tuple(event.audit_event_id for event in second_page.events) + assert len(second_page_ids) == 2 + assert set(first_page_ids).isdisjoint(second_page_ids) + assert newest_after_first_page not in second_page_ids + assert all( + event_id < first_page.next_before_audit_event_id + for event_id in second_page_ids + ) + timed_batch_id = f"timed-{suffix}" timed_checkpoint = _checkpoint(timed_batch_id, "c" * 64) with psycopg.connect(application_role_dsn) as timed_connection: @@ -258,7 +297,7 @@ def test_live_audit_is_tenant_isolated_append_only_and_rollback_safe() -> None: assert rollback_error.value.sqlstate == "55000" database_admin.rollback() - assert len(tenant_a.list_audit_events(consumer, batch_id, "default")) == 2 + assert len(tenant_a.list_audit_events(consumer, batch_id, "default")) == 5 assert len(tenant_b.list_audit_events(consumer, batch_id, "default")) == 1 finally: with psycopg.connect(ADMIN_DSN, autocommit=True) as cluster_admin: From 0986987894c0dc83b1182ad77b94483339b774ca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 14:35:49 +0900 Subject: [PATCH 16/17] test(audit): require returned rows to honor keyset cursor --- tests/test_checkpoint_audit_pagination.py | 29 ++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/tests/test_checkpoint_audit_pagination.py b/tests/test_checkpoint_audit_pagination.py index fff642e19..e00ee5467 100644 --- a/tests/test_checkpoint_audit_pagination.py +++ b/tests/test_checkpoint_audit_pagination.py @@ -104,7 +104,7 @@ def cursor(self) -> FakeCursor: return self.fake_cursor def commit(self) -> None: - """Record an unexpected package commit if production attempts one.""" + """Record an unexpected explicit package commit if production attempts one.""" self.commits += 1 @@ -194,6 +194,7 @@ def test_first_export_page_uses_one_bounded_lookahead_row( query, params = cursor.calls[-1] assert "checkpoint_audit_event_id < %s" not in query assert "ORDER BY checkpoint_audit_event_id DESC LIMIT %s" in query + assert "OFFSET" not in query assert params == ("tenant-a", "worker-a", "default", "batch-1", 3) @@ -221,9 +222,31 @@ def test_next_export_page_is_keyset_bound_and_ignores_newer_concurrent_rows( assert page.next_before_audit_event_id is None query, params = cursor.calls[-1] assert "checkpoint_audit_event_id < %s" in query + assert "OFFSET" not in query assert params == ("tenant-a", "worker-a", "default", "batch-1", 8, 3) +def test_continuation_page_revalidates_returned_identity_against_cursor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A faulty adapter cannot return an event at or newer than the strict cursor.""" + monkeypatch.setattr(checkpoint_audit, "_set_transaction_tenant_scope", lambda *_: None) + store = checkpoint_audit.AuditedPostgresBatchResultCheckpointStore( + "postgresql://unit", + tenant_scope="tenant-a", + ) + + with pytest.raises(RuntimeError, match="violated the continuation cursor"): + store.list_audit_event_page_in_transaction( + FakeCursor(rows=(_row(8), _row(7))), + "worker-a", + "batch-1", + "default", + before_audit_event_id=8, + limit=2, + ) + + def test_export_page_revalidates_database_key_and_descending_order( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -279,10 +302,10 @@ def test_export_page_fails_closed_on_invalid_collection_or_driver_overrun( ) -def test_owned_export_page_uses_one_connection_without_committing( +def test_owned_export_page_uses_one_connection_without_explicit_commit( monkeypatch: pytest.MonkeyPatch, ) -> None: - """The package-owned page read preserves the existing read-only transaction boundary.""" + """The package-owned read delegates through one connection without calling commit().""" cursor = FakeCursor(rows=(_row(3),)) connection = FakeConnection(cursor) fake_psycopg = FakePsycopg(connection) From 207ceb48de33d599673d7d888523e17b81276866 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 7 Aug 2026 14:36:39 +0900 Subject: [PATCH 17/17] fix(audit): fail closed on cursor-violating rows --- pg_llm_batch/checkpoint_audit.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pg_llm_batch/checkpoint_audit.py b/pg_llm_batch/checkpoint_audit.py index f6f85ad88..16b2f3d0e 100644 --- a/pg_llm_batch/checkpoint_audit.py +++ b/pg_llm_batch/checkpoint_audit.py @@ -408,6 +408,10 @@ def list_audit_event_page_in_transaction( ) if event_key != expected_key: raise RuntimeError("checkpoint audit query returned a row outside the requested key") + if before is not None and any( + event.audit_event_id >= before for event in events + ): + raise RuntimeError("checkpoint audit query violated the continuation cursor") if any( current.audit_event_id >= previous.audit_event_id for previous, current in zip(events, events[1:])