Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased — documentation and integrity audit

Review-event history now uses bounded cursor pagination with a default page size of fifty and a maximum of one hundred. Clients follow `next_after_id` to retrieve subsequent pages. PostgreSQL integration coverage verifies persisted review payloads, chronological insertion order, record isolation, and continued exclusion of reviewed fixtures from citation export. Review events do not yet update the evidence read model.

The documentation work begun from commit `9fddcbb` expands the academic corpus, corrects implementation claims, and standardizes attribution to CIPRIAN ȘTEFAN PLEȘCA — cercetător român independent. It preserves the author's removal of the wiki and the additional academic topics. This section describes development work, not a published v0.3.0 release. Mandatory release gates remain a separate decision under the project request and governance policy.

The whitepaper now uses current evidence and multi-omics constructors, the implemented A–G mapping, and the actual navigation-score factors. It distinguishes persisted publications from synthetic evidence and graph demonstrations. The API reference now describes JSON-body PubMed ingestion, its operator key, current bounds, and title filtering. These corrections repair the explanation of existing behavior; they do not themselves repair the runtime limitations documented alongside it.
Expand Down
10 changes: 10 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,16 @@ Evidence grades and scores require their methodological labels. The A–G mappin

Publication responses currently enforce a false synthetic flag without deriving authenticity from a verified origin model. This is a known limitation for test-seeded or otherwise manually inserted records. Clients and operators must not use that flag alone as proof that an item came from a real provider request. Correcting this requires code and schema decisions plus a regression test; documenting the limitation does not repair it.

### Bounded review history

Review history accepts `after_id` (a nonnegative event identifier, default zero) and `limit` (one through one hundred, default fifty). Events are ordered by their database identifier, representing insertion order rather than the reviewer-supplied timestamp. The response preserves `items`, `mode`, and `disclaimer`, and adds `limit` and `next_after_id`. Pass the returned cursor as `after_id` for the next request; a null cursor means no additional events were visible during that query. Invalid bounds return HTTP 422 with `INVALID_REQUEST`.

```bash
curl 'http://localhost:8000/api/v1/evidence/SYN-001/review-events?after_id=0&limit=20'
```

The repository fetches at most the requested limit plus one row, using the extra row to detect continuation. Cursors remain scoped to the requested evidence identifier. Concurrent writes can become visible in subsequent requests; this interface is not a frozen export snapshot. Existing clients that previously expected the entire history in one response must follow the cursor. Review events remain separate from the fixture read model and do not make synthetic records citation eligible. PostgreSQL integration tests exercise authenticated writes, persisted payloads, ordered pagination, record isolation, and fixture exclusion from citation export.

## Failure and health interpretation

Request-schema failures use a structured invalid-request response. Provider failures, unavailable storage, disabled ingestion, unauthorized access, missing records, and unsupported resources represent different conditions. Preserve the error classification in client behavior. A provider outage should not become an empty-results screen suggesting no research exists, and a missing database configuration should not be described as a scientific data-quality finding.
Expand Down
13 changes: 10 additions & 3 deletions src/openlongevity/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,10 +287,17 @@ async def review_evidence_record(
return {"mode": "review-event", "item": event, "disclaimer": DISCLAIMER}

@app.get("/api/v1/evidence/{identifier}/review-events")
async def review_events(identifier: str) -> dict[str, Any]:
async def review_events(
identifier: str,
after_id: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=100),
) -> dict[str, Any]:
fixture_by_identifier(identifier)
events = await require_review_repository().list_for_record(identifier)
return {"mode": "review-events", "items": events, "disclaimer": DISCLAIMER}
events, next_after_id = await require_review_repository().list_for_record(
identifier, after_id=after_id, limit=limit,
)
return {"mode": "review-events", "items": events, "limit": limit,
"next_after_id": next_after_id, "disclaimer": DISCLAIMER}

@app.get("/api/v1/research-gaps")
def gaps(topic: str = Query(min_length=1, max_length=120)) -> dict[str, Any]:
Expand Down
15 changes: 11 additions & 4 deletions src/openlongevity/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,19 @@ async def record_event(
await session.flush()
return self.serialize(row)

async def list_for_record(self, record_identifier: str) -> list[dict[str, Any]]:
async def list_for_record(
self, record_identifier: str, *, after_id: int = 0, limit: int = 50,
) -> tuple[list[dict[str, Any]], int | None]:
if after_id < 0 or not 1 <= limit <= 100:
raise ValueError("Review history requires after_id >= 0 and limit between 1 and 100")
async with self.database.sessions() as session:
rows = await session.scalars(select(EvidenceReviewEventRow).where(
EvidenceReviewEventRow.record_identifier == record_identifier
).order_by(EvidenceReviewEventRow.id))
return [self.serialize(row) for row in rows]
EvidenceReviewEventRow.record_identifier == record_identifier,
EvidenceReviewEventRow.id > after_id,
).order_by(EvidenceReviewEventRow.id).limit(limit + 1))
items = [self.serialize(row) for row in rows]
next_after_id = items[limit - 1]["id"] if len(items) > limit else None
return items[:limit], next_after_id

