diff --git a/docs/research/NEXT_100_PR_MAP.md b/docs/research/NEXT_100_PR_MAP.md index e4c5ac4f..4eb2d9f3 100644 --- a/docs/research/NEXT_100_PR_MAP.md +++ b/docs/research/NEXT_100_PR_MAP.md @@ -168,7 +168,7 @@ Make OpenAMP artifacts useful even without OpenAMP scoring. | I6 | Add versioned schema export (complete). — versioning/schema_export.py: exports schemas with version metadata for stable partner API; tests/versioning/test_schema_export.py. | Stable API for partners. | B | | I7 | Add comparative summary across multiple candidate batches (complete). — BCS- schema: 14 fields, 12 validation rules, VALID_TREND_DIRECTIONS, VALID_QUALITY_TIERS, MIN_BATCHES_FOR_TREND=2; trend consistency enforced; 63 tests. | Shows trajectory, not just snapshots. | C | | I8 | Add machine-readable release manifest (complete). — evidence/release_manifest.py: machine-readable manifest of what was released in each pipeline run; tests/evidence/test_release_manifest.py. | Downstream tools can parse what was released. | B | -| I9 | Add public API stub with rate-limit and privacy policy stubs. | Safety for eventual public access. | D | +| I9 | Add public API stub with rate-limit and privacy policy stubs. | Safety for eventual public access. | D | DONE | | I10 | Add annotation layer for wet-lab-updated evidence. | Closes the wet-lab feedback loop. | D | ## Phase J — Long-term infrastructure diff --git a/src/openamp_foundry/interop/public_api_policy_stub.py b/src/openamp_foundry/interop/public_api_policy_stub.py new file mode 100644 index 00000000..9bfb1909 --- /dev/null +++ b/src/openamp_foundry/interop/public_api_policy_stub.py @@ -0,0 +1,186 @@ +"""PAS- public API policy stub schema. + +Machine-readable declaration of the safety and privacy properties that any +future public-facing API endpoint for OpenAMP outputs must satisfy. + +This stub documents what the API WILL do (or refuse to do) before any +implementation begins — making security and privacy requirements testable +from day one. When the actual API is built, these stubs become acceptance +criteria. + +Key invariants: + - The public API must never accept raw sequence data (sequence privacy). + - Rate limiting is declared before any endpoint goes live. + - Explicit data-not-collected list prevents silent collection. + - dry_lab_only outputs only: the API cannot return wet-lab results. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +VALID_AUTH_METHODS: frozenset[str] = frozenset({ + "api_key", + "oauth2", + "none", +}) + +VALID_DATA_COLLECTION_CATEGORIES: frozenset[str] = frozenset({ + "request_timestamp", + "endpoint_called", + "error_code", + "response_time_ms", + "api_key_hash", + "ip_address_hash", + "query_parameters", + "raw_sequence_data", + "candidate_ids", + "user_agent", +}) + +REQUIRED_NOT_COLLECTED: tuple[str, ...] = ( + "raw_sequence_data", + "candidate_ids", +) + +MIN_RATE_LIMIT_RPM: int = 1 +MAX_RATE_LIMIT_RPM: int = 1000 + + +@dataclass +class PublicApiPolicyStub: + pas_id: str + api_version: str + pipeline_version: str + auth_method: str + rate_limit_requests_per_minute: int + rate_limit_requests_per_day: int + sequence_data_accepted: bool + data_collected: list[str] + data_not_collected: list[str] + required_not_collected_declared: bool + dry_lab_only: bool + limitations: list[str] + created_at: str + + +def validate_public_api_policy_stub(pas: PublicApiPolicyStub) -> None: + if not pas.pas_id.startswith("PAS-"): + raise ValueError(f"pas_id must start with 'PAS-': {pas.pas_id!r}") + if not pas.api_version: + raise ValueError("api_version must be non-empty") + if not pas.pipeline_version: + raise ValueError("pipeline_version must be non-empty") + if pas.auth_method not in VALID_AUTH_METHODS: + raise ValueError( + f"auth_method {pas.auth_method!r} not in VALID_AUTH_METHODS" + ) + if pas.rate_limit_requests_per_minute < MIN_RATE_LIMIT_RPM: + raise ValueError( + f"rate_limit_requests_per_minute must be >= {MIN_RATE_LIMIT_RPM}" + ) + if pas.rate_limit_requests_per_minute > MAX_RATE_LIMIT_RPM: + raise ValueError( + f"rate_limit_requests_per_minute must be <= {MAX_RATE_LIMIT_RPM}" + ) + if pas.rate_limit_requests_per_day < pas.rate_limit_requests_per_minute: + raise ValueError( + "rate_limit_requests_per_day must be >= rate_limit_requests_per_minute" + ) + for cat in pas.data_collected: + if cat not in VALID_DATA_COLLECTION_CATEGORIES: + raise ValueError( + f"data_collected category {cat!r} not in VALID_DATA_COLLECTION_CATEGORIES" + ) + for cat in pas.data_not_collected: + if cat not in VALID_DATA_COLLECTION_CATEGORIES: + raise ValueError( + f"data_not_collected category {cat!r} not in VALID_DATA_COLLECTION_CATEGORIES" + ) + overlap = set(pas.data_collected) & set(pas.data_not_collected) + if overlap: + raise ValueError( + f"data_collected and data_not_collected overlap: {overlap}" + ) + if pas.sequence_data_accepted: + raise ValueError( + "sequence_data_accepted must be False for public API" + ) + expected_required_declared = all( + r in pas.data_not_collected for r in REQUIRED_NOT_COLLECTED + ) + if pas.required_not_collected_declared != expected_required_declared: + raise ValueError( + "required_not_collected_declared inconsistent with data_not_collected" + ) + if not pas.required_not_collected_declared: + missing = [r for r in REQUIRED_NOT_COLLECTED if r not in pas.data_not_collected] + raise ValueError( + f"data_not_collected must include required items: {missing}" + ) + if not pas.dry_lab_only: + raise ValueError("dry_lab_only must be True") + if not pas.limitations: + raise ValueError("limitations must be non-empty") + if not pas.created_at: + raise ValueError("created_at must be non-empty") + + +def build_public_api_policy_stub( + *, + pas_id: str, + api_version: str, + pipeline_version: str, + auth_method: str, + rate_limit_requests_per_minute: int, + rate_limit_requests_per_day: int, + data_collected: list[str] | None = None, + data_not_collected: list[str], + limitations: list[str], + created_at: str, +) -> PublicApiPolicyStub: + """Build a PublicApiPolicyStub. + + sequence_data_accepted is always False. + required_not_collected_declared is auto-computed from data_not_collected. + """ + data_collected = list(data_collected) if data_collected else [] + data_not_collected = list(data_not_collected) + not_collected_set = set(data_not_collected) + required_declared = all(r in not_collected_set for r in REQUIRED_NOT_COLLECTED) + pas = PublicApiPolicyStub( + pas_id=pas_id, + api_version=api_version, + pipeline_version=pipeline_version, + auth_method=auth_method, + rate_limit_requests_per_minute=rate_limit_requests_per_minute, + rate_limit_requests_per_day=rate_limit_requests_per_day, + sequence_data_accepted=False, + data_collected=data_collected, + data_not_collected=data_not_collected, + required_not_collected_declared=required_declared, + dry_lab_only=True, + limitations=limitations, + created_at=created_at, + ) + validate_public_api_policy_stub(pas) + return pas + + +def format_public_api_policy_stub(pas: PublicApiPolicyStub) -> str: + lines = [ + f"Public API Policy Stub — {pas.pas_id}", + f"API: {pas.api_version} | Pipeline: {pas.pipeline_version}", + f"Auth: {pas.auth_method}", + f"Rate limit: {pas.rate_limit_requests_per_minute} rpm / " + f"{pas.rate_limit_requests_per_day} rpd", + f"Sequence data accepted: {pas.sequence_data_accepted}", + f"Required non-collection declared: {pas.required_not_collected_declared}", + ] + if pas.data_collected: + lines.append(f"Data collected: {', '.join(pas.data_collected)}") + lines.append(f"Data NOT collected: {', '.join(pas.data_not_collected)}") + lines.append(f"Limitations: {'; '.join(pas.limitations)}") + lines.append(f"Created: {pas.created_at}") + lines.append(f"dry_lab_only: {pas.dry_lab_only}") + return "\n".join(lines) diff --git a/tests/interop/test_public_api_policy_stub.py b/tests/interop/test_public_api_policy_stub.py new file mode 100644 index 00000000..19691b62 --- /dev/null +++ b/tests/interop/test_public_api_policy_stub.py @@ -0,0 +1,305 @@ +"""Tests for PAS- public API policy stub schema.""" + +import pytest +from openamp_foundry.interop.public_api_policy_stub import ( + PublicApiPolicyStub, + VALID_AUTH_METHODS, + VALID_DATA_COLLECTION_CATEGORIES, + REQUIRED_NOT_COLLECTED, + MIN_RATE_LIMIT_RPM, + MAX_RATE_LIMIT_RPM, + build_public_api_policy_stub, + format_public_api_policy_stub, + validate_public_api_policy_stub, +) + +_REQUIRED_NC = list(REQUIRED_NOT_COLLECTED) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _build(**kwargs): + defaults = dict( + pas_id="PAS-001", + api_version="v1.0-stub", + pipeline_version="v1.0", + auth_method="api_key", + rate_limit_requests_per_minute=60, + rate_limit_requests_per_day=1000, + data_collected=["request_timestamp", "endpoint_called"], + data_not_collected=["raw_sequence_data", "candidate_ids"], + limitations=["dry-lab only, public API not yet live"], + created_at="2026-07-10", + ) + defaults.update(kwargs) + return build_public_api_policy_stub(**defaults) + + +# --------------------------------------------------------------------------- +# 1. Constants +# --------------------------------------------------------------------------- + + +def test_valid_auth_methods_is_frozenset(): + assert isinstance(VALID_AUTH_METHODS, frozenset) + + +def test_valid_auth_methods_contains_api_key(): + assert "api_key" in VALID_AUTH_METHODS + + +def test_valid_auth_methods_contains_oauth2(): + assert "oauth2" in VALID_AUTH_METHODS + + +def test_valid_auth_methods_contains_none(): + assert "none" in VALID_AUTH_METHODS + + +def test_valid_data_collection_categories_is_frozenset(): + assert isinstance(VALID_DATA_COLLECTION_CATEGORIES, frozenset) + + +def test_valid_data_collection_categories_contains_raw_sequence_data(): + assert "raw_sequence_data" in VALID_DATA_COLLECTION_CATEGORIES + + +def test_valid_data_collection_categories_contains_candidate_ids(): + assert "candidate_ids" in VALID_DATA_COLLECTION_CATEGORIES + + +def test_required_not_collected_is_tuple(): + assert isinstance(REQUIRED_NOT_COLLECTED, tuple) + + +def test_required_not_collected_contains_raw_sequence_data(): + assert "raw_sequence_data" in REQUIRED_NOT_COLLECTED + + +def test_required_not_collected_contains_candidate_ids(): + assert "candidate_ids" in REQUIRED_NOT_COLLECTED + + +def test_min_rate_limit_rpm_is_positive(): + assert MIN_RATE_LIMIT_RPM >= 1 + + +def test_max_rate_limit_rpm(): + assert MAX_RATE_LIMIT_RPM == 1000 + + +# --------------------------------------------------------------------------- +# 2. build happy paths +# --------------------------------------------------------------------------- + + +def test_build_returns_public_api_policy_stub(): + assert isinstance(_build(), PublicApiPolicyStub) + + +def test_build_pas_id_stored(): + assert _build().pas_id == "PAS-001" + + +def test_build_api_version_stored(): + assert _build().api_version == "v1.0-stub" + + +def test_build_pipeline_version_stored(): + assert _build().pipeline_version == "v1.0" + + +def test_build_dry_lab_only_true(): + assert _build().dry_lab_only is True + + +def test_build_sequence_data_accepted_false(): + assert _build().sequence_data_accepted is False + + +def test_build_auth_method_api_key(): + assert _build().auth_method == "api_key" + + +def test_build_auth_method_oauth2(): + r = _build(auth_method="oauth2") + assert r.auth_method == "oauth2" + + +def test_build_auth_method_none(): + r = _build(auth_method="none") + assert r.auth_method == "none" + + +def test_build_rate_limit_rpm_stored(): + assert _build().rate_limit_requests_per_minute == 60 + + +def test_build_rate_limit_rpd_stored(): + assert _build().rate_limit_requests_per_day == 1000 + + +def test_build_data_collected_stored(): + r = _build() + assert "request_timestamp" in r.data_collected + + +def test_build_data_not_collected_stored(): + r = _build() + assert "raw_sequence_data" in r.data_not_collected + + +def test_build_required_not_collected_declared_true(): + assert _build().required_not_collected_declared is True + + +def test_build_limitations_stored(): + assert "dry-lab only" in _build().limitations[0] + + +def test_build_created_at_stored(): + assert _build().created_at == "2026-07-10" + + +def test_build_empty_data_collected_allowed(): + r = _build(data_collected=[]) + assert r.data_collected == [] + + +# --------------------------------------------------------------------------- +# 3. validate rejection cases +# --------------------------------------------------------------------------- + + +def test_validate_rejects_bad_pas_id_prefix(): + with pytest.raises(ValueError, match="PAS-"): + _build(pas_id="BAD-001") + + +def test_validate_rejects_empty_api_version(): + with pytest.raises(ValueError, match="api_version"): + _build(api_version="") + + +def test_validate_rejects_empty_pipeline_version(): + with pytest.raises(ValueError): + _build(pipeline_version="") + + +def test_validate_rejects_invalid_auth_method(): + with pytest.raises(ValueError, match="auth_method"): + _build(auth_method="UNKNOWN") + + +def test_validate_rejects_rpm_below_minimum(): + with pytest.raises(ValueError, match="rate_limit_requests_per_minute"): + _build(rate_limit_requests_per_minute=0) + + +def test_validate_rejects_rpm_above_maximum(): + with pytest.raises(ValueError, match="rate_limit_requests_per_minute"): + _build(rate_limit_requests_per_minute=1001) + + +def test_validate_rejects_rpd_less_than_rpm(): + with pytest.raises(ValueError, match="rate_limit_requests_per_day"): + _build( + rate_limit_requests_per_minute=100, + rate_limit_requests_per_day=50, + ) + + +def test_validate_rejects_invalid_data_collected_category(): + with pytest.raises(ValueError, match="data_collected"): + _build(data_collected=["UNKNOWN_CATEGORY"]) + + +def test_validate_rejects_invalid_data_not_collected_category(): + with pytest.raises(ValueError, match="data_not_collected"): + _build(data_not_collected=["raw_sequence_data", "candidate_ids", "UNKNOWN"]) + + +def test_validate_rejects_overlap_between_collected_and_not_collected(): + with pytest.raises(ValueError, match="overlap"): + _build( + data_collected=["raw_sequence_data"], + data_not_collected=["raw_sequence_data", "candidate_ids"], + ) + + +def test_validate_rejects_missing_raw_sequence_data_in_not_collected(): + with pytest.raises(ValueError, match="raw_sequence_data"): + _build(data_not_collected=["candidate_ids"]) + + +def test_validate_rejects_missing_candidate_ids_in_not_collected(): + with pytest.raises(ValueError, match="candidate_ids"): + _build(data_not_collected=["raw_sequence_data"]) + + +def test_validate_rejects_dry_lab_only_false(): + pas = _build() + pas.dry_lab_only = False + with pytest.raises(ValueError, match="dry_lab_only"): + validate_public_api_policy_stub(pas) + + +def test_validate_rejects_empty_limitations(): + with pytest.raises(ValueError, match="limitations"): + _build(limitations=[]) + + +def test_validate_rejects_empty_created_at(): + with pytest.raises(ValueError): + _build(created_at="") + + +def test_validate_rejects_sequence_data_accepted_true(): + pas = _build() + pas.sequence_data_accepted = True + with pytest.raises(ValueError, match="sequence_data_accepted"): + validate_public_api_policy_stub(pas) + + +# --------------------------------------------------------------------------- +# 4. format +# --------------------------------------------------------------------------- + + +def test_format_contains_pas_id(): + assert "PAS-001" in format_public_api_policy_stub(_build()) + + +def test_format_contains_api_version(): + assert "v1.0-stub" in format_public_api_policy_stub(_build()) + + +def test_format_contains_auth_method(): + assert "api_key" in format_public_api_policy_stub(_build()) + + +def test_format_contains_rate_limits(): + assert "60" in format_public_api_policy_stub(_build()) + + +def test_format_contains_sequence_data_accepted(): + assert "False" in format_public_api_policy_stub(_build()) + + +def test_format_contains_data_not_collected(): + assert "raw_sequence_data" in format_public_api_policy_stub(_build()) + + +def test_format_contains_limitations(): + assert "dry-lab only" in format_public_api_policy_stub(_build()) + + +def test_format_contains_dry_lab_only(): + assert "dry_lab_only: True" in format_public_api_policy_stub(_build()) + + +def test_format_is_string(): + assert isinstance(format_public_api_policy_stub(_build()), str) diff --git a/tests/test_test_count_regression.py b/tests/test_test_count_regression.py index 4e56cc08..94b4a2e4 100644 --- a/tests/test_test_count_regression.py +++ b/tests/test_test_count_regression.py @@ -4,7 +4,7 @@ import sys import math -BASELINE = 11138 +BASELINE = 11192 def test_test_count_regression():