diff --git a/api/pyproject.toml b/api/pyproject.toml index 4b36599..32a0ab6 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "opf-api" -version = "0.2.0" +version = "0.3.0" description = "Unified privacy-detection API — one contract, multiple backends" requires-python = ">=3.10" dependencies = [ diff --git a/api/src/opf_api/main.py b/api/src/opf_api/main.py index 6cf8d8c..8bd4d60 100644 --- a/api/src/opf_api/main.py +++ b/api/src/opf_api/main.py @@ -19,12 +19,6 @@ logging.basicConfig(level=logging.INFO) -# Bumped to 2 with the /v1/sanitize consolidation: response shape changed -# (`sanitized_text` replaces `redacted_text`/`tokenized_text`, `replacement` -# replaces `placeholder`/`token`, new `mode` field on SanitizeResponse). -SCHEMA_VERSION = 2 - - @asynccontextmanager async def lifespan(app: FastAPI): registry = build_default_registry() @@ -36,7 +30,6 @@ async def lifespan(app: FastAPI): ) app.state.registry = registry app.state.default_detector = default - app.state.schema_version = SCHEMA_VERSION app.state.token_vault_client = TokenVaultClient.from_env() if app.state.token_vault_client is None: logger.info( @@ -74,11 +67,10 @@ async def lifespan(app: FastAPI): ## Versioning -Two version numbers appear in this API. They are independent: - -- `info.version` (this spec) — tracks the OpenAPI contract. -- `schema_version` (response payload field) — tracks the request/response payload shape. - Currently `2`. Bumps when payload field names or semantics change. Clients should pin on this. +The API is pre-`1.0`. `info.version` is the only version surface — every +breaking change to request or response shape lands as a minor bump +(`0.x.0 -> 0.(x+1).0`). Non-breaking additions land as patch bumps. The +`/v1/` URL prefix is reserved for the eventual `1.0` cutover. ## Canonical labels @@ -108,7 +100,7 @@ async def lifespan(app: FastAPI): app = FastAPI( title="Privacy-detection API", - version="0.2.0", + version="0.3.0", summary="Unified PII detection across OPF, GLiNER, Presidio, and Skyflow.", description=API_DESCRIPTION, lifespan=lifespan, diff --git a/api/src/opf_api/routes.py b/api/src/opf_api/routes.py index 3b04876..b51bb72 100644 --- a/api/src/opf_api/routes.py +++ b/api/src/opf_api/routes.py @@ -7,7 +7,6 @@ from fastapi import APIRouter, HTTPException, Request from opf_eval.detectors.base import Span -from opf_eval.taxonomy import CANONICAL_LABELS from opf_eval.transforms import ( VaultTokenError, label_number_renderer, @@ -19,8 +18,10 @@ from .registry import DetectorEntry, detector_categories from .schemas import ( + CanonicalLabel, DetectorInfo, DetectorsResponse, + DetectorOptions, DetectRequest, DetectResponse, HealthResponse, @@ -36,7 +37,7 @@ _ERROR_400_EXAMPLE = { - "description": "Unknown detector, unknown category, or missing config for the chosen mode.", + "description": "Unknown detector or missing config for the chosen mode.", "content": { "application/json": { "examples": { @@ -46,12 +47,6 @@ "detail": "unknown detector 'foo'; available: ['gliner', 'opf', 'presidio']" }, }, - "unknown_category": { - "summary": "Unknown canonical category", - "value": { - "detail": "unknown canonical categories: ['FOO']. Valid: ['ACCOUNT', 'ADDRESS', ...]" - }, - }, "label_token_unconfigured": { "summary": "label_token mode without vault env", "value": { @@ -63,6 +58,46 @@ }, } +_ERROR_422_EXAMPLE = { + "description": ( + "Request body failed Pydantic validation. Common causes: a value in " + "`categories` that isn't a canonical label, an unknown key in " + "`options.opf`, or a wrong type on any field." + ), + "content": { + "application/json": { + "examples": { + "bad_category": { + "summary": "Non-canonical value in `categories`", + "value": { + "detail": [ + { + "type": "enum", + "loc": ["body", "categories", 0], + "msg": "Input should be 'PERSON', 'EMAIL', 'PHONE', ...", + "input": "FOO", + } + ] + }, + }, + "unknown_opf_option": { + "summary": "Unknown key in `options.opf`", + "value": { + "detail": [ + { + "type": "extra_forbidden", + "loc": ["body", "options", "opf", "decod_mode"], + "msg": "Extra inputs are not permitted", + "input": "argmax", + } + ] + }, + }, + } + } + }, +} + _ERROR_502_EXAMPLE = { "description": "Detector backend failure or vault call failure.", "content": { @@ -82,9 +117,6 @@ } -_VALID_CATEGORIES = set(CANONICAL_LABELS) - - def _resolve_detector(request: Request, name: str | None) -> tuple[str, DetectorEntry]: chosen = name or request.app.state.default_detector registry: dict[str, DetectorEntry] = request.app.state.registry @@ -97,33 +129,15 @@ def _resolve_detector(request: Request, name: str | None) -> tuple[str, Detector return chosen, entry -def _validate_categories(categories: list[str] | None) -> set[str] | None: - if categories is None: - return None - bad = [c for c in categories if c not in _VALID_CATEGORIES] - if bad: - raise HTTPException( - status_code=400, - detail=( - f"unknown canonical categories: {bad}. " - f"Valid: {sorted(_VALID_CATEGORIES)}" - ), - ) - return set(categories) - - class _DetectInput(Protocol): text: str detector: str | None - categories: list[str] | None - decode_mode: object + categories: list[CanonicalLabel] | None + options: DetectorOptions | None async def _run_detect(request: Request, body: _DetectInput) -> tuple[str, list[Span]]: name, entry = _resolve_detector(request, body.detector) - if body.decode_mode is not None and name != "opf": - # Silently no-op rather than 400 — ignore for non-OPF. - pass detector = await entry.get() async with entry.call_lock: @@ -133,8 +147,12 @@ async def _run_detect(request: Request, body: _DetectInput) -> tuple[str, list[S raise HTTPException(status_code=502, detail=f"{name}: {result['error']}") spans: list[Span] = list(result.get("spans") or []) - allow = _validate_categories(body.categories) - if allow is not None: + if body.categories is not None: + # Empty list is a deliberate "match nothing" filter, not "no filter". + # Omit the field (or send null) to keep every category. CanonicalLabel + # inherits str, so membership lookup against the raw detector label + # works without coercion. + allow = set(body.categories) spans = [s for s in spans if s["label"] in allow] return name, spans @@ -148,10 +166,14 @@ async def _run_detect(request: Request, body: _DetectInput) -> tuple[str, list[S "Run the chosen detector over `text` and return canonical-labelled spans.\n\n" "No rewriting is performed — call `/v1/sanitize` for that.\n\n" "Filter detector output to a subset of canonical labels with `categories`.\n" - "OPF-only: pass `decode_mode` to override the default Viterbi decoding." + "OPF-only: pass `options.opf.decode_mode` to override the default Viterbi decoding." ), response_description="Detected spans plus per-label counts.", - responses={400: _ERROR_400_EXAMPLE, 502: _ERROR_502_EXAMPLE}, + responses={ + 400: _ERROR_400_EXAMPLE, + 422: _ERROR_422_EXAMPLE, + 502: _ERROR_502_EXAMPLE, + }, ) async def detect(request: Request, body: DetectRequest) -> DetectResponse: """Detect-only: returns spans, no text rewriting.""" @@ -169,7 +191,6 @@ async def detect(request: Request, body: DetectRequest) -> DetectResponse: ] by_label = Counter(s.label for s in out_spans) return DetectResponse( - schema_version=request.app.state.schema_version, detector=name, text=body.text, detected_spans=out_spans, @@ -216,7 +237,11 @@ def _build_label_token_renderer_raising_http( "`[LABEL]` for that span only." ), response_description="Sanitized text, the spans that were rewritten, and per-label counts.", - responses={400: _ERROR_400_EXAMPLE, 502: _ERROR_502_EXAMPLE}, + responses={ + 400: _ERROR_400_EXAMPLE, + 422: _ERROR_422_EXAMPLE, + 502: _ERROR_502_EXAMPLE, + }, ) async def sanitize(request: Request, body: SanitizeRequest) -> SanitizeResponse: """Detect + rewrite the input text under the chosen `mode`. @@ -278,7 +303,6 @@ async def sanitize(request: Request, body: SanitizeRequest) -> SanitizeResponse: by_label = Counter(s.label for s in out_spans) return SanitizeResponse( - schema_version=request.app.state.schema_version, detector=name, mode=mode, text=body.text, @@ -336,5 +360,4 @@ async def health(request: Request) -> HealthResponse: status="ok", default_detector=request.app.state.default_detector, loaded_detectors=loaded, - schema_version=request.app.state.schema_version, ) diff --git a/api/src/opf_api/schemas.py b/api/src/opf_api/schemas.py index 408e039..fc51c0d 100644 --- a/api/src/opf_api/schemas.py +++ b/api/src/opf_api/schemas.py @@ -1,9 +1,12 @@ from __future__ import annotations +from enum import Enum from typing import Literal from pydantic import BaseModel, ConfigDict, Field +from opf_eval.taxonomy import CANONICAL_LABELS + DecodeMode = Literal["viterbi", "argmax"] @@ -15,14 +18,65 @@ SanitizeMode = Literal["redact", "label", "label_number", "label_token"] -CANONICAL_LABEL_DESCRIPTION = ( - "Canonical categories to keep. Valid values: PERSON, EMAIL, PHONE, " - "ADDRESS, URL, DATE, ACCOUNT, SECRET, USERNAME, DEMOGRAPHIC, " - "ORGANIZATION, OCCUPATION, MONEY, VEHICLE, PHYSICAL. " - "Omit or set to null to keep all categories the detector produces." +class CanonicalLabel(str, Enum): + """Canonical PII category. Every detector's raw output maps into this taxonomy.""" + + PERSON = "PERSON" + EMAIL = "EMAIL" + PHONE = "PHONE" + ADDRESS = "ADDRESS" + URL = "URL" + DATE = "DATE" + ACCOUNT = "ACCOUNT" + SECRET = "SECRET" + USERNAME = "USERNAME" + DEMOGRAPHIC = "DEMOGRAPHIC" + ORGANIZATION = "ORGANIZATION" + OCCUPATION = "OCCUPATION" + MONEY = "MONEY" + VEHICLE = "VEHICLE" + PHYSICAL = "PHYSICAL" + + +# Guardrail: enum must mirror opf_eval.taxonomy.CANONICAL_LABELS exactly. +# Failing here at import time is loud and immediate. +assert {m.value for m in CanonicalLabel} == set(CANONICAL_LABELS), ( + "CanonicalLabel enum drifted from opf_eval.taxonomy.CANONICAL_LABELS: " + f"enum={sorted(m.value for m in CanonicalLabel)} " + f"taxonomy={sorted(CANONICAL_LABELS)}" ) +class OpfOptions(BaseModel): + """Options that only apply when `detector` is `opf`.""" + + model_config = ConfigDict(extra="forbid") # catch typos like `decod_mode` + + decode_mode: DecodeMode = Field( + default="viterbi", + description=( + "OPF decode strategy. `viterbi` (default) maximises sequence probability; " + "`argmax` picks the most likely label per token independently. " + "**Currently advisory** — the OPF detector reads its decode mode from the " + "server's `OPF_DECODE_MODE` env at startup; per-request override is " + "reserved for a follow-up that extends `OPFDetector.detect()`." + ), + examples=["viterbi"], + ) + + +class DetectorOptions(BaseModel): + """Per-detector options namespace. Each key carries options for that one + detector. Only the entry matching the top-level `detector` is consulted — + other keys are accepted but ignored, so a client can carry one options + blob across detector swaps without restructuring it.""" + + opf: OpfOptions | None = Field( + default=None, + description="Options for the `opf` detector. Ignored by other detectors.", + ) + + class DetectRequest(BaseModel): """Base request for `/v1/detect` — no text-rewriting fields.""" @@ -51,19 +105,24 @@ class DetectRequest(BaseModel): ), examples=["presidio", "opf", "gliner"], ) - categories: list[str] | None = Field( + categories: list[CanonicalLabel] | None = Field( default=None, - description=CANONICAL_LABEL_DESCRIPTION, + description=( + "Canonical categories to keep. Omit or set to `null` to keep every " + "category the detector produces. An empty list `[]` is a deliberate " + "\"match nothing\" filter and returns zero spans — use `null` if you " + "mean \"no filter\". Values outside the canonical taxonomy are " + "rejected with `422`." + ), examples=[["EMAIL", "PHONE"]], ) - decode_mode: DecodeMode | None = Field( + options: DetectorOptions | None = Field( default=None, description=( - "OPF-only decode strategy. Ignored by every other detector. " - "`viterbi` (default) maximises sequence probability; " - "`argmax` picks the most likely label per token independently." + "Per-detector options namespace. The entry matching the top-level " + "`detector` is consulted; other entries are accepted but ignored." ), - examples=["viterbi"], + examples=[{"opf": {"decode_mode": "argmax"}}], ) @@ -142,7 +201,6 @@ class SanitizeResponse(BaseModel): json_schema_extra={ "examples": [ { - "schema_version": 2, "detector": "presidio", "mode": "label_token", "text": "Email alice@x.com or call +1-415-555-0100.", @@ -172,14 +230,6 @@ class SanitizeResponse(BaseModel): } ) - schema_version: int = Field( - ..., - description=( - "Payload schema version. Bumps when field names or semantics change. " - "Independent of `info.version` in the OpenAPI spec." - ), - examples=[2], - ) detector: str = Field( ..., description="Detector that produced the spans.", @@ -233,7 +283,6 @@ class DetectResponse(BaseModel): json_schema_extra={ "examples": [ { - "schema_version": 2, "detector": "presidio", "text": "Email joe@example.com about the trip to Elgin, TX.", "detected_spans": [ @@ -252,11 +301,6 @@ class DetectResponse(BaseModel): } ) - schema_version: int = Field( - ..., - description="Payload schema version. Independent of the OpenAPI `info.version`.", - examples=[2], - ) detector: str = Field(..., description="Detector that produced the spans.") text: str = Field(..., description="Echo of the request `text`.") detected_spans: list[SpanOut] = Field( @@ -273,7 +317,7 @@ class DetectorInfo(BaseModel): """One row in the detector registry.""" name: str = Field(..., description="Registry key.", examples=["opf"]) - categories: list[str] = Field( + categories: list[CanonicalLabel] = Field( ..., description="Canonical categories this detector can produce.", examples=[["PERSON", "EMAIL", "PHONE"]], @@ -344,7 +388,6 @@ class HealthResponse(BaseModel): "status": "ok", "default_detector": "opf", "loaded_detectors": ["opf"], - "schema_version": 2, } ] } @@ -359,8 +402,3 @@ class HealthResponse(BaseModel): description="Names of detectors that have been initialised so far.", examples=[["opf"]], ) - schema_version: int = Field( - ..., - description="Payload schema version. Independent of the OpenAPI `info.version`.", - examples=[2], - ) diff --git a/api/tests/test_routes.py b/api/tests/test_routes.py index ff9e242..00f22f9 100644 --- a/api/tests/test_routes.py +++ b/api/tests/test_routes.py @@ -109,7 +109,6 @@ def _build_app( fake_entry.instance = instance app.state.registry = {"fake": fake_entry} app.state.default_detector = "fake" - app.state.schema_version = 2 app.state.token_vault_client = token_vault_client return app @@ -150,6 +149,36 @@ async def test_detect_filter_categories(): assert [s["label"] for s in body["detected_spans"]] == ["EMAIL"] +@pytest.mark.asyncio +async def test_detect_empty_categories_returns_zero_spans(): + # `[]` is a deliberate "match nothing" filter — distinct from `None`, + # which means "no filter". Regression guard for that contract. + async with _client() as c: + r = await c.post( + "/v1/detect", + json={ + "text": "Joe at joe@example.com lives in Elgin, TX.", + "categories": [], + }, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["detected_spans"] == [] + assert body["summary"] == {"span_count": 0, "by_label": {}} + + +@pytest.mark.asyncio +async def test_detect_null_categories_returns_all_spans(): + async with _client() as c: + r = await c.post( + "/v1/detect", + json={"text": "Joe at joe@example.com lives in Elgin, TX.", "categories": None}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert {s["label"] for s in body["detected_spans"]} == {"EMAIL", "ADDRESS"} + + @pytest.mark.asyncio async def test_detect_invalid_category(): async with _client() as c: @@ -157,8 +186,40 @@ async def test_detect_invalid_category(): "/v1/detect", json={"text": "x", "categories": ["NOT_REAL"]}, ) - assert r.status_code == 400 - assert "NOT_REAL" in r.json()["detail"] + assert r.status_code == 422 + detail = r.json()["detail"] + # Pydantic returns a list of per-field errors; the bad input must be there. + assert any("NOT_REAL" == err.get("input") for err in detail), detail + + +@pytest.mark.asyncio +async def test_detect_unknown_opf_option_rejected(): + async with _client() as c: + r = await c.post( + "/v1/detect", + json={"text": "x", "options": {"opf": {"decod_mode": "argmax"}}}, + ) + assert r.status_code == 422 + detail = r.json()["detail"] + assert any( + err.get("type") == "extra_forbidden" and "decod_mode" in err.get("loc", []) + for err in detail + ), detail + + +@pytest.mark.asyncio +async def test_detect_valid_opf_options_accepted(): + async with _client() as c: + r = await c.post( + "/v1/detect", + json={ + "text": "Joe at joe@example.com lives in Elgin, TX.", + "options": {"opf": {"decode_mode": "argmax"}}, + }, + ) + assert r.status_code == 200, r.text + body = r.json() + assert {s["label"] for s in body["detected_spans"]} == {"EMAIL", "ADDRESS"} @pytest.mark.asyncio diff --git a/docs/api/openapi.json b/docs/api/openapi.json index c18fd01..2f2c38a 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -1,6 +1,28 @@ { "components": { "schemas": { + "CanonicalLabel": { + "description": "Canonical PII category. Every detector's raw output maps into this taxonomy.", + "enum": [ + "PERSON", + "EMAIL", + "PHONE", + "ADDRESS", + "URL", + "DATE", + "ACCOUNT", + "SECRET", + "USERNAME", + "DEMOGRAPHIC", + "ORGANIZATION", + "OCCUPATION", + "MONEY", + "VEHICLE", + "PHYSICAL" + ], + "title": "CanonicalLabel", + "type": "string" + }, "DetectRequest": { "description": "Base request for `/v1/detect` \u2014 no text-rewriting fields.", "examples": [ @@ -17,7 +39,7 @@ "anyOf": [ { "items": { - "type": "string" + "$ref": "#/components/schemas/CanonicalLabel" }, "type": "array" }, @@ -25,7 +47,7 @@ "type": "null" } ], - "description": "Canonical categories to keep. Valid values: PERSON, EMAIL, PHONE, ADDRESS, URL, DATE, ACCOUNT, SECRET, USERNAME, DEMOGRAPHIC, ORGANIZATION, OCCUPATION, MONEY, VEHICLE, PHYSICAL. Omit or set to null to keep all categories the detector produces.", + "description": "Canonical categories to keep. Omit or set to `null` to keep every category the detector produces. An empty list `[]` is a deliberate \"match nothing\" filter and returns zero spans \u2014 use `null` if you mean \"no filter\". Values outside the canonical taxonomy are rejected with `422`.", "examples": [ [ "EMAIL", @@ -34,41 +56,40 @@ ], "title": "Categories" }, - "decode_mode": { + "detector": { "anyOf": [ { - "enum": [ - "viterbi", - "argmax" - ], "type": "string" }, { "type": "null" } ], - "description": "OPF-only decode strategy. Ignored by every other detector. `viterbi` (default) maximises sequence probability; `argmax` picks the most likely label per token independently.", + "description": "Detector name from `GET /v1/detectors`. Omit to use the server's `DEFAULT_DETECTOR` (set via env, typically `opf`).", "examples": [ - "viterbi" + "presidio", + "opf", + "gliner" ], - "title": "Decode Mode" + "title": "Detector" }, - "detector": { + "options": { "anyOf": [ { - "type": "string" + "$ref": "#/components/schemas/DetectorOptions" }, { "type": "null" } ], - "description": "Detector name from `GET /v1/detectors`. Omit to use the server's `DEFAULT_DETECTOR` (set via env, typically `opf`).", + "description": "Per-detector options namespace. The entry matching the top-level `detector` is consulted; other entries are accepted but ignored.", "examples": [ - "presidio", - "opf", - "gliner" - ], - "title": "Detector" + { + "opf": { + "decode_mode": "argmax" + } + } + ] }, "text": { "description": "Free-form text to scan for PII.", @@ -99,7 +120,6 @@ } ], "detector": "presidio", - "schema_version": 2, "summary": { "by_label": { "EMAIL": 1 @@ -123,14 +143,6 @@ "title": "Detector", "type": "string" }, - "schema_version": { - "description": "Payload schema version. Independent of the OpenAPI `info.version`.", - "examples": [ - 2 - ], - "title": "Schema Version", - "type": "integer" - }, "summary": { "$ref": "#/components/schemas/SummaryOut" }, @@ -153,7 +165,6 @@ } }, "required": [ - "schema_version", "detector", "text", "detected_spans", @@ -175,7 +186,7 @@ ] ], "items": { - "type": "string" + "$ref": "#/components/schemas/CanonicalLabel" }, "title": "Categories", "type": "array" @@ -214,6 +225,24 @@ "title": "DetectorInfo", "type": "object" }, + "DetectorOptions": { + "description": "Per-detector options namespace. Each key carries options for that one\ndetector. Only the entry matching the top-level `detector` is consulted \u2014\nother keys are accepted but ignored, so a client can carry one options\nblob across detector swaps without restructuring it.", + "properties": { + "opf": { + "anyOf": [ + { + "$ref": "#/components/schemas/OpfOptions" + }, + { + "type": "null" + } + ], + "description": "Options for the `opf` detector. Ignored by other detectors." + } + }, + "title": "DetectorOptions", + "type": "object" + }, "DetectorsResponse": { "description": "Response from `/v1/detectors`.", "examples": [ @@ -275,19 +304,6 @@ "title": "DetectorsResponse", "type": "object" }, - "HTTPValidationError": { - "properties": { - "detail": { - "items": { - "$ref": "#/components/schemas/ValidationError" - }, - "title": "Detail", - "type": "array" - } - }, - "title": "HTTPValidationError", - "type": "object" - }, "HealthResponse": { "description": "Response from `/v1/health`.", "examples": [ @@ -296,7 +312,6 @@ "loaded_detectors": [ "opf" ], - "schema_version": 2, "status": "ok" } ], @@ -322,14 +337,6 @@ "title": "Loaded Detectors", "type": "array" }, - "schema_version": { - "description": "Payload schema version. Independent of the OpenAPI `info.version`.", - "examples": [ - 2 - ], - "title": "Schema Version", - "type": "integer" - }, "status": { "const": "ok", "description": "Always `ok` when this endpoint responds.", @@ -340,12 +347,32 @@ "required": [ "status", "default_detector", - "loaded_detectors", - "schema_version" + "loaded_detectors" ], "title": "HealthResponse", "type": "object" }, + "OpfOptions": { + "additionalProperties": false, + "description": "Options that only apply when `detector` is `opf`.", + "properties": { + "decode_mode": { + "default": "viterbi", + "description": "OPF decode strategy. `viterbi` (default) maximises sequence probability; `argmax` picks the most likely label per token independently. **Currently advisory** \u2014 the OPF detector reads its decode mode from the server's `OPF_DECODE_MODE` env at startup; per-request override is reserved for a follow-up that extends `OPFDetector.detect()`.", + "enum": [ + "viterbi", + "argmax" + ], + "examples": [ + "viterbi" + ], + "title": "Decode Mode", + "type": "string" + } + }, + "title": "OpfOptions", + "type": "object" + }, "SanitizeRequest": { "description": "Request for `/v1/sanitize` \u2014 `DetectRequest` plus a `mode` field that picks\nhow detected spans are rewritten.", "examples": [ @@ -360,7 +387,7 @@ "anyOf": [ { "items": { - "type": "string" + "$ref": "#/components/schemas/CanonicalLabel" }, "type": "array" }, @@ -368,7 +395,7 @@ "type": "null" } ], - "description": "Canonical categories to keep. Valid values: PERSON, EMAIL, PHONE, ADDRESS, URL, DATE, ACCOUNT, SECRET, USERNAME, DEMOGRAPHIC, ORGANIZATION, OCCUPATION, MONEY, VEHICLE, PHYSICAL. Omit or set to null to keep all categories the detector produces.", + "description": "Canonical categories to keep. Omit or set to `null` to keep every category the detector produces. An empty list `[]` is a deliberate \"match nothing\" filter and returns zero spans \u2014 use `null` if you mean \"no filter\". Values outside the canonical taxonomy are rejected with `422`.", "examples": [ [ "EMAIL", @@ -377,25 +404,6 @@ ], "title": "Categories" }, - "decode_mode": { - "anyOf": [ - { - "enum": [ - "viterbi", - "argmax" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "description": "OPF-only decode strategy. Ignored by every other detector. `viterbi` (default) maximises sequence probability; `argmax` picks the most likely label per token independently.", - "examples": [ - "viterbi" - ], - "title": "Decode Mode" - }, "detector": { "anyOf": [ { @@ -429,6 +437,24 @@ "title": "Mode", "type": "string" }, + "options": { + "anyOf": [ + { + "$ref": "#/components/schemas/DetectorOptions" + }, + { + "type": "null" + } + ], + "description": "Per-detector options namespace. The entry matching the top-level `detector` is consulted; other entries are accepted but ignored.", + "examples": [ + { + "opf": { + "decode_mode": "argmax" + } + } + ] + }, "text": { "description": "Free-form text to scan for PII.", "examples": [ @@ -469,7 +495,6 @@ "detector": "presidio", "mode": "label_token", "sanitized_text": "Email [EMAIL_MGaE1Bo] or call [PHONE_vRXiWKZ].", - "schema_version": 2, "summary": { "by_label": { "EMAIL": 1, @@ -519,14 +544,6 @@ "title": "Sanitized Text", "type": "string" }, - "schema_version": { - "description": "Payload schema version. Bumps when field names or semantics change. Independent of `info.version` in the OpenAPI spec.", - "examples": [ - 2 - ], - "title": "Schema Version", - "type": "integer" - }, "summary": { "$ref": "#/components/schemas/SummaryOut" }, @@ -552,7 +569,6 @@ } }, "required": [ - "schema_version", "detector", "mode", "text", @@ -712,46 +728,6 @@ ], "title": "SummaryOut", "type": "object" - }, - "ValidationError": { - "properties": { - "ctx": { - "title": "Context", - "type": "object" - }, - "input": { - "title": "Input" - }, - "loc": { - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "integer" - } - ] - }, - "title": "Location", - "type": "array" - }, - "msg": { - "title": "Message", - "type": "string" - }, - "type": { - "title": "Error Type", - "type": "string" - } - }, - "required": [ - "loc", - "msg", - "type" - ], - "title": "ValidationError", - "type": "object" } } }, @@ -760,16 +736,16 @@ "name": "local-privacy", "url": "https://github.com/jstjoe/local-privacy" }, - "description": "Unified PII detection across **OPF**, **GLiNER**, **Presidio**, and **Skyflow**.\nPick a backend with the `detector` field; canonical labels apply uniformly across all of them.\n\n## Endpoints\n\n- `POST /v1/detect` \u2014 return spans only, no text rewriting.\n- `POST /v1/sanitize` \u2014 detect and rewrite spans under one of four modes.\n- `GET /v1/detectors` \u2014 list registered detectors and their category coverage.\n- `GET /v1/health` \u2014 liveness probe; does not exercise detector backends.\n\n## Versioning\n\nTwo version numbers appear in this API. They are independent:\n\n- `info.version` (this spec) \u2014 tracks the OpenAPI contract.\n- `schema_version` (response payload field) \u2014 tracks the request/response payload shape.\n Currently `2`. Bumps when payload field names or semantics change. Clients should pin on this.\n\n## Canonical labels\n\nEvery detector's raw output maps into a 15-label taxonomy:\n`PERSON`, `EMAIL`, `PHONE`, `ADDRESS`, `URL`, `DATE`, `ACCOUNT`, `SECRET`, `USERNAME`,\n`DEMOGRAPHIC`, `ORGANIZATION`, `OCCUPATION`, `MONEY`, `VEHICLE`, `PHYSICAL`.\nDetectors vary in coverage \u2014 `GET /v1/detectors` reports each detector's category list.\n", + "description": "Unified PII detection across **OPF**, **GLiNER**, **Presidio**, and **Skyflow**.\nPick a backend with the `detector` field; canonical labels apply uniformly across all of them.\n\n## Endpoints\n\n- `POST /v1/detect` \u2014 return spans only, no text rewriting.\n- `POST /v1/sanitize` \u2014 detect and rewrite spans under one of four modes.\n- `GET /v1/detectors` \u2014 list registered detectors and their category coverage.\n- `GET /v1/health` \u2014 liveness probe; does not exercise detector backends.\n\n## Versioning\n\nThe API is pre-`1.0`. `info.version` is the only version surface \u2014 every\nbreaking change to request or response shape lands as a minor bump\n(`0.x.0 -> 0.(x+1).0`). Non-breaking additions land as patch bumps. The\n`/v1/` URL prefix is reserved for the eventual `1.0` cutover.\n\n## Canonical labels\n\nEvery detector's raw output maps into a 15-label taxonomy:\n`PERSON`, `EMAIL`, `PHONE`, `ADDRESS`, `URL`, `DATE`, `ACCOUNT`, `SECRET`, `USERNAME`,\n`DEMOGRAPHIC`, `ORGANIZATION`, `OCCUPATION`, `MONEY`, `VEHICLE`, `PHYSICAL`.\nDetectors vary in coverage \u2014 `GET /v1/detectors` reports each detector's category list.\n", "summary": "Unified PII detection across OPF, GLiNER, Presidio, and Skyflow.", "title": "Privacy-detection API", - "version": "0.2.0" + "version": "0.3.0" }, "openapi": "3.1.0", "paths": { "/v1/detect": { "post": { - "description": "Run the chosen detector over `text` and return canonical-labelled spans.\n\nNo rewriting is performed \u2014 call `/v1/sanitize` for that.\n\nFilter detector output to a subset of canonical labels with `categories`.\nOPF-only: pass `decode_mode` to override the default Viterbi decoding.", + "description": "Run the chosen detector over `text` and return canonical-labelled spans.\n\nNo rewriting is performed \u2014 call `/v1/sanitize` for that.\n\nFilter detector output to a subset of canonical labels with `categories`.\nOPF-only: pass `options.opf.decode_mode` to override the default Viterbi decoding.", "operationId": "detect_v1_detect_post", "requestBody": { "content": { @@ -802,12 +778,6 @@ "detail": "mode='label_token' requires SKYFLOW_TOKEN_VAULT_URL and SKYFLOW_TOKEN_VAULT_ID env vars to be set" } }, - "unknown_category": { - "summary": "Unknown canonical category", - "value": { - "detail": "unknown canonical categories: ['FOO']. Valid: ['ACCOUNT', 'ADDRESS', ...]" - } - }, "unknown_detector": { "summary": "Unknown detector", "value": { @@ -817,17 +787,51 @@ } } }, - "description": "Unknown detector, unknown category, or missing config for the chosen mode." + "description": "Unknown detector or missing config for the chosen mode." }, "422": { "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" + "examples": { + "bad_category": { + "summary": "Non-canonical value in `categories`", + "value": { + "detail": [ + { + "input": "FOO", + "loc": [ + "body", + "categories", + 0 + ], + "msg": "Input should be 'PERSON', 'EMAIL', 'PHONE', ...", + "type": "enum" + } + ] + } + }, + "unknown_opf_option": { + "summary": "Unknown key in `options.opf`", + "value": { + "detail": [ + { + "input": "argmax", + "loc": [ + "body", + "options", + "opf", + "decod_mode" + ], + "msg": "Extra inputs are not permitted", + "type": "extra_forbidden" + } + ] + } + } } } }, - "description": "Validation Error" + "description": "Request body failed Pydantic validation. Common causes: a value in `categories` that isn't a canonical label, an unknown key in `options.opf`, or a wrong type on any field." }, "502": { "content": { @@ -936,12 +940,6 @@ "detail": "mode='label_token' requires SKYFLOW_TOKEN_VAULT_URL and SKYFLOW_TOKEN_VAULT_ID env vars to be set" } }, - "unknown_category": { - "summary": "Unknown canonical category", - "value": { - "detail": "unknown canonical categories: ['FOO']. Valid: ['ACCOUNT', 'ADDRESS', ...]" - } - }, "unknown_detector": { "summary": "Unknown detector", "value": { @@ -951,17 +949,51 @@ } } }, - "description": "Unknown detector, unknown category, or missing config for the chosen mode." + "description": "Unknown detector or missing config for the chosen mode." }, "422": { "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" + "examples": { + "bad_category": { + "summary": "Non-canonical value in `categories`", + "value": { + "detail": [ + { + "input": "FOO", + "loc": [ + "body", + "categories", + 0 + ], + "msg": "Input should be 'PERSON', 'EMAIL', 'PHONE', ...", + "type": "enum" + } + ] + } + }, + "unknown_opf_option": { + "summary": "Unknown key in `options.opf`", + "value": { + "detail": [ + { + "input": "argmax", + "loc": [ + "body", + "options", + "opf", + "decod_mode" + ], + "msg": "Extra inputs are not permitted", + "type": "extra_forbidden" + } + ] + } + } } } }, - "description": "Validation Error" + "description": "Request body failed Pydantic validation. Common causes: a value in `categories` that isn't a canonical label, an unknown key in `options.opf`, or a wrong type on any field." }, "502": { "content": { diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 619c7ab..c7f5dab 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2,25 +2,54 @@ openapi: 3.1.0 info: title: Privacy-detection API summary: Unified PII detection across OPF, GLiNER, Presidio, and Skyflow. - description: "Unified PII detection across **OPF**, **GLiNER**, **Presidio**, and\ - \ **Skyflow**.\nPick a backend with the `detector` field; canonical labels apply\ - \ uniformly across all of them.\n\n## Endpoints\n\n- `POST /v1/detect` — return\ - \ spans only, no text rewriting.\n- `POST /v1/sanitize` — detect and rewrite spans\ - \ under one of four modes.\n- `GET /v1/detectors` — list registered detectors\ - \ and their category coverage.\n- `GET /v1/health` — liveness probe; does not\ - \ exercise detector backends.\n\n## Versioning\n\nTwo version numbers appear in\ - \ this API. They are independent:\n\n- `info.version` (this spec) — tracks the\ - \ OpenAPI contract.\n- `schema_version` (response payload field) — tracks the\ - \ request/response payload shape.\n Currently `2`. Bumps when payload field names\ - \ or semantics change. Clients should pin on this.\n\n## Canonical labels\n\n\ - Every detector's raw output maps into a 15-label taxonomy:\n`PERSON`, `EMAIL`,\ - \ `PHONE`, `ADDRESS`, `URL`, `DATE`, `ACCOUNT`, `SECRET`, `USERNAME`,\n`DEMOGRAPHIC`,\ - \ `ORGANIZATION`, `OCCUPATION`, `MONEY`, `VEHICLE`, `PHYSICAL`.\nDetectors vary\ - \ in coverage — `GET /v1/detectors` reports each detector's category list.\n" + description: 'Unified PII detection across **OPF**, **GLiNER**, **Presidio**, and + **Skyflow**. + + Pick a backend with the `detector` field; canonical labels apply uniformly across + all of them. + + + ## Endpoints + + + - `POST /v1/detect` — return spans only, no text rewriting. + + - `POST /v1/sanitize` — detect and rewrite spans under one of four modes. + + - `GET /v1/detectors` — list registered detectors and their category coverage. + + - `GET /v1/health` — liveness probe; does not exercise detector backends. + + + ## Versioning + + + The API is pre-`1.0`. `info.version` is the only version surface — every + + breaking change to request or response shape lands as a minor bump + + (`0.x.0 -> 0.(x+1).0`). Non-breaking additions land as patch bumps. The + + `/v1/` URL prefix is reserved for the eventual `1.0` cutover. + + + ## Canonical labels + + + Every detector''s raw output maps into a 15-label taxonomy: + + `PERSON`, `EMAIL`, `PHONE`, `ADDRESS`, `URL`, `DATE`, `ACCOUNT`, `SECRET`, `USERNAME`, + + `DEMOGRAPHIC`, `ORGANIZATION`, `OCCUPATION`, `MONEY`, `VEHICLE`, `PHYSICAL`. + + Detectors vary in coverage — `GET /v1/detectors` reports each detector''s category + list. + + ' contact: name: local-privacy url: https://github.com/jstjoe/local-privacy - version: 0.2.0 + version: 0.3.0 servers: - url: http://localhost:8000 description: Local dev @@ -39,7 +68,7 @@ paths: Filter detector output to a subset of canonical labels with `categories`. - OPF-only: pass `decode_mode` to override the default Viterbi decoding.' + OPF-only: pass `options.opf.decode_mode` to override the default Viterbi decoding.' operationId: detect_v1_detect_post requestBody: content: @@ -55,8 +84,7 @@ paths: schema: $ref: '#/components/schemas/DetectResponse' '400': - description: Unknown detector, unknown category, or missing config for the - chosen mode. + description: Unknown detector or missing config for the chosen mode. content: application/json: examples: @@ -65,16 +93,41 @@ paths: value: detail: 'unknown detector ''foo''; available: [''gliner'', ''opf'', ''presidio'']' - unknown_category: - summary: Unknown canonical category - value: - detail: 'unknown canonical categories: [''FOO'']. Valid: [''ACCOUNT'', - ''ADDRESS'', ...]' label_token_unconfigured: summary: label_token mode without vault env value: detail: mode='label_token' requires SKYFLOW_TOKEN_VAULT_URL and SKYFLOW_TOKEN_VAULT_ID env vars to be set + '422': + description: 'Request body failed Pydantic validation. Common causes: a + value in `categories` that isn''t a canonical label, an unknown key in + `options.opf`, or a wrong type on any field.' + content: + application/json: + examples: + bad_category: + summary: Non-canonical value in `categories` + value: + detail: + - type: enum + loc: + - body + - categories + - 0 + msg: Input should be 'PERSON', 'EMAIL', 'PHONE', ... + input: FOO + unknown_opf_option: + summary: Unknown key in `options.opf` + value: + detail: + - type: extra_forbidden + loc: + - body + - options + - opf + - decod_mode + msg: Extra inputs are not permitted + input: argmax '502': description: Detector backend failure or vault call failure. content: @@ -88,12 +141,6 @@ paths: summary: Token vault call failed value: detail: 'label_token: vault insert failed: 401' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' /v1/sanitize: post: tags: @@ -146,8 +193,7 @@ paths: schema: $ref: '#/components/schemas/SanitizeResponse' '400': - description: Unknown detector, unknown category, or missing config for the - chosen mode. + description: Unknown detector or missing config for the chosen mode. content: application/json: examples: @@ -156,16 +202,41 @@ paths: value: detail: 'unknown detector ''foo''; available: [''gliner'', ''opf'', ''presidio'']' - unknown_category: - summary: Unknown canonical category - value: - detail: 'unknown canonical categories: [''FOO'']. Valid: [''ACCOUNT'', - ''ADDRESS'', ...]' label_token_unconfigured: summary: label_token mode without vault env value: detail: mode='label_token' requires SKYFLOW_TOKEN_VAULT_URL and SKYFLOW_TOKEN_VAULT_ID env vars to be set + '422': + description: 'Request body failed Pydantic validation. Common causes: a + value in `categories` that isn''t a canonical label, an unknown key in + `options.opf`, or a wrong type on any field.' + content: + application/json: + examples: + bad_category: + summary: Non-canonical value in `categories` + value: + detail: + - type: enum + loc: + - body + - categories + - 0 + msg: Input should be 'PERSON', 'EMAIL', 'PHONE', ... + input: FOO + unknown_opf_option: + summary: Unknown key in `options.opf` + value: + detail: + - type: extra_forbidden + loc: + - body + - options + - opf + - decod_mode + msg: Extra inputs are not permitted + input: argmax '502': description: Detector backend failure or vault call failure. content: @@ -179,12 +250,6 @@ paths: summary: Token vault call failed value: detail: 'label_token: vault insert failed: 401' - '422': - description: Validation Error - content: - application/json: - schema: - $ref: '#/components/schemas/HTTPValidationError' /v1/detectors: get: tags: @@ -220,6 +285,27 @@ paths: $ref: '#/components/schemas/HealthResponse' components: schemas: + CanonicalLabel: + type: string + enum: + - PERSON + - EMAIL + - PHONE + - ADDRESS + - URL + - DATE + - ACCOUNT + - SECRET + - USERNAME + - DEMOGRAPHIC + - ORGANIZATION + - OCCUPATION + - MONEY + - VEHICLE + - PHYSICAL + title: CanonicalLabel + description: Canonical PII category. Every detector's raw output maps into this + taxonomy. DetectRequest: properties: text: @@ -242,30 +328,26 @@ components: categories: anyOf: - items: - type: string + $ref: '#/components/schemas/CanonicalLabel' type: array - type: 'null' title: Categories - description: 'Canonical categories to keep. Valid values: PERSON, EMAIL, - PHONE, ADDRESS, URL, DATE, ACCOUNT, SECRET, USERNAME, DEMOGRAPHIC, ORGANIZATION, - OCCUPATION, MONEY, VEHICLE, PHYSICAL. Omit or set to null to keep all - categories the detector produces.' + description: Canonical categories to keep. Omit or set to `null` to keep + every category the detector produces. An empty list `[]` is a deliberate + "match nothing" filter and returns zero spans — use `null` if you mean + "no filter". Values outside the canonical taxonomy are rejected with `422`. examples: - - EMAIL - PHONE - decode_mode: + options: anyOf: - - type: string - enum: - - viterbi - - argmax + - $ref: '#/components/schemas/DetectorOptions' - type: 'null' - title: Decode Mode - description: OPF-only decode strategy. Ignored by every other detector. - `viterbi` (default) maximises sequence probability; `argmax` picks the - most likely label per token independently. + description: Per-detector options namespace. The entry matching the top-level + `detector` is consulted; other entries are accepted but ignored. examples: - - viterbi + - opf: + decode_mode: argmax type: object required: - text @@ -278,12 +360,6 @@ components: text: Email joe@example.com about the trip to Elgin, TX. DetectResponse: properties: - schema_version: - type: integer - title: Schema Version - description: Payload schema version. Independent of the OpenAPI `info.version`. - examples: - - 2 detector: type: string title: Detector @@ -308,7 +384,6 @@ components: description: Non-fatal warning surfaced by the detector backend, if any. type: object required: - - schema_version - detector - text - detected_spans @@ -323,7 +398,6 @@ components: start: 6 text: joe@example.com detector: presidio - schema_version: 2 summary: by_label: EMAIL: 1 @@ -339,7 +413,7 @@ components: - opf categories: items: - type: string + $ref: '#/components/schemas/CanonicalLabel' type: array title: Categories description: Canonical categories this detector can produce. @@ -368,6 +442,23 @@ components: - proxy title: DetectorInfo description: One row in the detector registry. + DetectorOptions: + properties: + opf: + anyOf: + - $ref: '#/components/schemas/OpfOptions' + - type: 'null' + description: Options for the `opf` detector. Ignored by other detectors. + type: object + title: DetectorOptions + description: 'Per-detector options namespace. Each key carries options for that + one + + detector. Only the entry matching the top-level `detector` is consulted — + + other keys are accepted but ignored, so a client can carry one options + + blob across detector swaps without restructuring it.' DetectorsResponse: properties: default: @@ -409,15 +500,6 @@ components: loaded: false name: skyflow proxy: true - HTTPValidationError: - properties: - detail: - items: - $ref: '#/components/schemas/ValidationError' - type: array - title: Detail - type: object - title: HTTPValidationError HealthResponse: properties: status: @@ -439,26 +521,38 @@ components: description: Names of detectors that have been initialised so far. examples: - - opf - schema_version: - type: integer - title: Schema Version - description: Payload schema version. Independent of the OpenAPI `info.version`. - examples: - - 2 type: object required: - status - default_detector - loaded_detectors - - schema_version title: HealthResponse description: Response from `/v1/health`. examples: - default_detector: opf loaded_detectors: - opf - schema_version: 2 status: ok + OpfOptions: + properties: + decode_mode: + type: string + enum: + - viterbi + - argmax + title: Decode Mode + description: OPF decode strategy. `viterbi` (default) maximises sequence + probability; `argmax` picks the most likely label per token independently. + **Currently advisory** — the OPF detector reads its decode mode from the + server's `OPF_DECODE_MODE` env at startup; per-request override is reserved + for a follow-up that extends `OPFDetector.detect()`. + default: viterbi + examples: + - viterbi + additionalProperties: false + type: object + title: OpfOptions + description: Options that only apply when `detector` is `opf`. SanitizeRequest: properties: text: @@ -481,30 +575,26 @@ components: categories: anyOf: - items: - type: string + $ref: '#/components/schemas/CanonicalLabel' type: array - type: 'null' title: Categories - description: 'Canonical categories to keep. Valid values: PERSON, EMAIL, - PHONE, ADDRESS, URL, DATE, ACCOUNT, SECRET, USERNAME, DEMOGRAPHIC, ORGANIZATION, - OCCUPATION, MONEY, VEHICLE, PHYSICAL. Omit or set to null to keep all - categories the detector produces.' + description: Canonical categories to keep. Omit or set to `null` to keep + every category the detector produces. An empty list `[]` is a deliberate + "match nothing" filter and returns zero spans — use `null` if you mean + "no filter". Values outside the canonical taxonomy are rejected with `422`. examples: - - EMAIL - PHONE - decode_mode: + options: anyOf: - - type: string - enum: - - viterbi - - argmax + - $ref: '#/components/schemas/DetectorOptions' - type: 'null' - title: Decode Mode - description: OPF-only decode strategy. Ignored by every other detector. - `viterbi` (default) maximises sequence probability; `argmax` picks the - most likely label per token independently. + description: Per-detector options namespace. The entry matching the top-level + `detector` is consulted; other entries are accepted but ignored. examples: - - viterbi + - opf: + decode_mode: argmax mode: type: string enum: @@ -536,13 +626,6 @@ components: text: Email alice@x.com or call +1-415-555-0100. SanitizeResponse: properties: - schema_version: - type: integer - title: Schema Version - description: Payload schema version. Bumps when field names or semantics - change. Independent of `info.version` in the OpenAPI spec. - examples: - - 2 detector: type: string title: Detector @@ -590,7 +673,6 @@ components: description: Non-fatal warning surfaced by the detector backend, if any. type: object required: - - schema_version - detector - mode - text @@ -616,7 +698,6 @@ components: detector: presidio mode: label_token sanitized_text: Email [EMAIL_MGaE1Bo] or call [PHONE_vRXiWKZ]. - schema_version: 2 summary: by_label: EMAIL: 1 @@ -736,32 +817,6 @@ components: title: SummaryOut description: Per-response detection summary. `by_label` maps canonical label to count. - ValidationError: - properties: - loc: - items: - anyOf: - - type: string - - type: integer - type: array - title: Location - msg: - type: string - title: Message - type: - type: string - title: Error Type - input: - title: Input - ctx: - type: object - title: Context - type: object - required: - - loc - - msg - - type - title: ValidationError tags: - name: Detect description: Detection-only endpoint. Returns spans without rewriting the input. diff --git a/docs/guides/auth-and-env.md b/docs/guides/auth-and-env.md index 831af5f..45cc72b 100644 --- a/docs/guides/auth-and-env.md +++ b/docs/guides/auth-and-env.md @@ -17,7 +17,8 @@ The API itself is unauthenticated today — front it with whatever your deployme ## Error codes -- `400` — unknown detector, unknown canonical category, or `label_token` mode requested without the vault env. +- `400` — unknown detector, or `label_token` mode requested without the vault env. Body is `{"detail": ""}`. +- `422` — request body failed Pydantic validation. Common causes: a value in `categories` that isn't a canonical label, an unknown key inside `options.opf`, or a wrong type on any field. Body is `{"detail": [{"type", "loc", "msg", "input"}, ...]}` — one entry per failing field. - `502` — detector backend errored, or token vault call failed. Always `200` from `/v1/health` while the process is up; it does not probe detector backends. diff --git a/docs/guides/overview.md b/docs/guides/overview.md index 179c326..ae16078 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -29,9 +29,4 @@ Then visit one of `/scalar`, `/docs`, `/redoc`. The OpenAPI spec itself lives at ## Versioning -Two version numbers appear in this API. They are independent. - -- `info.version` (in this OpenAPI spec) tracks the contract. Currently `0.2.0`. -- `schema_version` (in every response payload) tracks request/response payload shape. Currently `2`. Clients should pin on this. - -A future field rename or semantic shift bumps `schema_version`. A new endpoint, new field, or doc-only change does not. +The API is pre-`1.0`. `info.version` (in this OpenAPI spec) is the only version surface — currently `0.3.0`. Each breaking change to request or response shape lands as a minor bump (`0.x.0 -> 0.(x+1).0`); non-breaking additions land as patch bumps. The `/v1/` URL prefix is reserved for the eventual `1.0` cutover. diff --git a/uv.lock b/uv.lock index 72985cb..ffe4d62 100644 --- a/uv.lock +++ b/uv.lock @@ -1810,7 +1810,7 @@ requires-dist = [ [[package]] name = "opf-api" -version = "0.2.0" +version = "0.3.0" source = { editable = "api" } dependencies = [ { name = "fastapi" },