@staticmethod
def serialize(row: EvidenceReviewEventRow) -> dict[str, Any]:
Expand Down
12 changes: 11 additions & 1 deletion tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@
TEST_DATABASE_URL = os.getenv("TEST_DATABASE_URL")


@pytest.mark.parametrize("params", [
{"limit": 0}, {"limit": 101}, {"after_id": -1}, {"after_id": "invalid"},
])
def test_review_history_rejects_invalid_pagination(params: dict[str, object]) -> None:
with TestClient(create_app()) as client:
response = client.get("/api/v1/evidence/SYN-001/review-events", params=params)
assert response.status_code == 422
assert response.json()["error"]["code"] == "INVALID_REQUEST"


def test_health_contract() -> None:
client = TestClient(create_app())
health = client.get("/api/v1/health")
Expand Down Expand Up @@ -99,4 +109,4 @@ def test_review_endpoint_rejects_machine_status_as_human_review() -> None:
},
)
assert response.status_code == 422
assert response.json()["error"]["code"] == "INVALID_REVIEW"
assert response.json()["error"]["code"] == "INVALID_REVIEW"
74 changes: 74 additions & 0 deletions tests/test_review_history.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""PostgreSQL review audit round-trip and cursor isolation contracts."""

import os
from uuid import uuid4

import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import delete

from openlongevity.api import create_app
from openlongevity.db import Database, EvidenceReviewEventRow
from openlongevity.repository import EvidenceReviewRepository

TEST_DATABASE_URL = os.getenv("TEST_DATABASE_URL")
pytestmark = [
pytest.mark.postgres,
pytest.mark.skipif(not TEST_DATABASE_URL, reason="TEST_DATABASE_URL is required"),
]


async def test_review_history_round_trip_and_cursor() -> None:
assert TEST_DATABASE_URL is not None
database = Database(TEST_DATABASE_URL)
repository = EvidenceReviewRepository(database)
reviewer = f"test-{uuid4()}"
app = create_app(database_url=TEST_DATABASE_URL, review_key="test-review-key")
try:
async with app.router.lifespan_context(app):
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test",
) as client:
body = {"status": "verified", "reviewer": reviewer,
"reviewed_at": "2026-09-22T10:00:00+03:00", "notes": "Fixture review."}
unauthorized = await client.post("/api/v1/evidence/SYN-001/review", json=body)
assert unauthorized.status_code == 401
created = []
for status in ("verified", "disputed", "human_reviewed"):
response = await client.post(
"/api/v1/evidence/SYN-001/review", json={**body, "status": status},
headers={"X-Review-Key": "test-review-key"},
)
assert response.status_code == 200
created.append(response.json()["item"])
# Another record must never leak into this record's cursor page.
await repository.record_event(
record_identifier=f"OTHER-{reviewer}", status="verified", reviewer=reviewer,
reviewed_at=body["reviewed_at"], notes=body["notes"], payload={},
)
first = (await client.get(
"/api/v1/evidence/SYN-001/review-events",
params={"after_id": created[0]["id"] - 1, "limit": 2},
)).json()
assert first["items"] == created[:2]
assert first["next_after_id"] == created[1]["id"]
second = (await client.get(
"/api/v1/evidence/SYN-001/review-events",
params={"after_id": first["next_after_id"], "limit": 2},
)).json()
assert second["items"] == created[2:]
assert second["next_after_id"] is None
empty, cursor = await repository.list_for_record(
"SYN-001", after_id=created[-1]["id"], limit=2,
)
assert (empty, cursor) == ([], None)
# Reviewing a fixture never makes it a citable observation.
exported = (await client.get("/api/v1/evidence/export/citation")).json()
assert exported["items"] == []
assert exported["excluded"][0]["reason"] == "synthetic_fixture"
finally:
async with database.sessions.begin() as session:
await session.execute(delete(EvidenceReviewEventRow).where(
EvidenceReviewEventRow.reviewer == reviewer,
))
await database.close()
Loading