diff --git a/backend/api/prompts.py b/backend/api/prompts.py index c4d7008ea..020eb0390 100644 --- a/backend/api/prompts.py +++ b/backend/api/prompts.py @@ -36,7 +36,6 @@ class PromptCreate(BaseModel): class PromptResponse(BaseModel): - id: int prompt_uid: str title: str description: Optional[str] = None @@ -46,7 +45,7 @@ class PromptResponse(BaseModel): created_at: datetime.datetime updated_at: datetime.datetime - model_config = {"from_attributes": True} + model_config = ConfigDict(from_attributes=True) class PromptTestSettings(BaseModel): diff --git a/backend/tests/test_prompt_response_naming_contract.py b/backend/tests/test_prompt_response_naming_contract.py new file mode 100644 index 000000000..7bb7afa96 --- /dev/null +++ b/backend/tests/test_prompt_response_naming_contract.py @@ -0,0 +1,47 @@ +"""Naming and public-identifier contract for prompt responses.""" + +from __future__ import annotations + +import datetime +from types import SimpleNamespace + +from api.prompts import PromptResponse + + +def _prompt_record() -> SimpleNamespace: + """Return an ORM-shaped prompt record that still contains its private row id.""" + now = datetime.datetime(2026, 9, 1, tzinfo=datetime.timezone.utc) + return SimpleNamespace( + id=17, + prompt_uid="prompt-example", + title="Example", + description=None, + content="Summarize {{email}}", + is_shared=False, + created_by="user-example", + created_at=now, + updated_at=now, + ) + + +def test_prompt_response_uses_only_opaque_public_identifier() -> None: + """Sequential database identity must not enter the owned API response model.""" + assert "prompt_uid" in PromptResponse.model_fields + assert "id" not in PromptResponse.model_fields + assert "prompt_record_id" not in PromptResponse.model_fields + + prompt_response = PromptResponse.model_validate(_prompt_record()) + serialized_response = prompt_response.model_dump() + + assert serialized_response["prompt_uid"] == "prompt-example" + assert "id" not in serialized_response + assert "prompt_record_id" not in serialized_response + + +def test_prompt_response_json_schema_does_not_advertise_sequential_database_id() -> None: + """FastAPI's response schema must advertise the opaque UID as the sole identifier.""" + response_properties = PromptResponse.model_json_schema()["properties"] + + assert "prompt_uid" in response_properties + assert "id" not in response_properties + assert "prompt_record_id" not in response_properties diff --git a/docs/doctoring/prompt-response-semantic-identifiers.md b/docs/doctoring/prompt-response-semantic-identifiers.md new file mode 100644 index 000000000..6d013098d --- /dev/null +++ b/docs/doctoring/prompt-response-semantic-identifiers.md @@ -0,0 +1,40 @@ +# Prompt response semantic identifiers + +## Decision + +Naruon's Prompt Catalog bounded context already has an opaque public identifier, `prompt_uid`, and a separate sequential database row identity, `PromptTemplate.id`. The public `/api/prompts` list and create responses now expose only `prompt_uid`; the sequential row identity is not part of `PromptResponse` at all. + +The original naming-only draft moved `id` behind `prompt_record_id` while retaining a public `id` alias. Fresh independent review correctly identified that this preserved unnecessary sequential-database identity exposure. The repair therefore removes both `PromptResponse.id` and `PromptResponse.prompt_record_id` from the response schema rather than merely recasing or aliasing them. + +| Previous public field | Current public field | Meaning | +| --- | --- | --- | +| `id` (sequential database row id) | removed | private persistence identity | +| `prompt_uid` | `prompt_uid` | opaque public prompt identity | + +## DDD, security, and compatibility boundary + +- **Bounded context:** Prompt Catalog. +- **Entity:** persisted prompt-template record. +- **Public identity:** `prompt_uid` is the sole prompt identifier in list/create response contracts. +- **Persistence identity:** `PromptTemplate.id` remains private to persistence and may still exist on ORM records; Pydantic `from_attributes=True` ignores it because it is not a response field. +- **Authorization invariant:** organization/workspace ownership filters on `list_prompts` and creation ownership assignments remain unchanged. Opaque identifiers are defense-in-depth and do not replace object-level authorization. +- **Public contract change:** the redundant sequential `id` response property is intentionally removed. Existing clients must use the already-present `prompt_uid` for prompt identity. +- **Persistence:** unchanged. No database migration, backfill, index change, new lock, UPSERT change, partition change, or read/write split is introduced. + +OWASP API Security Top 10 API1:2023 notes that object identifiers, including sequential integers, are common BOLA attack inputs and recommends random, unpredictable record identifiers together with proper object-level authorization. Naruon already has the unpredictable `prompt_uid`, so retaining a second sequential public identifier had no buyer-visible product benefit and widened the identifier surface unnecessarily. + +## Verification contract + +`backend/tests/test_prompt_response_naming_contract.py` constructs an ORM-shaped record that still contains private `id=17`, validates it into `PromptResponse`, and requires both runtime serialization and the generated JSON schema to omit `id` and `prompt_record_id` while retaining `prompt_uid`. Existing prompt API tests continue to exercise list/create behavior, organization/workspace scoping, and prompt UID creation. Exact-head repository CI, security workflows, review threads, and branch protection remain authoritative merge evidence. + +## Research traceability + +Empirical software-engineering research supports treating identifier names as program-comprehension artifacts rather than cosmetic style. Feitelson et al. found that explicitly choosing the concepts represented in a name improved judged name quality and tended to produce names containing more concepts; later replication work corroborated that model and found that merely making names longer was not equivalent to selecting meaningful concepts. Here the stronger domain conclusion is that the public concept is already fully represented by `prompt_uid`; a second database-row identifier should not be renamed and exported when it is not part of the public domain language. + +### References + +Alpern, R., Lazer, I., Tzachor, I., Hakim, H., Weissbuch, S., & Feitelson, D. G. (2024). *Reproducing, extending, and analyzing naming experiments*. arXiv. https://doi.org/10.48550/arXiv.2402.10022 + +Feitelson, D. G., Mizrahi, A., Noy, N., Ben Shabat, A., Eliyahu, O., & Sheffer, R. (2022). How developers choose names. *IEEE Transactions on Software Engineering, 48*(1), 37–52. https://doi.org/10.1109/TSE.2020.2976920 + +OWASP Foundation. (2023). *API1:2023 Broken Object Level Authorization*. OWASP API Security Top 10. https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/