Skip to content

Commit d6dd2a8

Browse files
authored
fix(durable): bound provider lifecycle fields (#170)
* test(durable): require bounded lifecycle fields * test(durable): preserve sparse lifecycle compatibility * fix(durable): validate persisted lifecycle vocabulary * docs(durable): record verified lifecycle vocabulary * test(durable): align NUL lifecycle contract
1 parent 1ccdbf6 commit d6dd2a8

4 files changed

Lines changed: 272 additions & 22 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Durable provider lifecycle field contract
2+
3+
## Status
4+
5+
ACTIVE-PR. This assurance note describes the bounded validation introduced by the
6+
current lifecycle-field change. It becomes IMPLEMENTED-ON-PROTECTED-MAIN only
7+
after the exact source is accepted and merged through repository governance.
8+
9+
## Problem boundary
10+
11+
Provider lifecycle observations are untrusted control-plane evidence. A durable
12+
projection must not persist arbitrary non-empty status or endpoint strings and
13+
then infer terminal semantics from those unverified values. At the same time,
14+
existing sparse observations remain compatible: an absent/empty status maps to
15+
the historical `unknown` state and an absent/empty endpoint remains `None`.
16+
17+
The durable persistence boundary therefore validates every present non-empty
18+
status and endpoint before PostgreSQL acquisition. Unsupported values fail with
19+
fixed package-owned error categories and the rejected provider value is not
20+
copied into the error message. The HTTP client's broader endpoint grammar is a
21+
separate transport-compatibility boundary; durable evidence intentionally claims
22+
only the first-party Batch vocabulary verified here.
23+
24+
## Verified vocabulary
25+
26+
As checked on 2026-08-13, the OpenAI Batch API reference documents Batch support
27+
for `/v1/responses`, `/v1/chat/completions`, `/v1/embeddings`,
28+
`/v1/completions`, and `/v1/moderations`. The lifecycle vocabulary represented
29+
by the Batch object and cancellation flow is `validating`, `failed`,
30+
`in_progress`, `finalizing`, `completed`, `expired`, `cancelling`, and
31+
`cancelled`.
32+
33+
`completed`, `failed`, `expired`, and `cancelled` are the package's durable
34+
terminal set. `cancelling` remains transitional and must not receive a terminal
35+
timestamp merely because cancellation has been requested.
36+
37+
OpenAI-compatible providers may expose extensions, but pg-llm-batch does not
38+
silently promote an unverified extension into durable semantics. Supporting an
39+
additional endpoint or status requires a reviewed compatibility change and
40+
fresh regression/provider evidence.
41+
42+
## Failure, rollback, and recovery
43+
44+
This change adds no schema migration and does not rewrite historical lifecycle
45+
rows. Rejected new observations fail before database I/O. Rollback is therefore
46+
a source rollback of the validation change; existing PostgreSQL state is not
47+
transformed by that rollback.
48+
49+
If an embedding provider adds a legitimate new lifecycle value, operators should
50+
not weaken validation locally. The recovery path is to verify the provider
51+
contract, add the value to the reviewed finite set with tests and documentation,
52+
and deploy that accepted package revision.
53+
54+
## Verification expectations
55+
56+
Acceptance requires regressions proving that unsupported, oversized,
57+
control-bearing, and non-string present values fail before PostgreSQL access;
58+
that sparse absence preserves the prior safe defaults; that every currently
59+
verified endpoint/status is accepted; and that terminal timestamps are assigned
60+
only to the reviewed terminal subset. Repository Python 3.10/3.12/3.14, exact
61+
owned-production statement/branch coverage, docstrings, packaging, security,
62+
SAST, PostgreSQL/container, and required central review gates remain mandatory.
63+
64+
## Primary reference
65+
66+
OpenAI. (2026). *Batch | OpenAI API reference*.
67+
https://platform.openai.com/docs/api-reference/batch/object

pg_llm_batch/db.py

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,27 @@
3838
TENANT_SCOPE_PATTERN = re.compile(
3939
rf"[A-Za-z0-9][A-Za-z0-9._:-]{{0,{MAX_TENANT_SCOPE_CHARACTERS - 1}}}\Z"
4040
)
41+
SUPPORTED_REMOTE_BATCH_STATUSES = frozenset(
42+
{
43+
"validating",
44+
"failed",
45+
"in_progress",
46+
"finalizing",
47+
"completed",
48+
"expired",
49+
"cancelling",
50+
"cancelled",
51+
}
52+
)
53+
SUPPORTED_REMOTE_BATCH_ENDPOINTS = frozenset(
54+
{
55+
"/v1/responses",
56+
"/v1/chat/completions",
57+
"/v1/embeddings",
58+
"/v1/completions",
59+
"/v1/moderations",
60+
}
61+
)
4162
REMOTE_TERMINAL_STATUSES = frozenset({"completed", "failed", "expired", "cancelled"})
4263
_REMOTE_BATCH_STATE_FIELDS = (
4364
"tenant_scope",
@@ -113,6 +134,26 @@ def normalize_optional_provider_text(value: Any) -> Optional[str]:
113134
)
114135

115136

137+
def _provider_batch_status(value: Any) -> str:
138+
"""Return one verified durable lifecycle status or the sparse safe default."""
139+
if value is None or value == "":
140+
return "unknown"
141+
if type(value) is not str or value not in SUPPORTED_REMOTE_BATCH_STATUSES:
142+
raise ValueError("batch_status is not a supported provider status")
143+
return value
144+
145+
146+
def _provider_batch_endpoint(value: Any) -> Optional[str]:
147+
"""Return one verified durable batch endpoint or preserve sparse absence."""
148+
if value is None or value == "":
149+
return None
150+
if type(value) is not str or value not in SUPPORTED_REMOTE_BATCH_ENDPOINTS:
151+
raise ValueError(
152+
"batch_endpoint is not a supported provider batch endpoint"
153+
)
154+
return value
155+
156+
116157
def _provider_count(value: Any) -> int:
117158
"""Return a non-negative integer provider count or the safe default zero."""
118159
return value if type(value) is int and value >= 0 else 0
@@ -377,10 +418,8 @@ def _normalize_remote_batch_snapshot(
377418
provider_batch.get("error_file_id"),
378419
"error_file_id",
379420
)
380-
batch_status = (
381-
normalize_optional_provider_text(provider_batch.get("status"))
382-
or "unknown"
383-
)
421+
batch_status = _provider_batch_status(provider_batch.get("status"))
422+
batch_endpoint = _provider_batch_endpoint(provider_batch.get("endpoint"))
384423
counts_value = provider_batch.get("request_counts")
385424
request_counts = counts_value if isinstance(counts_value, Mapping) else {}
386425
raw_total_requests = request_counts.get("total")
@@ -405,9 +444,7 @@ def _normalize_remote_batch_snapshot(
405444
"remote_batch_id": remote_batch_id,
406445
"observation_order": observation_order,
407446
"input_file_id": input_file_id,
408-
"batch_endpoint": normalize_optional_provider_text(
409-
provider_batch.get("endpoint")
410-
),
447+
"batch_endpoint": batch_endpoint,
411448
"batch_status": batch_status,
412449
"output_file_id": output_file_id,
413450
"error_file_id": error_file_id,
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
"""Regression tests for durable provider lifecycle field validation."""
3+
4+
from __future__ import annotations
5+
6+
from datetime import datetime, timezone
7+
8+
import pytest
9+
10+
from pg_llm_batch import db
11+
12+
13+
class _NoDatabaseIO:
14+
"""Fail if invalid provider lifecycle fields reach PostgreSQL acquisition."""
15+
16+
def __init__(self) -> None:
17+
self.connections: list[str] = []
18+
19+
def connect(self, dsn: str):
20+
"""Record an unexpected connection attempt and fail the test immediately."""
21+
self.connections.append(dsn)
22+
raise AssertionError("invalid lifecycle fields reached PostgreSQL")
23+
24+
25+
def _provider_batch(*, status: object, endpoint: object) -> dict[str, object]:
26+
"""Build one otherwise-valid provider lifecycle observation."""
27+
return {
28+
"id": "batch_contract_1",
29+
"status": status,
30+
"endpoint": endpoint,
31+
"request_counts": {"total": 1, "completed": 0, "failed": 0},
32+
}
33+
34+
35+
@pytest.mark.parametrize(
36+
"status",
37+
["future_state", "COMPLETED", "x" * 65, "completed\x00secret", 7],
38+
)
39+
def test_persistence_rejects_unsupported_status_before_database_io(
40+
monkeypatch: pytest.MonkeyPatch,
41+
status: object,
42+
) -> None:
43+
"""Reject unsupported provider status evidence before PostgreSQL mutation."""
44+
driver = _NoDatabaseIO()
45+
monkeypatch.setattr(db, "psycopg", driver)
46+
47+
with pytest.raises(
48+
ValueError,
49+
match="batch_status is not a supported provider status",
50+
) as exc:
51+
db.persist_remote_batch_state(
52+
"postgresql://should-not-connect",
53+
"default",
54+
_provider_batch(status=status, endpoint="/v1/responses"),
55+
1,
56+
observed_at=datetime(2026, 8, 13, tzinfo=timezone.utc),
57+
)
58+
59+
assert str(status) not in str(exc.value)
60+
assert driver.connections == []
61+
62+
63+
@pytest.mark.parametrize(
64+
"endpoint",
65+
[
66+
"/v1/future-endpoint",
67+
"/v1/chat/completions?debug=1",
68+
"/v1/../chat/completions",
69+
"/v1/responses\x00secret",
70+
7,
71+
],
72+
)
73+
def test_persistence_rejects_unsupported_endpoint_before_database_io(
74+
monkeypatch: pytest.MonkeyPatch,
75+
endpoint: object,
76+
) -> None:
77+
"""Reject unsupported provider endpoint evidence before PostgreSQL mutation."""
78+
driver = _NoDatabaseIO()
79+
monkeypatch.setattr(db, "psycopg", driver)
80+
81+
with pytest.raises(
82+
ValueError,
83+
match="batch_endpoint is not a supported provider batch endpoint",
84+
) as exc:
85+
db.persist_remote_batch_state(
86+
"postgresql://should-not-connect",
87+
"default",
88+
_provider_batch(status="validating", endpoint=endpoint),
89+
1,
90+
observed_at=datetime(2026, 8, 13, tzinfo=timezone.utc),
91+
)
92+
93+
assert str(endpoint) not in str(exc.value)
94+
assert driver.connections == []
95+
96+
97+
def test_sparse_status_and_endpoint_preserve_legacy_safe_defaults() -> None:
98+
"""Absent lifecycle fields retain the historical sparse-response contract."""
99+
observed = datetime(2026, 8, 13, tzinfo=timezone.utc)
100+
snapshot, _ = db._normalize_remote_batch_snapshot(
101+
"standalone",
102+
"default",
103+
_provider_batch(status=None, endpoint=None),
104+
1,
105+
observed,
106+
)
107+
assert snapshot["batch_status"] == "unknown"
108+
assert snapshot["batch_endpoint"] is None
109+
assert snapshot["terminal_at"] is None
110+
111+
112+
def test_official_openai_statuses_and_endpoints_normalize_deterministically() -> None:
113+
"""Accept the currently documented OpenAI Batch status and endpoint sets."""
114+
statuses = (
115+
"validating",
116+
"failed",
117+
"in_progress",
118+
"finalizing",
119+
"completed",
120+
"expired",
121+
"cancelling",
122+
"cancelled",
123+
)
124+
endpoints = (
125+
"/v1/responses",
126+
"/v1/chat/completions",
127+
"/v1/embeddings",
128+
"/v1/completions",
129+
"/v1/moderations",
130+
)
131+
observed = datetime(2026, 8, 13, tzinfo=timezone.utc)
132+
133+
for index, status in enumerate(statuses, start=1):
134+
endpoint = endpoints[(index - 1) % len(endpoints)]
135+
snapshot, _ = db._normalize_remote_batch_snapshot(
136+
"standalone",
137+
"default",
138+
_provider_batch(status=status, endpoint=endpoint),
139+
index,
140+
observed,
141+
)
142+
assert snapshot["batch_status"] == status
143+
assert snapshot["batch_endpoint"] == endpoint
144+
assert (snapshot["terminal_at"] is observed) is (
145+
status in {"failed", "completed", "expired", "cancelled"}
146+
)

tests/test_remote_batch_state_contracts.py

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -428,27 +428,27 @@ def recorder(
428428
assert chr(0) not in repr(recorded)
429429

430430

431-
def test_remote_field_contract_normalizes_nul_optional_text(
431+
def test_remote_field_contract_rejects_nul_lifecycle_text_before_database_access(
432432
monkeypatch: Any,
433433
) -> None:
434-
"""NUL-bearing descriptive provider text cannot reach PostgreSQL columns."""
434+
"""NUL-bearing lifecycle status fails before PostgreSQL persistence."""
435435
driver = _Psycopg()
436436
monkeypatch.setattr(db, "psycopg", driver)
437437

438-
snapshot = db.persist_remote_batch_state(
439-
"postgresql://example",
440-
"primary",
441-
{
442-
"id": "batch-1",
443-
"endpoint": f"/v1/responses{chr(0)}shadow",
444-
"status": f"completed{chr(0)}shadow",
445-
},
446-
observation_order=6,
447-
)
438+
with pytest.raises(ValueError, match="batch_status"):
439+
db.persist_remote_batch_state(
440+
"postgresql://example",
441+
"primary",
442+
{
443+
"id": "batch-1",
444+
"endpoint": f"/v1/responses{chr(0)}shadow",
445+
"status": f"completed{chr(0)}shadow",
446+
},
447+
observation_order=6,
448+
)
448449

449-
assert snapshot["batch_endpoint"] is None
450-
assert snapshot["batch_status"] == "unknown"
451-
assert chr(0) not in repr(driver.executions[1][1])
450+
assert driver.connections == []
451+
assert driver.executions == []
452452

453453

454454
def test_remote_field_contract_adds_database_checks() -> None:

0 commit comments

Comments
 (0)