diff --git a/CLAUDE.md b/CLAUDE.md index e1814aba..da3ec4be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,7 +190,7 @@ mcp-data-platform/ │ ├── pkcestore/ # In-flight PKCE state for outbound OAuth (oauth-start → callback) │ ├── platform/ # Core orchestration: facade, config, options, lifecycle (fieldcrypt/, instructions/, personastore/ = seams shared with pkg/admin; other facade-internal seams live under internal/platform/) │ ├── portal/ # Asset portal data layer (assetindex/, collectionindex/, datahubapi/, knowledgepage/, threads/, ...) -│ ├── prompt/ # Prompt management for the MCP data platform +│ ├── prompt/ # Prompt management: versioned store contract, review gate (versionhttp/ = version-history/approval/usage REST for the admin + portal surfaces) │ ├── query/ # Query execution provider abstraction (trino/ = Trino adapter) │ ├── ratelimit/ # Shared per-IP token-bucket limiter + trusted-proxy client-IP resolver (portal viewer, OAuth endpoints) │ ├── registry/ # Toolkit registration and management diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 18ac7345..5ec6b7c0 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -2173,7 +2173,7 @@ Structured feedback from reviewers (including non-agent subject-matter experts a The single home for the Memory to Insight to Knowledge lifecycle (formerly the separate Knowledge Pages, Knowledge & Memory, and admin Knowledge & Memory routes, which now redirect here). A header teaches the model: everything learned is a Memory; a memory others would benefit from becomes an Insight (a proposal awaiting review); whoever holds the `apply_knowledge` capability promotes good insights into Knowledge (business/domain facts become knowledge pages, technical/entity facts go to the DataHub catalog). Three tabs, with review/promote affordances gated on the `apply_knowledge` tool (a capability, not an admin role). Knowledge (default): unified search across every accessible source grouped by source with a coverage summary (the same federation as the `search` tool, over `GET /api/v1/portal/search`); with an empty query, browse of canonical knowledge pages (create/edit/remove for `apply_knowledge` holders); and for `apply_knowledge` holders the changesets (the record of insights promoted into knowledge, with rollback) since a changeset is created only at apply time and belongs with the promoted knowledge, not the unpromoted insights. Insights: the review pipeline only (insights are the one memory type that crosses between users) - your captured insights with status and relevance search, plus for `apply_knowledge` holders the full review queue (approve/reject); a pending-review count is badged on the sidebar Knowledge item and the Insights tab. Memory: personal, scoped to your own records (the cross-user unit is the insight), classified by lifecycle class (`sink_class`: Preference, Event, Business knowledge, Operational rule, Schema/entity). The Knowledge tab also has Catalog and Context Docs sub-tabs (#719/#720), first-class routes at `/knowledge/catalog` and `/knowledge/context-docs`, that surface the DataHub catalog and context documents in the portal. Catalog: pick a connection, browse/search datasets, open one to see description, tags, owners, glossary terms, domain, and columns, and edit each facet inline when the persona grants `datahub_update` and the connection is writable (no dataset create/delete since datasets originate in source systems). Context Docs: browse/search and full create/edit/delete of markdown context documents through a markdown editor, gated on `datahub_create`/`datahub_update`/`datahub_delete`; a document attaches only to Dataset/GlossaryTerm/GlossaryNode/Container. Both are backed by the portal DataHub REST API at `/api/v1/portal/datahub/{connection}/...` (`GET .../connections` lists connections with a writable flag): reads require DataHub access on the persona; a write requires the matching MCP tool grant AND a write-enabled connection (`read_only: false`), both enforced server-side and recorded in the audit log. Tag and glossary-term edits use batched add/remove sets (the clobber-safe write path, #721/#729). ## Prompts -Personal, available, and shared prompt templates. Three tabs: Personal (create/edit/delete own prompts), Available (global/persona prompts), and Shared (prompts other users shared with you). Search ranks approved prompts by relevance to a phrase (semantic vector similarity when an embedding provider is configured, keyword fallback otherwise) across the caller's full visibility, best-first; browse mode keeps sortable columns. The same ranking backs the MCP tool `manage_prompt list query=...`. Approved prompts are embedded off the request path by the shared index-jobs framework (source_kind `prompts`); editing a prompt's title, description, body, or tags clears its vector so it re-embeds against the new text. Visibility (own personal, global, matching-persona; all approved for admins) is applied before ranking, so a prompt you cannot read is never returned. Sortable columns, expandable rows with full content and copy button. Scope badges: Personal, Global, Persona, System. Lifecycle status badge (draft, approved, deprecated, superseded) and comma-separated tags on create/edit. Request Promotion on a personal prompt asks an admin to promote it to a chosen persona or to global; the prompt stays personal with a "Promotion requested" badge until an admin approves or rejects it. Share sends a personal prompt directly to another user by email (owner-initiated, no approval): the recipient gets a real runnable prompt, not a markdown snapshot, and it appears on the Prompts page's Shared tab. Personal prompt names are unique per owner; when served over MCP, names carry a scope prefix computed at serve time: `personal-`, `-` (one per persona), `global-`, or `shared-` for prompts shared with the caller, keeping the surface collision-free by construction; every descriptor carries a `title` from display_name. Users never need machine names: agents resolve any handle (stored name, display name, `mcp:prompt:`, or free text) to a ready-to-run prompt with the `manage_prompt` `use` command, which returns rendered content, argument specs, and provenance, or ranked candidates when ambiguous. +Personal, available, and shared prompt templates. Three tabs: Personal (create/edit/delete own prompts), Available (global/persona prompts), and Shared (prompts other users shared with you). Search ranks approved prompts by relevance to a phrase (semantic vector similarity when an embedding provider is configured, keyword fallback otherwise) across the caller's full visibility, best-first; browse mode keeps sortable columns. The same ranking backs the MCP tool `manage_prompt list query=...`. Approved prompts are embedded off the request path by the shared index-jobs framework (source_kind `prompts`); editing a prompt's title, description, body, or tags clears its vector so it re-embeds against the new text. Visibility (own personal, global, matching-persona; all approved for admins) is applied before ranking, so a prompt you cannot read is never returned. Sortable columns, expandable rows with full content and copy button. Scope badges: Personal, Global, Persona, System. Lifecycle status badge (draft, approved, deprecated, superseded) and comma-separated tags on create/edit. Request Promotion on a personal prompt asks an admin to promote it to a chosen persona or to global; the prompt stays personal with a "Promotion requested" badge until an admin approves or rejects it. Share sends a personal prompt directly to another user by email (owner-initiated, no approval): the recipient gets a real runnable prompt, not a markdown snapshot, and it appears on the Prompts page's Shared tab. Personal prompt names are unique per owner; when served over MCP, names carry a scope prefix computed at serve time: `personal-`, `-` (one per persona), `global-`, or `shared-` for prompts shared with the caller, keeping the surface collision-free by construction; every descriptor carries a `title` from display_name. Users never need machine names: agents resolve any handle (stored name, display name, `mcp:prompt:`, or free text) to a ready-to-run prompt with the `manage_prompt` `use` command, which returns rendered content, argument specs, and provenance (including version, approver, and approval time), or ranked candidates when ambiguous. Every database prompt is versioned (#1009): each mutation of content, display name, description, arguments, or tags snapshots an immutable `prompt_versions` row with its author, and approval stamps bind to the specific version approved (approving v5 never alters v4's recorded approval). Editing the content or arguments of an approved global or persona prompt does not change what is served: the edit lands as a pending draft version (manage_prompt update returns status pending_approval; a gated content edit cannot be combined with scope/status changes in one call) and the approved snapshot keeps serving until an admin approves the draft via `POST /api/v1/admin/prompts/{id}/versions/{version}/approve` (reject with `.../reject`; full history with author and content via `GET /api/v1/admin/prompts/{id}/versions`, owner-readable per prompt via `GET /api/v1/portal/prompts/{id}/versions`). Metadata-only edits (tags, category, description, display name) apply directly; personal prompts version silently. Served prompts carry provenance: `prompts/get` stamps `prompt_version` / `prompt_approved_by` / `prompt_approved_at` / `prompt_reference` into `_meta`. Usage stats are aggregated from `prompt_serve` audit events (emitted on every database-prompt `prompts/get` and resolved `use`, within the audit retention window): `manage_prompt get` reports `run_count` and `last_run_at`, and `GET /api/v1/admin/prompts/usage` / `GET /api/v1/portal/prompts/usage` return the per-prompt rollup (portal scoped to the caller's visible prompts) for library curation and dead-prompt detection. --- diff --git a/docs/llms.txt b/docs/llms.txt index 57b80c1e..c62c0d0a 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -12,7 +12,7 @@ mcp-data-platform is the orchestration layer for the txn2 MCP ecosystem. DataHub - [The Data Stack](https://mcp-data-platform.txn2.com/concepts/components/): Why DataHub, Trino, and S3, what each component brings, and how cross-enrichment makes them greater than the sum of their parts - [Operating Modes](https://mcp-data-platform.txn2.com/server/operating-modes/): Two deployment modes, standalone (no database) and database-backed. Feature availability by mode, example configurations, decision guide - [Deployment](https://mcp-data-platform.txn2.com/server/deployment/): Docker Compose and Kubernetes/Helm deployment guides, including how connected agents pick up tool-contract changes across an upgrade -- [Tools](https://mcp-data-platform.txn2.com/server/tools/): Complete tool list for every toolkit (DataHub, Trino, S3, knowledge, memory, portal, gateways), plus platform_find_tools semantic tool discovery, manage_prompt with the use resolve-and-run verb (any handle: name, display name, mcp:prompt:, free text), and the uniform structured error envelope +- [Tools](https://mcp-data-platform.txn2.com/server/tools/): Complete tool list for every toolkit (DataHub, Trino, S3, knowledge, memory, portal, gateways), plus platform_find_tools semantic tool discovery, manage_prompt with the use resolve-and-run verb (any handle: name, display name, mcp:prompt:, free text), prompt versioning with approval provenance (content edits to approved shared prompts pend as draft versions until admin approval; served prompts carry version and approver; run counts from prompt-serve audit events), and the uniform structured error envelope - [Multi-Provider](https://mcp-data-platform.txn2.com/server/multi-provider/): Connect multiple instances of each service - [Audit Logging](https://mcp-data-platform.txn2.com/server/audit/): PostgreSQL-backed audit logging for tool calls: schema and field reference, parameter sanitization with configurable redact_keys and log_parameters opt-out, async vs sync delivery semantics and the audit_events_dropped_total metric, caller-class separation, monthly partition rotation, and retention - [Observability (Metrics)](https://mcp-data-platform.txn2.com/server/observability/): OpenTelemetry Prometheus metrics covering tool calls, gateway HTTP calls, and toolkit/provider internals, plus optional OTLP distributed tracing and an authenticated PromQL proxy for the portal diff --git a/docs/server/configuration.md b/docs/server/configuration.md index 6e040866..e72a3ea3 100644 --- a/docs/server/configuration.md +++ b/docs/server/configuration.md @@ -216,6 +216,8 @@ All registered prompts (platform + toolkit) are included in the `platform_info` **Prompt titles and resolution.** Database prompts are served on the native MCP prompts surface under per-viewer scope-prefixed names (`global-`, `-`, `personal-`, `shared-`), which keeps the surface collision-free by construction; every descriptor carries a `title` from `display_name` so clients show the human name regardless. Users never need to know any machine name: agents resolve a prompt from whatever handle the user says (stored name, display name, `mcp:prompt:`, or free text) with the `manage_prompt` `use` command. +**Versioning and approval provenance.** Every database prompt is versioned: each mutation of its content, display name, description, arguments, or tags snapshots an immutable version row with the author, and approval stamps bind to the specific version that was approved. Editing the content or arguments of an approved global or persona prompt does not change what is served: the edit is saved as a pending draft version, and the approved snapshot keeps serving until an admin approves the draft (admin API `POST /api/v1/admin/prompts/{id}/versions/{version}/approve`, or reject with `.../reject`; history via `GET .../versions`). Metadata-only edits (tags, category, description, display name) apply directly. Personal prompts version silently without review. Served prompts carry their provenance: `prompts/get` responses stamp `prompt_version`, `prompt_approved_by`, `prompt_approved_at`, and `prompt_reference` into `_meta`, and `manage_prompt use` reports the same in its provenance block, so an agent can state "running Daily Sales Report v4, approved by jane@example.com" before executing. Run counts and last-run timestamps are aggregated from prompt-serve audit events (within the audit retention window) and exposed on `manage_prompt get` and the `GET /api/v1/admin/prompts/usage` and `GET /api/v1/portal/prompts/usage` endpoints. + ### Streamable HTTP Configuration The HTTP transport serves both legacy SSE (`/sse`, `/message`) and Streamable HTTP (`/`) endpoints. Streamable HTTP session behavior is configured under `server.streamable`: diff --git a/docs/server/tools.md b/docs/server/tools.md index 2bb2852d..5319e386 100644 --- a/docs/server/tools.md +++ b/docs/server/tools.md @@ -1015,10 +1015,12 @@ This is discovery, not routing: the agent still chooses which returned tool to c - an `mcp:prompt:` reference (the same reference `search` results carry), - or free text, ranked against the library. -A single confident match returns `status: "resolved"` with the prompt content (argument values passed in `args` are substituted), its argument specs, any required arguments still missing, and provenance (scope, status, approver, owner, reference) so the agent can confirm what it is about to run. An ambiguous handle returns `status: "ambiguous"` with a short ranked candidate list to disambiguate, never an error and never a silent first-match. Operator, workflow, and toolkit prompts resolve through `use` as well (they remain read-only to the management commands); the auto-generated `platform-overview` prompt is served only on the native prompts surface. +A single confident match returns `status: "resolved"` with the prompt content (argument values passed in `args` are substituted), its argument specs, any required arguments still missing, and provenance (scope, status, version, approver, owner, reference) so the agent can confirm what it is about to run: "running Daily Sales Report v4, approved by jane@example.com". An ambiguous handle returns `status: "ambiguous"` with a short ranked candidate list to disambiguate, never an error and never a silent first-match. Operator, workflow, and toolkit prompts resolve through `use` as well (they remain read-only to the management commands); the auto-generated `platform-overview` prompt is served only on the native prompts surface. Non-admins manage only their own personal prompts; admins manage every scope. On the native MCP prompts surface, database prompts are presented under per-viewer scope-prefixed names (see [Configuration: Prompts](configuration.md#prompts)); `use` insulates users from those presented names entirely. +**Versioning and review.** Every mutation of a prompt's content, display name, description, arguments, or tags snapshots an immutable version with its author; approval stamps bind to the specific version approved. Editing the content or arguments of an **approved global or persona** prompt does not apply immediately: `update` returns `status: "pending_approval"` with the draft's `pending_version`, and every caller keeps being served the approved snapshot until an admin approves the draft (in the admin portal, or `POST /api/v1/admin/prompts/{id}/versions/{version}/approve`). A gated content edit cannot be combined with scope/status/other non-versioned changes in one call; submit them separately. Personal prompts and never-approved drafts version silently. `get` also reports `run_count` and `last_run_at`, aggregated from prompt-serve audit events (each `prompts/get` and resolved `use` counts as a serve). + --- ## Next Steps diff --git a/internal/apidocs/docs.go b/internal/apidocs/docs.go index ea4d5a50..b39f7568 100644 --- a/internal/apidocs/docs.go +++ b/internal/apidocs/docs.go @@ -5373,6 +5373,46 @@ const docTemplate = `{ } } }, + "/admin/prompts/usage": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Returns run count and last-run timestamp per prompt id, aggregated from prompt_serve audit events within the audit retention window. Prompts never served are absent from the map.", + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "Prompt usage stats", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/prompt.Usage" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/admin/prompts/{id}": { "get": { "security": [ @@ -5430,7 +5470,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "Updates an existing prompt by ID and re-registers it with the live MCP server.", + "description": "Updates an existing prompt by ID and re-registers it with the live MCP server. A content or arguments edit to an approved global or persona prompt is deferred as a pending draft version (202): the approved snapshot keeps being served until the draft is approved via the version approval endpoint.", "consumes": [ "application/json" ], @@ -5466,6 +5506,12 @@ const docTemplate = `{ "$ref": "#/definitions/prompt.Prompt" } }, + "202": { + "description": "Accepted", + "schema": { + "$ref": "#/definitions/prompt.EditOutcome" + } + }, "400": { "description": "Bad Request", "schema": { @@ -5650,6 +5696,268 @@ const docTemplate = `{ } } }, + "/admin/prompts/{id}/versions": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Returns every version of a prompt with full content and author, newest first.", + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "List prompt versions", + "parameters": [ + { + "type": "string", + "description": "Prompt ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/versionhttp.versionListResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/admin/prompts/{id}/versions/{version}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Returns a single prompt version snapshot.", + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "Get prompt version", + "parameters": [ + { + "type": "string", + "description": "Prompt ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Version number", + "name": "version", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/prompt.Version" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/admin/prompts/{id}/versions/{version}/approve": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Applies a pending draft version's snapshot to the live prompt, stamping the approval on that specific version. Other pending drafts are superseded.", + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "Approve prompt version", + "parameters": [ + { + "type": "string", + "description": "Prompt ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Version number", + "name": "version", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/prompt.Prompt" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/admin/prompts/{id}/versions/{version}/reject": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Rejects a pending draft version, leaving the live prompt unchanged.", + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "Reject prompt version", + "parameters": [ + { + "type": "string", + "description": "Prompt ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Version number", + "name": "version", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/admin/settings/smtp": { "get": { "security": [ @@ -10078,6 +10386,55 @@ const docTemplate = `{ } } }, + "/portal/prompts/usage": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Returns run count and last-run timestamp per prompt id for the prompts visible to the caller.", + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "Prompt usage stats (portal)", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/prompt.Usage" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/portal/prompts/{id}": { "put": { "security": [ @@ -10088,7 +10445,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "Updates a personal prompt owned by the current user. Admins can update any prompt.", + "description": "Updates a personal prompt owned by the current user. Admins can update any prompt; an admin's content edit to an approved shared prompt is deferred as a pending draft version (202) until approved.", "consumes": [ "application/json" ], @@ -10124,6 +10481,12 @@ const docTemplate = `{ "$ref": "#/definitions/prompt.Prompt" } }, + "202": { + "description": "Accepted", + "schema": { + "$ref": "#/definitions/prompt.EditOutcome" + } + }, "400": { "description": "Bad Request", "schema": { @@ -10354,6 +10717,79 @@ const docTemplate = `{ } } }, + "/portal/prompts/{id}/versions": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Returns the version history of a prompt the caller owns; admins may read any prompt's history.", + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "List prompt versions (portal)", + "parameters": [ + { + "type": "string", + "description": "Prompt ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/versionhttp.versionListResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/portal/search": { "get": { "security": [ @@ -14190,14 +14626,16 @@ const docTemplate = `{ "auth", "admin", "mcp_tool_call", - "apigateway_invoke" + "apigateway_invoke", + "prompt_serve" ], "x-enum-varnames": [ "EventTypeToolCall", "EventTypeAuth", "EventTypeAdmin", "EventTypeMCPToolCall", - "EventTypeAPIGatewayInvoke" + "EventTypeAPIGatewayInvoke", + "EventTypePromptServe" ] }, "audit.Overview": { @@ -16993,6 +17431,19 @@ const docTemplate = `{ } } }, + "prompt.EditOutcome": { + "type": "object", + "properties": { + "applied": { + "description": "Applied is true when the live row was updated (the edit is being served).", + "type": "boolean" + }, + "pending_version": { + "description": "PendingVersion is the draft version number when the edit was deferred\nfor review; zero when Applied.", + "type": "integer" + } + } + }, "prompt.Prompt": { "type": "object", "properties": { @@ -17040,6 +17491,9 @@ const docTemplate = `{ "type": "string", "example": "prompt_a1b2c3d4" }, + "last_run_at": { + "type": "string" + }, "name": { "type": "string", "example": "daily-sales-report" @@ -17076,6 +17530,11 @@ const docTemplate = `{ "type": "boolean", "example": false }, + "run_count": { + "description": "Serve-derived usage (see UsageReader). Populated only on read surfaces\nwired to a usage reader (manage_prompt get, the prompt usage REST\nendpoints); omitted elsewhere. Zero/absent means never served within the\naudit retention window.", + "type": "integer", + "example": 37 + }, "scope": { "type": "string", "example": "persona" @@ -17106,6 +17565,87 @@ const docTemplate = `{ "updated_at": { "type": "string", "example": "2026-01-15T14:30:00Z" + }, + "version": { + "description": "Version is the number of the snapshot the live row currently serves\n(see VersionStore). Pending draft versions above this number exist in\nthe version history but are not served until approved.", + "type": "integer", + "example": 4 + } + } + }, + "prompt.Usage": { + "type": "object", + "properties": { + "last_run_at": { + "type": "string" + }, + "run_count": { + "type": "integer", + "example": 37 + } + } + }, + "prompt.Version": { + "type": "object", + "properties": { + "approved_at": { + "type": "string" + }, + "approved_by": { + "type": "string", + "example": "admin@example.com" + }, + "arguments": { + "type": "array", + "items": { + "$ref": "#/definitions/prompt.Argument" + } + }, + "author": { + "type": "string", + "example": "jane@example.com" + }, + "content": { + "type": "string", + "example": "Analyze sales data for {date} grouped by region." + }, + "created_at": { + "type": "string", + "example": "2026-06-12T14:30:00Z" + }, + "description": { + "type": "string", + "example": "Generate a daily sales summary by region" + }, + "display_name": { + "type": "string", + "example": "Daily Sales Report" + }, + "id": { + "type": "string", + "example": "ver_a1b2c3d4" + }, + "prompt_id": { + "type": "string", + "example": "prompt_a1b2c3d4" + }, + "status": { + "type": "string", + "example": "applied" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "sales", + "reporting" + ] + }, + "version": { + "type": "integer", + "example": 4 } } }, @@ -17179,6 +17719,21 @@ const docTemplate = `{ "type": "boolean" } } + }, + "versionhttp.versionListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/prompt.Version" + } + }, + "total": { + "type": "integer", + "example": 4 + } + } } }, "securityDefinitions": { diff --git a/internal/apidocs/swagger.json b/internal/apidocs/swagger.json index 9fcfc294..95d0fd03 100644 --- a/internal/apidocs/swagger.json +++ b/internal/apidocs/swagger.json @@ -5367,6 +5367,46 @@ } } }, + "/admin/prompts/usage": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Returns run count and last-run timestamp per prompt id, aggregated from prompt_serve audit events within the audit retention window. Prompts never served are absent from the map.", + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "Prompt usage stats", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/prompt.Usage" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/admin/prompts/{id}": { "get": { "security": [ @@ -5424,7 +5464,7 @@ "BearerAuth": [] } ], - "description": "Updates an existing prompt by ID and re-registers it with the live MCP server.", + "description": "Updates an existing prompt by ID and re-registers it with the live MCP server. A content or arguments edit to an approved global or persona prompt is deferred as a pending draft version (202): the approved snapshot keeps being served until the draft is approved via the version approval endpoint.", "consumes": [ "application/json" ], @@ -5460,6 +5500,12 @@ "$ref": "#/definitions/prompt.Prompt" } }, + "202": { + "description": "Accepted", + "schema": { + "$ref": "#/definitions/prompt.EditOutcome" + } + }, "400": { "description": "Bad Request", "schema": { @@ -5644,6 +5690,268 @@ } } }, + "/admin/prompts/{id}/versions": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Returns every version of a prompt with full content and author, newest first.", + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "List prompt versions", + "parameters": [ + { + "type": "string", + "description": "Prompt ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/versionhttp.versionListResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/admin/prompts/{id}/versions/{version}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Returns a single prompt version snapshot.", + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "Get prompt version", + "parameters": [ + { + "type": "string", + "description": "Prompt ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Version number", + "name": "version", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/prompt.Version" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/admin/prompts/{id}/versions/{version}/approve": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Applies a pending draft version's snapshot to the live prompt, stamping the approval on that specific version. Other pending drafts are superseded.", + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "Approve prompt version", + "parameters": [ + { + "type": "string", + "description": "Prompt ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Version number", + "name": "version", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/prompt.Prompt" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/admin/prompts/{id}/versions/{version}/reject": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Rejects a pending draft version, leaving the live prompt unchanged.", + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "Reject prompt version", + "parameters": [ + { + "type": "string", + "description": "Prompt ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "Version number", + "name": "version", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/admin/settings/smtp": { "get": { "security": [ @@ -10072,6 +10380,55 @@ } } }, + "/portal/prompts/usage": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Returns run count and last-run timestamp per prompt id for the prompts visible to the caller.", + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "Prompt usage stats (portal)", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/prompt.Usage" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/portal/prompts/{id}": { "put": { "security": [ @@ -10082,7 +10439,7 @@ "BearerAuth": [] } ], - "description": "Updates a personal prompt owned by the current user. Admins can update any prompt.", + "description": "Updates a personal prompt owned by the current user. Admins can update any prompt; an admin's content edit to an approved shared prompt is deferred as a pending draft version (202) until approved.", "consumes": [ "application/json" ], @@ -10118,6 +10475,12 @@ "$ref": "#/definitions/prompt.Prompt" } }, + "202": { + "description": "Accepted", + "schema": { + "$ref": "#/definitions/prompt.EditOutcome" + } + }, "400": { "description": "Bad Request", "schema": { @@ -10348,6 +10711,79 @@ } } }, + "/portal/prompts/{id}/versions": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Returns the version history of a prompt the caller owns; admins may read any prompt's history.", + "produces": [ + "application/json" + ], + "tags": [ + "Prompts" + ], + "summary": "List prompt versions (portal)", + "parameters": [ + { + "type": "string", + "description": "Prompt ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/versionhttp.versionListResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/portal/search": { "get": { "security": [ @@ -14184,14 +14620,16 @@ "auth", "admin", "mcp_tool_call", - "apigateway_invoke" + "apigateway_invoke", + "prompt_serve" ], "x-enum-varnames": [ "EventTypeToolCall", "EventTypeAuth", "EventTypeAdmin", "EventTypeMCPToolCall", - "EventTypeAPIGatewayInvoke" + "EventTypeAPIGatewayInvoke", + "EventTypePromptServe" ] }, "audit.Overview": { @@ -16987,6 +17425,19 @@ } } }, + "prompt.EditOutcome": { + "type": "object", + "properties": { + "applied": { + "description": "Applied is true when the live row was updated (the edit is being served).", + "type": "boolean" + }, + "pending_version": { + "description": "PendingVersion is the draft version number when the edit was deferred\nfor review; zero when Applied.", + "type": "integer" + } + } + }, "prompt.Prompt": { "type": "object", "properties": { @@ -17034,6 +17485,9 @@ "type": "string", "example": "prompt_a1b2c3d4" }, + "last_run_at": { + "type": "string" + }, "name": { "type": "string", "example": "daily-sales-report" @@ -17070,6 +17524,11 @@ "type": "boolean", "example": false }, + "run_count": { + "description": "Serve-derived usage (see UsageReader). Populated only on read surfaces\nwired to a usage reader (manage_prompt get, the prompt usage REST\nendpoints); omitted elsewhere. Zero/absent means never served within the\naudit retention window.", + "type": "integer", + "example": 37 + }, "scope": { "type": "string", "example": "persona" @@ -17100,6 +17559,87 @@ "updated_at": { "type": "string", "example": "2026-01-15T14:30:00Z" + }, + "version": { + "description": "Version is the number of the snapshot the live row currently serves\n(see VersionStore). Pending draft versions above this number exist in\nthe version history but are not served until approved.", + "type": "integer", + "example": 4 + } + } + }, + "prompt.Usage": { + "type": "object", + "properties": { + "last_run_at": { + "type": "string" + }, + "run_count": { + "type": "integer", + "example": 37 + } + } + }, + "prompt.Version": { + "type": "object", + "properties": { + "approved_at": { + "type": "string" + }, + "approved_by": { + "type": "string", + "example": "admin@example.com" + }, + "arguments": { + "type": "array", + "items": { + "$ref": "#/definitions/prompt.Argument" + } + }, + "author": { + "type": "string", + "example": "jane@example.com" + }, + "content": { + "type": "string", + "example": "Analyze sales data for {date} grouped by region." + }, + "created_at": { + "type": "string", + "example": "2026-06-12T14:30:00Z" + }, + "description": { + "type": "string", + "example": "Generate a daily sales summary by region" + }, + "display_name": { + "type": "string", + "example": "Daily Sales Report" + }, + "id": { + "type": "string", + "example": "ver_a1b2c3d4" + }, + "prompt_id": { + "type": "string", + "example": "prompt_a1b2c3d4" + }, + "status": { + "type": "string", + "example": "applied" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "sales", + "reporting" + ] + }, + "version": { + "type": "integer", + "example": 4 } } }, @@ -17173,6 +17713,21 @@ "type": "boolean" } } + }, + "versionhttp.versionListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/prompt.Version" + } + }, + "total": { + "type": "integer", + "example": 4 + } + } } }, "securityDefinitions": { diff --git a/internal/apidocs/swagger.yaml b/internal/apidocs/swagger.yaml index 6c178916..5f9fea97 100644 --- a/internal/apidocs/swagger.yaml +++ b/internal/apidocs/swagger.yaml @@ -1617,6 +1617,7 @@ definitions: - admin - mcp_tool_call - apigateway_invoke + - prompt_serve type: string x-enum-varnames: - EventTypeToolCall @@ -1624,6 +1625,7 @@ definitions: - EventTypeAdmin - EventTypeMCPToolCall - EventTypeAPIGatewayInvoke + - EventTypePromptServe audit.Overview: properties: avg_duration_ms: @@ -3654,6 +3656,18 @@ definitions: example: true type: boolean type: object + prompt.EditOutcome: + properties: + applied: + description: Applied is true when the live row was updated (the edit is being + served). + type: boolean + pending_version: + description: |- + PendingVersion is the draft version number when the edit was deferred + for review; zero when Applied. + type: integer + type: object prompt.Prompt: properties: approved_at: @@ -3688,6 +3702,8 @@ definitions: id: example: prompt_a1b2c3d4 type: string + last_run_at: + type: string name: example: daily-sales-report type: string @@ -3718,6 +3734,14 @@ definitions: reject (see ApplyPromotionRequest / ApprovePromotion). example: false type: boolean + run_count: + description: |- + Serve-derived usage (see UsageReader). Populated only on read surfaces + wired to a usage reader (manage_prompt get, the prompt usage REST + endpoints); omitted elsewhere. Zero/absent means never served within the + audit retention window. + example: 37 + type: integer scope: example: persona type: string @@ -3741,6 +3765,67 @@ definitions: updated_at: example: "2026-01-15T14:30:00Z" type: string + version: + description: |- + Version is the number of the snapshot the live row currently serves + (see VersionStore). Pending draft versions above this number exist in + the version history but are not served until approved. + example: 4 + type: integer + type: object + prompt.Usage: + properties: + last_run_at: + type: string + run_count: + example: 37 + type: integer + type: object + prompt.Version: + properties: + approved_at: + type: string + approved_by: + example: admin@example.com + type: string + arguments: + items: + $ref: '#/definitions/prompt.Argument' + type: array + author: + example: jane@example.com + type: string + content: + example: Analyze sales data for {date} grouped by region. + type: string + created_at: + example: "2026-06-12T14:30:00Z" + type: string + description: + example: Generate a daily sales summary by region + type: string + display_name: + example: Daily Sales Report + type: string + id: + example: ver_a1b2c3d4 + type: string + prompt_id: + example: prompt_a1b2c3d4 + type: string + status: + example: applied + type: string + tags: + example: + - sales + - reporting + items: + type: string + type: array + version: + example: 4 + type: integer type: object resource.Scope: enum: @@ -3790,6 +3875,16 @@ definitions: reachable: type: boolean type: object + versionhttp.versionListResponse: + properties: + data: + items: + $ref: '#/definitions/prompt.Version' + type: array + total: + example: 4 + type: integer + type: object host: localhost:8080 info: contact: {} @@ -7281,8 +7376,10 @@ paths: put: consumes: - application/json - description: Updates an existing prompt by ID and re-registers it with the live - MCP server. + description: 'Updates an existing prompt by ID and re-registers it with the + live MCP server. A content or arguments edit to an approved global or persona + prompt is deferred as a pending draft version (202): the approved snapshot + keeps being served until the draft is approved via the version approval endpoint.' parameters: - description: Prompt ID in: path @@ -7302,6 +7399,10 @@ paths: description: OK schema: $ref: '#/definitions/prompt.Prompt' + "202": + description: Accepted + schema: + $ref: '#/definitions/prompt.EditOutcome' "400": description: Bad Request schema: @@ -7394,6 +7495,199 @@ paths: summary: Reject prompt promotion tags: - Prompts + /admin/prompts/{id}/versions: + get: + description: Returns every version of a prompt with full content and author, + newest first. + parameters: + - description: Prompt ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/versionhttp.versionListResponse' + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - ApiKeyAuth: [] + - BearerAuth: [] + summary: List prompt versions + tags: + - Prompts + /admin/prompts/{id}/versions/{version}: + get: + description: Returns a single prompt version snapshot. + parameters: + - description: Prompt ID + in: path + name: id + required: true + type: string + - description: Version number + in: path + name: version + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/prompt.Version' + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - ApiKeyAuth: [] + - BearerAuth: [] + summary: Get prompt version + tags: + - Prompts + /admin/prompts/{id}/versions/{version}/approve: + post: + description: Applies a pending draft version's snapshot to the live prompt, + stamping the approval on that specific version. Other pending drafts are superseded. + parameters: + - description: Prompt ID + in: path + name: id + required: true + type: string + - description: Version number + in: path + name: version + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/prompt.Prompt' + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "409": + description: Conflict + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - ApiKeyAuth: [] + - BearerAuth: [] + summary: Approve prompt version + tags: + - Prompts + /admin/prompts/{id}/versions/{version}/reject: + post: + description: Rejects a pending draft version, leaving the live prompt unchanged. + parameters: + - description: Prompt ID + in: path + name: id + required: true + type: string + - description: Version number + in: path + name: version + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "409": + description: Conflict + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - ApiKeyAuth: [] + - BearerAuth: [] + summary: Reject prompt version + tags: + - Prompts + /admin/prompts/usage: + get: + description: Returns run count and last-run timestamp per prompt id, aggregated + from prompt_serve audit events within the audit retention window. Prompts + never served are absent from the map. + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + $ref: '#/definitions/prompt.Usage' + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - ApiKeyAuth: [] + - BearerAuth: [] + summary: Prompt usage stats + tags: + - Prompts /admin/settings/smtp: get: description: Returns the stored SMTP configuration. The password is never returned; @@ -10231,7 +10525,8 @@ paths: consumes: - application/json description: Updates a personal prompt owned by the current user. Admins can - update any prompt. + update any prompt; an admin's content edit to an approved shared prompt is + deferred as a pending draft version (202) until approved. parameters: - description: Prompt ID in: path @@ -10251,6 +10546,10 @@ paths: description: OK schema: $ref: '#/definitions/prompt.Prompt' + "202": + description: Accepted + schema: + $ref: '#/definitions/prompt.EditOutcome' "400": description: Bad Request schema: @@ -10363,6 +10662,53 @@ paths: summary: Share a prompt tags: - Prompts + /portal/prompts/{id}/versions: + get: + description: Returns the version history of a prompt the caller owns; admins + may read any prompt's history. + parameters: + - description: Prompt ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/versionhttp.versionListResponse' + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "403": + description: Forbidden + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - ApiKeyAuth: [] + - BearerAuth: [] + summary: List prompt versions (portal) + tags: + - Prompts /portal/prompts/search: get: description: Ranks approved prompts visible to the caller by relevance to q. @@ -10409,6 +10755,37 @@ paths: summary: Search my prompts tags: - Prompts + /portal/prompts/usage: + get: + description: Returns run count and last-run timestamp per prompt id for the + prompts visible to the caller. + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + $ref: '#/definitions/prompt.Usage' + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - ApiKeyAuth: [] + - BearerAuth: [] + summary: Prompt usage stats (portal) + tags: + - Prompts /portal/search: get: description: One query fans across every source the caller can access (catalog, diff --git a/internal/httpserver/dbmounts.go b/internal/httpserver/dbmounts.go index 04bc6387..2ef3e396 100644 --- a/internal/httpserver/dbmounts.go +++ b/internal/httpserver/dbmounts.go @@ -1,9 +1,9 @@ package httpserver -// This file isolates the two composition-root mount functions whose bodies can +// This file isolates the composition-root mount functions whose bodies can // only run against a live Postgres: they early-return unless the platform has -// constructed its portal/resource stores, which requires a real database -// connection (ping-gated in platform.New). The project confines all real-DB +// constructed its portal/resource stores or a versioning-capable prompt store, +// which requires a real database connection (ping-gated in platform.New). The project confines all real-DB // tests to the //go:build integration suite (run via `make test-realdb`), and // that suite produces no coverage profile, so these lines can never appear // covered in the unit-test coverage.out the patch-coverage gate reads. They are @@ -23,6 +23,8 @@ import ( "github.com/txn2/mcp-data-platform/pkg/browsersession" "github.com/txn2/mcp-data-platform/pkg/platform" "github.com/txn2/mcp-data-platform/pkg/portal" + "github.com/txn2/mcp-data-platform/pkg/prompt" + "github.com/txn2/mcp-data-platform/pkg/prompt/versionhttp" "github.com/txn2/mcp-data-platform/pkg/resource" ) @@ -100,10 +102,62 @@ func mountPortalAPI(mux *http.ServeMux, p *platform.Platform, notify *notifydeli handler := portal.NewHandler(deps, portal.RequirePortalAuth(portalAuth)) mux.Handle("/api/v1/portal/", handler) mux.Handle("/portal/view/", handler) + mountPromptVersionPortalAPI(mux, p, portal.RequirePortalAuth(portalAuth), adminRoles) log.Println("Portal API enabled on /api/v1/portal/") return nil } +// mountPromptVersionAdminAPI registers the admin prompt-version routes when +// the platform has a versioning store (database deployments). Called from +// mountAdminAPI; a no-DB platform early-returns. +func mountPromptVersionAdminAPI(mux *http.ServeMux, p *platform.Platform, prefix string) { + deps, ok := promptVersionDeps(p) + if !ok { + return + } + deps.AdminEmail = adminEmail + versionhttp.New(deps).RegisterAdmin(mux, prefix, buildAdminAuth(p)) +} + +// mountPromptVersionPortalAPI registers the portal prompt-version routes. +// Called from mountPortalAPI with the portal's assembled auth middleware and +// admin roles. +func mountPromptVersionPortalAPI(mux *http.ServeMux, p *platform.Platform, wrap func(http.Handler) http.Handler, adminRoles []string) { + deps, ok := promptVersionDeps(p) + if !ok { + return + } + // Mirror the sibling persona wiring's nil guard (a nil registry would + // otherwise panic per-request inside the resolver closure). + var resolver portal.PersonaResolver + if pr := p.PersonaRegistry(); pr != nil { + resolver = buildPersonaResolver(pr, p.ToolkitRegistry()) + } + deps.PortalUser = portalIdentityResolver(adminRoles, resolver) + versionhttp.New(deps).RegisterPortal(mux, wrap) +} + +// promptVersionDeps assembles the surface-independent handler dependencies, +// reporting ok=false when the platform has no prompt versioning (no database). +// The versioning capability is asserted from the prompt store, which the +// prompt layer's notifying wrapper preserves. +func promptVersionDeps(p *platform.Platform) (versionhttp.Deps, bool) { + store := p.PromptStore() + versions, _ := store.(prompt.VersionStore) + if versions == nil { + return versionhttp.Deps{}, false + } + deps := versionhttp.Deps{ + Store: store, + Versions: versions, + Registrar: p, + } + if s := p.AuditStore(); s != nil { + deps.Usage = s + } + return deps, true +} + // mountResourcesAPI registers the managed resources REST API on the mux if enabled. func mountResourcesAPI(mux *http.ServeMux, p *platform.Platform) { if p == nil || p.ResourceStore() == nil { diff --git a/internal/httpserver/mounts.go b/internal/httpserver/mounts.go index d7ef06b4..821af247 100644 --- a/internal/httpserver/mounts.go +++ b/internal/httpserver/mounts.go @@ -52,6 +52,7 @@ func mountAdminAPI(mux *http.ServeMux, p *platform.Platform, notify *notifydeliv prefix = defaultAdminPathPrefix } mux.Handle(prefix+"/", adminHandler) + mountPromptVersionAdminAPI(mux, p, prefix) log.Println("Admin API enabled on", prefix) } @@ -446,8 +447,10 @@ func mountPortalUI(mux *http.ServeMux, p *platform.Platform, assetsAvailable boo log.Println("Portal UI enabled on /portal/") } -// buildAdminHandler constructs the admin REST API handler from the platform. -func buildAdminHandler(p *platform.Platform, notify *notifydelivery.Handle) http.Handler { +// buildAdminAuth constructs the admin persona-gate middleware. Shared by the +// admin API handler and the separately mounted prompt-version routes so both +// enforce identical authentication, persona, and CSRF rules. +func buildAdminAuth(p *platform.Platform) func(http.Handler) http.Handler { var authOpts []admin.PlatformAuthOption if p.BrowserSessionAuth() != nil { authOpts = append(authOpts, admin.WithBrowserSessionAuth(p.BrowserSessionAuth())) @@ -456,13 +459,16 @@ func buildAdminHandler(p *platform.Platform, notify *notifydelivery.Handle) http if adminPersona == "" { adminPersona = defaultAdminPersona } - platAuth := admin.NewPlatformAuthenticator( + return admin.RequirePersona(admin.NewPlatformAuthenticator( p.Authenticator(), adminPersona, p.PersonaRegistry(), authOpts..., - ) + )) +} +// buildAdminHandler constructs the admin REST API handler from the platform. +func buildAdminHandler(p *platform.Platform, notify *notifydelivery.Handle) http.Handler { deps := admin.Deps{ Config: p.Config(), ConfigStore: p.ConfigStore(), @@ -541,7 +547,7 @@ func buildAdminHandler(p *platform.Platform, notify *notifydelivery.Handle) http deps.NotificationSettings = notify.Settings() deps.SendTestEmail = notify.SendTest - return admin.NewHandler(deps, admin.RequirePersona(platAuth)) + return admin.NewHandler(deps, buildAdminAuth(p)) } // wireAdminIndexDeps attaches the api-gateway catalog store, embed-job queue, diff --git a/internal/httpserver/promptversions.go b/internal/httpserver/promptversions.go new file mode 100644 index 00000000..5f9b845c --- /dev/null +++ b/internal/httpserver/promptversions.go @@ -0,0 +1,59 @@ +package httpserver + +import ( + "net/http" + "slices" + + "github.com/txn2/mcp-data-platform/pkg/admin" + "github.com/txn2/mcp-data-platform/pkg/portal" + "github.com/txn2/mcp-data-platform/pkg/prompt/versionhttp" +) + +// This file holds the unit-testable identity adapters for the prompt +// version-history / review / usage REST surface (#1009). The handlers live in +// pkg/prompt/versionhttp (outside pkg/admin and pkg/portal, which sit at the +// package-size budget); the mount functions live in dbmounts.go because their +// bodies only run against a live Postgres (the versioning capability exists +// only on the database-backed prompt store). + +// adminEmail resolves the authenticated admin's identity for approval stamps, +// falling back to the user ID (the API key name) for API-key admins without +// an email, so an approval is always attributable to some actor. +func adminEmail(r *http.Request) string { + u := admin.GetUser(r.Context()) + if u == nil { + return "" + } + if u.Email != "" { + return u.Email + } + return u.UserID +} + +// portalIdentityResolver adapts the portal auth context to the versionhttp +// identity shape: caller email, admin membership, and resolved persona. +func portalIdentityResolver(adminRoles []string, resolver portal.PersonaResolver) func(r *http.Request) *versionhttp.PortalIdentity { + return func(r *http.Request) *versionhttp.PortalIdentity { + user := portal.GetUser(r.Context()) + if user == nil { + return nil + } + id := &versionhttp.PortalIdentity{Email: user.Email, IsAdmin: rolesIntersect(user.Roles, adminRoles)} + if resolver != nil { + if pi := resolver(user.Roles); pi != nil { + id.Persona = pi.Name + } + } + return id + } +} + +// rolesIntersect reports whether any user role appears in the target role set. +func rolesIntersect(userRoles, targetRoles []string) bool { + for _, ur := range userRoles { + if slices.Contains(targetRoles, ur) { + return true + } + } + return false +} diff --git a/internal/httpserver/promptversions_test.go b/internal/httpserver/promptversions_test.go new file mode 100644 index 00000000..2d90a674 --- /dev/null +++ b/internal/httpserver/promptversions_test.go @@ -0,0 +1,75 @@ +package httpserver + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/txn2/mcp-data-platform/pkg/admin" + "github.com/txn2/mcp-data-platform/pkg/portal" +) + +func TestRolesIntersect(t *testing.T) { + assert.True(t, rolesIntersect([]string{"analyst", "admin"}, []string{"admin"})) + assert.False(t, rolesIntersect([]string{"analyst"}, []string{"admin"})) + assert.False(t, rolesIntersect(nil, []string{"admin"})) + assert.False(t, rolesIntersect([]string{"analyst"}, nil)) +} + +// stubAdminAuth authenticates every request as one fixed admin user, driving +// the real admin middleware so adminEmail reads the real context key. +type stubAdminAuth struct{ user *admin.User } + +func (a *stubAdminAuth) Authenticate(*http.Request) (*admin.User, error) { + return a.user, nil +} + +func TestAdminEmail(t *testing.T) { + // Without an authenticated admin in context, the email is empty. + assert.Empty(t, adminEmail(httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/x", http.NoBody))) + + // Through the real RequirePersona middleware the email resolves. + var got string + wrap := admin.RequirePersona(&stubAdminAuth{user: &admin.User{Email: "root@example.com"}}) + h := wrap(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + got = adminEmail(r) + })) + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/x", http.NoBody)) + assert.Equal(t, "root@example.com", got) +} + +func TestPortalIdentityResolver(t *testing.T) { + resolver := portalIdentityResolver([]string{"admin"}, func(roles []string) *portal.PersonaInfo { + if rolesIntersect(roles, []string{"analyst_role"}) { + return &portal.PersonaInfo{Name: "analyst"} + } + return nil + }) + + // No user in context: nil identity. + assert.Nil(t, resolver(httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/x", http.NoBody))) + + // A portal user resolves email, persona, and admin membership. + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/x", http.NoBody) + req = req.WithContext(portal.ContextWithUser(req.Context(), &portal.User{ + Email: "sarah@example.com", Roles: []string{"analyst_role"}, + })) + id := resolver(req) + require.NotNil(t, id) + assert.Equal(t, "sarah@example.com", id.Email) + assert.Equal(t, "analyst", id.Persona) + assert.False(t, id.IsAdmin) + + req = httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/x", http.NoBody) + req = req.WithContext(portal.ContextWithUser(req.Context(), &portal.User{ + Email: "root@example.com", Roles: []string{"admin"}, + })) + id = resolver(req) + require.NotNil(t, id) + assert.True(t, id.IsAdmin) + assert.Empty(t, id.Persona) +} diff --git a/internal/platform/promptlayer/listchanged.go b/internal/platform/promptlayer/listchanged.go index 04f1729a..4c4979f0 100644 --- a/internal/platform/promptlayer/listchanged.go +++ b/internal/platform/promptlayer/listchanged.go @@ -47,15 +47,29 @@ func (h *Handle) notifyListChanged() { // (knowledge.PromptSearcher is Search + GetByID; GetByID comes from the embedded // prompt.Store, so implementing Search is sufficient for both.) // -// Invariant: the ONLY extension method any caller up-casts the prompt store to -// beyond prompt.Store is Search. If a future extension interface is introduced, -// it must be forwarded here too, or the wrapper will silently drop it. +// Invariant: the ONLY extension methods any caller up-casts the prompt store to +// beyond prompt.Store are Search (prompt.Searcher) and the versioning methods +// (prompt.VersionStore, asserted by prompt.ApplyEdit and the composition +// root). If a future extension interface is introduced, it must be forwarded +// here too, or the wrapper will silently drop it. func wrapStore(base prompt.Store, notify func()) prompt.Store { ns := ¬ifyingStore{Store: base, notify: notify} - if searcher, ok := base.(prompt.Searcher); ok { + searcher, hasSearch := base.(prompt.Searcher) + versions, hasVersions := base.(prompt.VersionStore) + nvs := notifyingVersionStore{VersionStore: versions, notify: notify} + switch { + case hasSearch && hasVersions: + return ¬ifyingSearchVersionStore{ + notifyingSearchStore: notifyingSearchStore{notifyingStore: ns, searcher: searcher}, + notifyingVersionStore: nvs, + } + case hasSearch: return ¬ifyingSearchStore{notifyingStore: ns, searcher: searcher} + case hasVersions: + return ¬ifyingVersionOnlyStore{notifyingStore: ns, notifyingVersionStore: nvs} + default: + return ns } - return ns } // notifyingStore decorates a prompt.Store so every successful create, update, or @@ -126,6 +140,51 @@ func (s *notifyingStore) DeleteByID(ctx context.Context, id string) error { return nil } +// notifyingVersionStore decorates the store's versioning capability so the +// version writes that change what is served or listed (an applied update, an +// approved draft) fire the same prompts/list_changed notifier as the plain +// store writes. CreateDraftVersion and RejectVersion never change the served +// set, so they pass through without notifying. Embedded by the capability +// combination wrappers built in wrapStore. +type notifyingVersionStore struct { + prompt.VersionStore + notify func() +} + +// notifyingVersionOnlyStore combines the write hooks with the versioning +// capability for a base store without search. +type notifyingVersionOnlyStore struct { + *notifyingStore + notifyingVersionStore +} + +// notifyingSearchVersionStore combines the write hooks with both capability +// extensions — the production postgres store's shape. +type notifyingSearchVersionStore struct { + notifyingSearchStore + notifyingVersionStore +} + +// UpdateWithVersion applies a versioned update and notifies on success. +func (s *notifyingVersionStore) UpdateWithVersion(ctx context.Context, p *prompt.Prompt, author string) error { + if err := s.VersionStore.UpdateWithVersion(ctx, p, author); err != nil { + return err //nolint:wrapcheck // transparent decorator: pass the store's error through unchanged + } + s.notify() + return nil +} + +// ApproveVersion applies a draft snapshot to the live prompt and notifies on +// success (the served content changed). +func (s *notifyingVersionStore) ApproveVersion(ctx context.Context, promptID string, version int, approver string) (*prompt.Prompt, error) { + p, err := s.VersionStore.ApproveVersion(ctx, promptID, version, approver) + if err != nil { + return nil, err //nolint:wrapcheck // transparent decorator: pass the store's error through unchanged + } + s.notify() + return p, nil +} + // atomicNotifier is the atomic slot type holding the bound notifier. Declared // as a field type alias so the Handle can zero-initialize it. type atomicNotifier = atomic.Pointer[ListChangedNotifier] @@ -134,7 +193,12 @@ type atomicNotifier = atomic.Pointer[ListChangedNotifier] // extension (and thus knowledge.PromptSearcher, which is Search + the embedded // GetByID) so the up-casts across the codebase continue to succeed. var ( - _ prompt.Store = (*notifyingStore)(nil) - _ prompt.Store = (*notifyingSearchStore)(nil) - _ prompt.Searcher = (*notifyingSearchStore)(nil) + _ prompt.Store = (*notifyingStore)(nil) + _ prompt.Store = (*notifyingSearchStore)(nil) + _ prompt.Searcher = (*notifyingSearchStore)(nil) + _ prompt.Store = (*notifyingVersionOnlyStore)(nil) + _ prompt.VersionStore = (*notifyingVersionOnlyStore)(nil) + _ prompt.Store = (*notifyingSearchVersionStore)(nil) + _ prompt.Searcher = (*notifyingSearchVersionStore)(nil) + _ prompt.VersionStore = (*notifyingSearchVersionStore)(nil) ) diff --git a/internal/platform/promptlayer/promptlayer.go b/internal/platform/promptlayer/promptlayer.go index c09a44db..475331ad 100644 --- a/internal/platform/promptlayer/promptlayer.go +++ b/internal/platform/promptlayer/promptlayer.go @@ -33,6 +33,7 @@ import ( "sync" "github.com/txn2/mcp-data-platform/pkg/embedding" + "github.com/txn2/mcp-data-platform/pkg/middleware" "github.com/txn2/mcp-data-platform/pkg/portal" "github.com/txn2/mcp-data-platform/pkg/prompt" promptpostgres "github.com/txn2/mcp-data-platform/pkg/prompt/postgres" @@ -114,6 +115,13 @@ type Handle struct { embedder embedding.Provider shareStore ShareLister + // auditLogger receives prompt_serve audit events on every successful + // database-prompt serve (prompts/get, manage_prompt use); usage reads the + // aggregation back for manage_prompt get. Both bound after construction + // (the audit layer is assembled later); nil disables the respective path. + auditLogger middleware.AuditLogger + usage prompt.UsageReader + // listChanged holds the prompts/list_changed notifier, bound after // construction once the session broadcaster exists. Read atomically per // write by the notifying store wrapper; nil until SetListChangedNotifier. @@ -153,11 +161,28 @@ func New(cfg Config) *Handle { // through the one shared instance — no per-handler emission to keep in // sync. wrapStore preserves the store's search capability; the notifier // is bound later via SetListChangedNotifier. + // wrapStore preserves the store's capability extensions (search, + // versioning), so version writes that change what is served fire + // list_changed like every other write. h.store = wrapStore(base, h.notifyListChanged) } return h } +// SetAuditLogger binds the audit logger that receives prompt_serve events. +// Called once the audit layer is assembled; nil (or never calling this) +// disables serve-event emission, and with it usage stats. +func (h *Handle) SetAuditLogger(l middleware.AuditLogger) { + h.auditLogger = l +} + +// SetUsageReader binds the audit-backed usage aggregation surfaced on +// manage_prompt get. Called once the audit store is assembled; nil (or never +// calling this) leaves usage fields unpopulated. +func (h *Handle) SetUsageReader(u prompt.UsageReader) { + h.usage = u +} + // SetEmbedder binds the embedding provider that powers manage_prompt semantic // ranking. Called once the provider is assembled; a nil provider (or never // calling this) leaves ranking on the lexical fallback. diff --git a/internal/platform/promptlayer/register.go b/internal/platform/promptlayer/register.go index a44f6f72..a57c79eb 100644 --- a/internal/platform/promptlayer/register.go +++ b/internal/platform/promptlayer/register.go @@ -623,33 +623,38 @@ func (h *Handle) GetByName(ctx context.Context, email string, personas []string, if h == nil || h.store == nil { return nil, false } + pr := h.resolveByName(ctx, email, personas, name) + if pr == nil { + return nil, false + } + h.auditPromptServe(ctx, pr, serveSurfacePromptsGet, email) + res := renderPrompt(pr, args) + attachProvenanceMeta(res, pr) + return res, true +} + +// resolveByName resolves a prefixed prompt name to the caller's visible +// database prompt, or nil. See GetByName for the prefix grammar and the +// reserved-prefix fall-through rationale. +func (h *Handle) resolveByName(ctx context.Context, email string, personas []string, name string) *prompt.Prompt { if bare, ok := strings.CutPrefix(name, promptPrefixPersonal); ok { - if res, found := h.getOwnedPersonalPrompt(ctx, email, bare, args); found { - return res, true + if pr := h.ownedPersonalPrompt(ctx, email, bare); pr != nil { + return pr } } if bare, ok := strings.CutPrefix(name, promptPrefixGlobal); ok { - if res, found := h.getGlobalPrompt(ctx, bare, args); found { - return res, true + if pr := h.globalPrompt(ctx, bare); pr != nil { + return pr } } if bare, ok := strings.CutPrefix(name, promptPrefixShared); ok { - if res, found := h.getSharedPrompt(ctx, email, bare, args); found { - return res, true + // Shared directly with the caller, matched by bare name; the first + // matching active share wins (consistent with listSharedDescriptors). + if pr := h.sharedPromptByName(ctx, email, bare); pr != nil { + return pr } } - return h.getPersonaPrompt(ctx, personas, name, args) -} - -// getSharedPrompt renders a prompt shared directly with the caller, matched by -// bare name. The first matching active share wins (consistent with the dedup in -// listSharedDescriptors). -func (h *Handle) getSharedPrompt(ctx context.Context, email, bare string, args map[string]string) (*mcp.GetPromptResult, bool) { - pr := h.sharedPromptByName(ctx, email, bare) - if pr == nil { - return nil, false - } - return renderPrompt(pr, args) + return h.personaPrompt(ctx, personas, name) } // sharedPromptByName finds the prompt shared directly with the caller matching @@ -675,36 +680,36 @@ func (h *Handle) sharedPromptByName(ctx context.Context, email, bare string) *pr return nil } -// getOwnedPersonalPrompt renders the caller's own personal prompt of the bare name. -func (h *Handle) getOwnedPersonalPrompt(ctx context.Context, email, bare string, args map[string]string) (*mcp.GetPromptResult, bool) { +// ownedPersonalPrompt resolves the caller's own personal prompt of the bare name. +func (h *Handle) ownedPersonalPrompt(ctx context.Context, email, bare string) *prompt.Prompt { if email == "" { - return nil, false + return nil } pr, err := h.store.GetPersonal(ctx, email, bare) if err != nil || pr == nil || !pr.Enabled { - return nil, false + return nil } - return renderPrompt(pr, args) + return pr } -// getGlobalPrompt renders the global prompt of the bare name. -func (h *Handle) getGlobalPrompt(ctx context.Context, bare string, args map[string]string) (*mcp.GetPromptResult, bool) { +// globalPrompt resolves the global prompt of the bare name. +func (h *Handle) globalPrompt(ctx context.Context, bare string) *prompt.Prompt { pr, err := h.store.Get(ctx, bare) // System rows are ingested static prompts already served under their bare // name via AddPrompt; do not also serve them under the global- prefix. if err != nil || pr == nil || !pr.Enabled || pr.Scope != prompt.ScopeGlobal || pr.Source == prompt.SourceSystem { - return nil, false + return nil } - return renderPrompt(pr, args) + return pr } -// getPersonaPrompt resolves a - prompt for a caller who belongs +// personaPrompt resolves a - prompt for a caller who belongs // to that persona and is shared the target prompt. Because both persona names // and prompt names may contain hyphens, a presented name can split more than one // way (persona "data" + "engineer-x" vs persona "data-engineer" + "x"); personas // are tried longest-name-first so the most specific persona prefix wins // deterministically. -func (h *Handle) getPersonaPrompt(ctx context.Context, personas []string, name string, args map[string]string) (*mcp.GetPromptResult, bool) { +func (h *Handle) personaPrompt(ctx context.Context, personas []string, name string) *prompt.Prompt { ordered := append([]string(nil), personas...) slices.SortStableFunc(ordered, func(a, b string) int { return len(b) - len(a) }) for _, persona := range ordered { @@ -714,17 +719,17 @@ func (h *Handle) getPersonaPrompt(ctx context.Context, personas []string, name s } if pr, err := h.store.Get(ctx, bare); err == nil && pr != nil && pr.Enabled && pr.Scope == prompt.ScopePersona && slices.Contains(pr.Personas, persona) { - return renderPrompt(pr, args) + return pr } } - return nil, false + return nil } // renderPrompt substitutes the request arguments into a prompt's content. // Shared by every serving path so personal and global/persona prompts render // identically. -func renderPrompt(pr *prompt.Prompt, args map[string]string) (*mcp.GetPromptResult, bool) { - return buildPromptResult(substituteArgs(pr.Content, args)), true +func renderPrompt(pr *prompt.Prompt, args map[string]string) *mcp.GetPromptResult { + return buildPromptResult(substituteArgs(pr.Content, args)) } // RegisterRuntimePrompt records a prompt's metadata at runtime. Called after diff --git a/internal/platform/promptlayer/serve.go b/internal/platform/promptlayer/serve.go new file mode 100644 index 00000000..0e52111f --- /dev/null +++ b/internal/platform/promptlayer/serve.go @@ -0,0 +1,109 @@ +package promptlayer + +import ( + "context" + "log/slog" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/txn2/mcp-data-platform/pkg/audit" + "github.com/txn2/mcp-data-platform/pkg/middleware" + "github.com/txn2/mcp-data-platform/pkg/prompt" +) + +// Serve surfaces recorded as the ToolName of a prompt_serve audit event. +const ( + serveSurfacePromptsGet = "prompts/get" + serveSurfaceUse = "manage_prompt" +) + +// _meta keys carrying prompt provenance on prompts/get results, so an agent +// can state what it is about to run ("Daily Sales Report v4, approved by +// jane@ on 2026-06-12") before executing (#1009). +const ( + metaPromptReference = "prompt_reference" + metaPromptVersion = "prompt_version" + metaPromptApprovedBy = "prompt_approved_by" + metaPromptApprovedAt = "prompt_approved_at" +) + +// attachProvenanceMeta stamps the served prompt's identity, version, and +// approval provenance into the result's _meta. +func attachProvenanceMeta(res *mcp.GetPromptResult, pr *prompt.Prompt) { + if res.Meta == nil { + res.Meta = mcp.Meta{} + } + res.Meta[metaPromptVersion] = pr.Version + if pr.ID != "" { + res.Meta[metaPromptReference] = promptRefPrefix + pr.ID + } + if pr.ApprovedBy != "" { + res.Meta[metaPromptApprovedBy] = pr.ApprovedBy + } + if pr.ApprovedAt != nil { + res.Meta[metaPromptApprovedAt] = pr.ApprovedAt.UTC().Format(time.RFC3339) + } +} + +// auditPromptServe records a prompt_serve audit event for a successfully +// served database prompt. These events are the source of the per-prompt run +// count and last-run rollup (prompt.UsageReader). email is the caller identity +// resolved by the serving path; it backstops the prompts/get path, where no +// PlatformContext exists (the tool-call middleware only handles tools/call). +// No-op without a bound audit logger. +func (h *Handle) auditPromptServe(ctx context.Context, pr *prompt.Prompt, surface, email string) { + if h.auditLogger == nil || pr.ID == "" { + return + } + ev := middleware.AuditEvent{ + Timestamp: time.Now().UTC(), + ToolName: surface, + UserEmail: email, + Success: true, + // The serve already passed the visibility checks; without this the + // row would persist authorized=false and read as a denial. + Authorized: true, + Source: "mcp", + EventKind: string(audit.EventTypePromptServe), + Parameters: map[string]any{ + "prompt_id": pr.ID, + "prompt_name": pr.Name, + "version": pr.Version, + }, + } + if pc := middleware.GetPlatformContext(ctx); pc != nil { + ev.RequestID = pc.RequestID + ev.SessionID = pc.SessionID + ev.UserID = pc.UserID + ev.Persona = pc.PersonaName + ev.Transport = pc.Transport + if pc.UserEmail != "" { + ev.UserEmail = pc.UserEmail + } + } + // context.Background is intentional, matching the MCP audit middleware: + // the async writer ignores the context and a sync write must not be + // canceled when the serving request ends. + if err := h.auditLogger.Log(context.Background(), ev); err != nil { + slog.Warn("failed to log prompt serve event", logKeyError, err, promptLogKey, pr.Name) + } +} + +// applyUsage fills the prompt's audit-derived usage fields from the bound +// usage reader. No-op without a reader; a read failure logs and leaves the +// fields empty rather than failing the caller's read. +func (h *Handle) applyUsage(ctx context.Context, pr *prompt.Prompt) { + if h.usage == nil || pr.ID == "" { + return + } + usage, err := h.usage.PromptUsage(ctx, []string{pr.ID}) + if err != nil { + slog.Warn("failed to read prompt usage", logKeyError, err, promptLogKey, pr.Name) + return + } + if u, ok := usage[pr.ID]; ok { + pr.RunCount = u.RunCount + pr.LastRunAt = u.LastRunAt + } +} diff --git a/internal/platform/promptlayer/shared_serving_test.go b/internal/platform/promptlayer/shared_serving_test.go index 96d14684..c7d337c5 100644 --- a/internal/platform/promptlayer/shared_serving_test.go +++ b/internal/platform/promptlayer/shared_serving_test.go @@ -61,6 +61,6 @@ func TestSharedPrompt_PromotedNotDoubleServed(t *testing.T) { for _, pr := range out { assert.NotEqual(t, "shared-report", pr.Name, "promoted prompt must not be served via shared- alias") } - _, ok := h.getSharedPrompt(ctx, "bob@example.com", "report", nil) + _, ok := h.GetByName(ctx, "bob@example.com", nil, "shared-report", nil) assert.False(t, ok, "promoted prompt not resolvable via shared- prefix") } diff --git a/internal/platform/promptlayer/tool.go b/internal/platform/promptlayer/tool.go index 1400004c..865added 100644 --- a/internal/platform/promptlayer/tool.go +++ b/internal/platform/promptlayer/tool.go @@ -3,6 +3,7 @@ package promptlayer import ( "context" "encoding/json" + "errors" "fmt" "log/slog" "strings" @@ -84,6 +85,9 @@ func (h *Handle) RegisterTool(server *mcp.Server) { "when the handle is ambiguous. " + "Non-admin users can manage their own personal prompts. " + "Admins can manage prompts at all scope levels (global, persona, personal). " + + "Editing the content or arguments of an approved global or persona prompt saves the change as a " + + "pending draft version (update returns status 'pending_approval'); the approved version keeps " + + "being served until an admin approves the draft. " + "Management commands cover database-stored prompts only; static prompts from server " + "configuration are not editable here, though 'use' resolves operator, workflow, and " + "toolkit prompts too.", @@ -195,6 +199,10 @@ func (h *Handle) handlePromptUpdate(ctx context.Context, input managePromptInput return promptErrorResult(msg), nil, nil } + // Snapshot the persisted state before mutation: ApplyEdit compares it + // against the edited copy to decide whether the edit needs admin review. + before := *existing + oldScope := existing.Scope if errMsg := applyPromptUpdates(existing, input, h.isAdminPersona(ctx)); errMsg != "" { return promptErrorResult(errMsg), nil, nil @@ -206,16 +214,39 @@ func (h *Handle) handlePromptUpdate(ctx context.Context, input managePromptInput return promptErrorResult(errMsg), nil, nil } - if err := h.store.Update(ctx, existing); err != nil { - slog.Error("failed to update prompt", promptLogKey, input.Name, promptLogKeyErr, err) + return h.persistPromptUpdate(ctx, &before, existing, oldScope, email) +} + +// persistPromptUpdate lands an edited prompt through the shared review gate. +// A review-gated content edit becomes a pending draft version and the served +// prompt (and its runtime metadata) stays on the approved snapshot; every +// other edit applies and re-registers. +func (h *Handle) persistPromptUpdate(ctx context.Context, before, existing *prompt.Prompt, oldScope, email string) (*mcp.CallToolResult, any, error) { + outcome, err := prompt.ApplyEdit(ctx, h.store, before, existing, email) + if errors.Is(err, prompt.ErrReviewRequiredMixedEdit) { + return promptErrorResult(err.Error()), nil, nil + } + if err != nil { + slog.Error("failed to update prompt", promptLogKey, existing.Name, promptLogKeyErr, err) return h.promptErrorDetail(ctx, "failed to update prompt", err), nil, nil } + if !outcome.Applied { + return promptJSONResult(map[string]any{ + fieldStatus: "pending_approval", + fieldName: existing.Name, + "pending_version": outcome.PendingVersion, + "message": fmt.Sprintf("this prompt is approved and shared, so the content change was saved as "+ + "draft version %d; the approved version continues to be served until an admin approves the "+ + "draft in the admin portal or via the admin prompts API", outcome.PendingVersion), + }) + } h.reregisterAfterUpdate(existing, oldScope) return promptJSONResult(map[string]any{ fieldStatus: "updated", fieldName: existing.Name, + "version": existing.Version, }) } @@ -511,6 +542,7 @@ func (h *Handle) handlePromptGet(ctx context.Context, input managePromptInput) ( } } + h.applyUsage(ctx, pr) return promptJSONResult(pr) } diff --git a/internal/platform/promptlayer/use.go b/internal/platform/promptlayer/use.go index ca48b7be..ce5986f2 100644 --- a/internal/platform/promptlayer/use.go +++ b/internal/platform/promptlayer/use.go @@ -40,7 +40,7 @@ func (h *Handle) handlePromptUse(ctx context.Context, input managePromptInput) ( return h.useByID(ctx, id, input.Args) } if pr := h.useExactName(ctx, strings.ToLower(handle)); pr != nil { - return promptUseResult(pr, input.Args) + return h.servePromptUse(ctx, pr, input.Args) } if res, done := h.useExactDisplayName(ctx, handle, input.Args); done { return res, nil, nil @@ -58,6 +58,14 @@ func (h *Handle) useByID(ctx context.Context, id string, args map[string]string) if pr == nil || !pr.Enabled || !h.canViewPrompt(ctx, pr) { return promptErrorResult(fmt.Sprintf("prompt %q not found", promptRefPrefix+id)), nil, nil } + return h.servePromptUse(ctx, pr, args) +} + +// servePromptUse records the prompt_serve audit event for a resolved use and +// renders the resolved-prompt response. Every single-match resolution path +// funnels through here so run counts see each of them. +func (h *Handle) servePromptUse(ctx context.Context, pr *prompt.Prompt, args map[string]string) (*mcp.CallToolResult, any, error) { + h.auditPromptServe(ctx, pr, serveSurfaceUse, resolveEmail(ctx)) return promptUseResult(pr, args) } @@ -130,11 +138,11 @@ func (h *Handle) useExactDisplayName(ctx context.Context, handle string, args ma case len(matches) == 0: return nil, false case len(matches) == 1: - res, _, _ := promptUseResult(&matches[0], args) + res, _, _ := h.servePromptUse(ctx, &matches[0], args) return res, true } if winner := precedenceWinner(matches); winner != nil { - res, _, _ := promptUseResult(winner, args) + res, _, _ := h.servePromptUse(ctx, winner, args) return res, true } res, _, _ := promptCandidatesResult(unscored(matches)) @@ -195,7 +203,7 @@ func (h *Handle) useRanked(ctx context.Context, handle string, args map[string]s case len(scored) == 0: return promptErrorResult(fmt.Sprintf("no prompt matched %q; use the list command to browse", handle)), nil, nil case len(scored) == 1 || scored[0].Score-scored[1].Score >= useConfidenceMargin: - return promptUseResult(&scored[0].Prompt, args) + return h.servePromptUse(ctx, &scored[0].Prompt, args) } return promptCandidatesResult(scored) } @@ -217,7 +225,7 @@ func (h *Handle) useSubstring(ctx context.Context, handle string, args map[strin case len(matches) == 0: return promptErrorResult(fmt.Sprintf("no prompt matched %q; use the list command to browse", handle)), nil, nil case len(matches) == 1: - return promptUseResult(&matches[0], args) + return h.servePromptUse(ctx, &matches[0], args) } return promptCandidatesResult(unscored(matches)) } @@ -299,6 +307,7 @@ func promptProvenance(pr *prompt.Prompt) map[string]any { "scope": pr.Scope, "source": pr.Source, fieldStatus: pr.Status, + "version": pr.Version, } if pr.ID != "" { prov["reference"] = promptRefPrefix + pr.ID diff --git a/internal/platform/promptlayer/versioning_realdb_integration_test.go b/internal/platform/promptlayer/versioning_realdb_integration_test.go new file mode 100644 index 00000000..35d7c3a3 --- /dev/null +++ b/internal/platform/promptlayer/versioning_realdb_integration_test.go @@ -0,0 +1,90 @@ +//go:build integration + +package promptlayer + +// The assembled-system proof for the #1009 acceptance criterion: editing an +// approved global prompt's content through the real manage_prompt tool does +// NOT change what other users are served over the real prompts/get path — the +// served content is the approved snapshot, not the draft — until the draft +// version is approved, after which the new content serves with its version +// and approval provenance in _meta. The stack is real end to end: a pgvector +// Postgres store behind the notifying wrapper, an mcp.Server with the +// prompt-visibility middleware, and in-memory client sessions. + +import ( + "context" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/txn2/mcp-data-platform/internal/testdb" + "github.com/txn2/mcp-data-platform/pkg/prompt" + pgstore "github.com/txn2/mcp-data-platform/pkg/prompt/postgres" + "github.com/txn2/mcp-data-platform/pkg/registry" +) + +func TestVersioning_RealDB_DraftNotServedUntilApproved(t *testing.T) { + db := testdb.New(t) + h := New(Config{Store: pgstore.New(db), AdminPersona: "admin", Registry: registry.NewRegistry()}) + ctx := context.Background() + + // Seed an approved global prompt through the real store. + seed := &prompt.Prompt{ + Name: "daily-sales-report", DisplayName: "Daily Sales Report", + Content: "approved body", Scope: prompt.ScopeGlobal, + Source: prompt.SourceOperator, Enabled: true, OwnerEmail: "jane@example.com", + } + require.NoError(t, h.Store().Create(ctx, seed)) + loaded, err := h.Store().Get(ctx, "daily-sales-report") + require.NoError(t, err) + require.NoError(t, loaded.ApplyStatusTransition(prompt.StatusApproved, "", "jane@example.com", true, time.Now().UTC())) + require.NoError(t, h.Store().Update(ctx, loaded)) + + getServed := func() (*mcp.GetPromptResult, string) { + session, cleanup := connectServingClient(t, h, "bob@example.com") + defer cleanup() + res, err := session.GetPrompt(ctx, &mcp.GetPromptParams{Name: "global-daily-sales-report"}) + require.NoError(t, err) + tc, ok := res.Messages[0].Content.(*mcp.TextContent) + require.True(t, ok) + return res, tc.Text + } + + res, text := getServed() + assert.Equal(t, "approved body", text) + assert.EqualValues(t, 1, res.Meta["prompt_version"]) + assert.Equal(t, "jane@example.com", res.Meta["prompt_approved_by"]) + + // An admin edits the content through the real manage_prompt handler: the + // edit is deferred as a pending draft version. + toolRes, _, err := h.handleManagePrompt(adminCtx(), managePromptInput{ + Command: "update", Name: "daily-sales-report", Content: "draft body", + }) + require.NoError(t, err) + require.False(t, toolRes.IsError, resultText(toolRes)) + assert.Contains(t, resultText(toolRes), "pending_approval") + + // THE acceptance criterion: another user is still served the approved + // snapshot, not the draft. + res, text = getServed() + assert.Equal(t, "approved body", text, "the draft must not be served before approval") + assert.EqualValues(t, 1, res.Meta["prompt_version"]) + + // Approve the draft through the store's versioning capability (the same + // call the admin REST endpoint makes). + vs, ok := h.Store().(prompt.VersionStore) + require.True(t, ok, "the wrapped store preserves the versioning capability") + updated, err := vs.ApproveVersion(ctx, loaded.ID, 2, "admin@example.com") + require.NoError(t, err) + assert.Equal(t, "draft body", updated.Content) + + // The approved snapshot now serves, with its provenance updated. + res, text = getServed() + assert.Equal(t, "draft body", text, "the approved version is served after approval") + assert.EqualValues(t, 2, res.Meta["prompt_version"]) + assert.Equal(t, "admin@example.com", res.Meta["prompt_approved_by"]) + assert.NotEmpty(t, res.Meta["prompt_approved_at"]) +} diff --git a/internal/platform/promptlayer/versioning_test.go b/internal/platform/promptlayer/versioning_test.go new file mode 100644 index 00000000..ab1e0937 --- /dev/null +++ b/internal/platform/promptlayer/versioning_test.go @@ -0,0 +1,403 @@ +package promptlayer + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/txn2/mcp-data-platform/pkg/audit" + "github.com/txn2/mcp-data-platform/pkg/middleware" + "github.com/txn2/mcp-data-platform/pkg/prompt" +) + +// --- capturing audit logger --- + +type capturingAuditLogger struct { + events []middleware.AuditEvent +} + +func (l *capturingAuditLogger) Log(_ context.Context, ev middleware.AuditEvent) error { + l.events = append(l.events, ev) + return nil +} + +// --- version-capable mock store --- + +// mockVersionStore extends the in-memory prompt store with a recording +// versioning capability, mirroring the postgres store's shape. +type mockVersionStore struct { + *mockPromptStore + drafts map[string][]prompt.Version // by prompt id + nextVersion int + versionErr error +} + +func newMockVersionStore() *mockVersionStore { + return &mockVersionStore{ + mockPromptStore: newMockPromptStore(), + drafts: map[string][]prompt.Version{}, + nextVersion: 2, + } +} + +// The read methods return copies, mirroring the real store (a row scanned from +// the database is never aliased to the stored state): a handler mutating its +// loaded prompt must not change what the store serves until a write lands. +func clonePrompt(p *prompt.Prompt) *prompt.Prompt { + if p == nil { + return nil + } + c := *p + return &c +} + +func (m *mockVersionStore) Get(ctx context.Context, name string) (*prompt.Prompt, error) { + p, err := m.mockPromptStore.Get(ctx, name) + return clonePrompt(p), err +} + +func (m *mockVersionStore) GetPersonal(ctx context.Context, ownerEmail, name string) (*prompt.Prompt, error) { + p, err := m.mockPromptStore.GetPersonal(ctx, ownerEmail, name) + return clonePrompt(p), err +} + +func (m *mockVersionStore) GetByID(ctx context.Context, id string) (*prompt.Prompt, error) { + p, err := m.mockPromptStore.GetByID(ctx, id) + return clonePrompt(p), err +} + +func (m *mockVersionStore) UpdateWithVersion(ctx context.Context, p *prompt.Prompt, _ string) error { + if m.versionErr != nil { + return m.versionErr + } + p.Version = m.nextVersion + return m.Update(ctx, p) +} + +func (m *mockVersionStore) CreateDraftVersion(_ context.Context, promptID string, proposed *prompt.Prompt, author string) (int, error) { + n := m.nextVersion + m.drafts[promptID] = append(m.drafts[promptID], prompt.Version{ + PromptID: promptID, Version: n, Content: proposed.Content, + Author: author, Status: prompt.VersionStatusDraft, + }) + return n, nil +} + +func (m *mockVersionStore) ListVersions(_ context.Context, promptID string) ([]prompt.Version, error) { + return m.drafts[promptID], nil +} + +func (m *mockVersionStore) GetVersion(_ context.Context, promptID string, version int) (*prompt.Version, error) { + for _, v := range m.drafts[promptID] { + if v.Version == version { + return &v, nil + } + } + return nil, nil //nolint:nilnil // interface contract +} + +func (m *mockVersionStore) ApproveVersion(ctx context.Context, promptID string, version int, approver string) (*prompt.Prompt, error) { + if m.versionErr != nil { + return nil, m.versionErr + } + v, _ := m.GetVersion(ctx, promptID, version) + p, _ := m.GetByID(ctx, promptID) + now := time.Now().UTC() + p.Content = v.Content + p.Version = version + p.Status = prompt.StatusApproved + p.ApprovedBy = approver + p.ApprovedAt = &now + return p, nil +} + +func (*mockVersionStore) RejectVersion(context.Context, string, int) error { return nil } + +var _ prompt.VersionStore = (*mockVersionStore)(nil) + +// newVersionedTestHandle builds a Handle over a version-capable store. +func newVersionedTestHandle() (*Handle, *mockVersionStore) { + h, _ := newTestHandle() + store := newMockVersionStore() + h.store = store + return h, store +} + +// A content edit to an approved global prompt through manage_prompt does not +// touch the live row: it returns pending_approval with the draft version, and +// the store still serves the approved content. +func TestManagePrompt_Update_ApprovedSharedContentEditPends(t *testing.T) { + h, store := newVersionedTestHandle() + store.prompts["report"] = &prompt.Prompt{ + ID: "p1", Name: "report", Scope: prompt.ScopeGlobal, Content: "approved body", + Status: prompt.StatusApproved, Enabled: true, Version: 1, + } + + res, _, err := h.handleManagePrompt(adminCtx(), managePromptInput{ + Command: "update", Name: "report", Content: "proposed body", + }) + require.NoError(t, err) + require.False(t, res.IsError, resultText(res)) + + var out map[string]any + require.NoError(t, json.Unmarshal([]byte(resultText(res)), &out)) + assert.Equal(t, "pending_approval", out["status"]) + assert.Equal(t, float64(2), out["pending_version"]) + assert.Contains(t, out["message"], "draft version 2") + + assert.Equal(t, "approved body", store.prompts["report"].Content, + "the live row keeps serving the approved snapshot") + require.Len(t, store.drafts["p1"], 1) + assert.Equal(t, "proposed body", store.drafts["p1"][0].Content) + assert.Equal(t, "admin@example.com", store.drafts["p1"][0].Author) +} + +// A gated content edit combined with a non-versioned change is rejected whole. +func TestManagePrompt_Update_MixedGatedEditRejected(t *testing.T) { + h, store := newVersionedTestHandle() + store.prompts["report"] = &prompt.Prompt{ + ID: "p1", Name: "report", Scope: prompt.ScopeGlobal, Content: "approved body", + Status: prompt.StatusApproved, Enabled: true, Version: 1, + } + + res, _, err := h.handleManagePrompt(adminCtx(), managePromptInput{ + Command: "update", Name: "report", Content: "proposed body", Status: prompt.StatusDeprecated, + }) + require.NoError(t, err) + assert.True(t, res.IsError) + assert.Contains(t, resultText(res), "separate updates") + assert.Equal(t, "approved body", store.prompts["report"].Content) + assert.Empty(t, store.drafts["p1"], "no draft is created for a rejected mixed edit") +} + +// A metadata-only edit to an approved shared prompt applies directly through +// the versioned update. +func TestManagePrompt_Update_MetadataEditAppliesDirectly(t *testing.T) { + h, store := newVersionedTestHandle() + store.prompts["report"] = &prompt.Prompt{ + ID: "p1", Name: "report", Scope: prompt.ScopeGlobal, Content: "approved body", + Description: "old", Status: prompt.StatusApproved, Enabled: true, Version: 1, + } + + res, _, err := h.handleManagePrompt(adminCtx(), managePromptInput{ + Command: "update", Name: "report", Description: "new description", + }) + require.NoError(t, err) + require.False(t, res.IsError, resultText(res)) + + var out map[string]any + require.NoError(t, json.Unmarshal([]byte(resultText(res)), &out)) + assert.Equal(t, "updated", out["status"]) + assert.Equal(t, float64(2), out["version"], "the applied edit advances the version") + assert.Equal(t, "new description", store.prompts["report"].Description) +} + +// A personal prompt's content edit applies silently (no review), still +// through the versioned update. +func TestManagePrompt_Update_PersonalContentEditAppliesSilently(t *testing.T) { + h, store := newVersionedTestHandle() + store.prompts["mine"] = &prompt.Prompt{ + ID: "p2", Name: "mine", Scope: prompt.ScopePersonal, OwnerEmail: "user@example.com", + Content: "v1 body", Status: prompt.StatusApproved, Enabled: true, Version: 1, + } + + res, _, err := h.handleManagePrompt(userCtx("user@example.com", "analyst"), managePromptInput{ + Command: "update", Name: "mine", Content: "v2 body", + }) + require.NoError(t, err) + require.False(t, res.IsError, resultText(res)) + assert.Equal(t, "v2 body", store.prompts["mine"].Content) + assert.Empty(t, store.drafts["p2"]) +} + +// prompts/get on a database prompt records a prompt_serve audit event carrying +// the prompt id and version, and stamps provenance into the result _meta. +func TestGetByName_EmitsServeAuditAndProvenanceMeta(t *testing.T) { + h, store := newTestHandle() + logger := &capturingAuditLogger{} + h.SetAuditLogger(logger) + approvedAt := time.Date(2026, 6, 12, 10, 0, 0, 0, time.UTC) + store.prompts["report"] = &prompt.Prompt{ + ID: "p1", Name: "report", Scope: prompt.ScopeGlobal, Content: "body {x}", + Status: prompt.StatusApproved, ApprovedBy: "jane@example.com", ApprovedAt: &approvedAt, + Enabled: true, Version: 4, + } + + res, ok := h.GetByName(context.Background(), "bob@example.com", nil, "global-report", map[string]string{"x": "Y"}) + require.True(t, ok) + + assert.Equal(t, 4, res.Meta["prompt_version"]) + assert.Equal(t, "mcp:prompt:p1", res.Meta["prompt_reference"]) + assert.Equal(t, "jane@example.com", res.Meta["prompt_approved_by"]) + assert.Equal(t, "2026-06-12T10:00:00Z", res.Meta["prompt_approved_at"]) + + require.Len(t, logger.events, 1) + ev := logger.events[0] + assert.Equal(t, string(audit.EventTypePromptServe), ev.EventKind) + assert.Equal(t, serveSurfacePromptsGet, ev.ToolName) + assert.Equal(t, "bob@example.com", ev.UserEmail, "prompts/get has no PlatformContext; the resolved email backstops") + assert.Equal(t, "p1", ev.Parameters["prompt_id"]) + assert.Equal(t, 4, ev.Parameters["version"]) + assert.True(t, ev.Success) +} + +// A miss emits nothing. +func TestGetByName_MissEmitsNoServeEvent(t *testing.T) { + h, _ := newTestHandle() + logger := &capturingAuditLogger{} + h.SetAuditLogger(logger) + + _, ok := h.GetByName(context.Background(), "bob@example.com", nil, "global-missing", nil) + assert.False(t, ok) + assert.Empty(t, logger.events) +} + +// manage_prompt use records the serve event with the caller identity from the +// platform context and reports the version in the provenance block. +func TestUse_EmitsServeAuditWithVersionProvenance(t *testing.T) { + h, store := newTestHandle() + logger := &capturingAuditLogger{} + h.SetAuditLogger(logger) + store.prompts["report"] = &prompt.Prompt{ + ID: "p1", Name: "report", Scope: prompt.ScopeGlobal, Content: "body", + Status: prompt.StatusApproved, Enabled: true, Version: 3, + } + + res, _, err := h.handleManagePrompt(userCtx("bob@example.com", "analyst"), managePromptInput{ + Command: cmdUse, Name: "report", + }) + require.NoError(t, err) + require.False(t, res.IsError, resultText(res)) + + var out map[string]any + require.NoError(t, json.Unmarshal([]byte(resultText(res)), &out)) + prov, ok := out["prompt"].(map[string]any) + require.True(t, ok) + assert.Equal(t, float64(3), prov["version"]) + + require.Len(t, logger.events, 1) + ev := logger.events[0] + assert.Equal(t, string(audit.EventTypePromptServe), ev.EventKind) + assert.Equal(t, serveSurfaceUse, ev.ToolName) + assert.Equal(t, "bob@example.com", ev.UserEmail) + assert.Equal(t, "analyst", ev.Persona) + assert.Equal(t, 3, ev.Parameters["version"]) +} + +// --- usage reader --- + +type stubUsageReader struct { + usage map[string]prompt.Usage + err error +} + +func (s *stubUsageReader) PromptUsage(_ context.Context, _ []string) (map[string]prompt.Usage, error) { + return s.usage, s.err +} + +// manage_prompt get populates the audit-derived usage fields when a usage +// reader is bound, and leaves them zero when the prompt was never served. +func TestManagePrompt_Get_PopulatesUsage(t *testing.T) { + h, store := newTestHandle() + last := time.Date(2026, 7, 20, 9, 0, 0, 0, time.UTC) + h.SetUsageReader(&stubUsageReader{usage: map[string]prompt.Usage{ + "p1": {RunCount: 37, LastRunAt: &last}, + }}) + store.prompts["report"] = &prompt.Prompt{ + ID: "p1", Name: "report", Scope: prompt.ScopeGlobal, Content: "body", Enabled: true, + } + store.prompts["unused"] = &prompt.Prompt{ + ID: "p2", Name: "unused", Scope: prompt.ScopeGlobal, Content: "body", Enabled: true, + } + + res, _, err := h.handleManagePrompt(adminCtx(), managePromptInput{Command: "get", Name: "report"}) + require.NoError(t, err) + var out prompt.Prompt + require.NoError(t, json.Unmarshal([]byte(resultText(res)), &out)) + assert.Equal(t, int64(37), out.RunCount) + require.NotNil(t, out.LastRunAt) + + res, _, err = h.handleManagePrompt(adminCtx(), managePromptInput{Command: "get", Name: "unused"}) + require.NoError(t, err) + var never prompt.Prompt + require.NoError(t, json.Unmarshal([]byte(resultText(res)), &never)) + assert.Zero(t, never.RunCount, "a never-served prompt reports zero usage") + assert.Nil(t, never.LastRunAt) +} + +// --- wrapper capability forwarding --- + +// The notifying wrapper preserves the versioning capability and fires +// list_changed on the version writes that change served content, but not on +// draft creation (which changes nothing served). +func TestWrapStore_ForwardsVersionCapabilityAndNotifies(t *testing.T) { + base := newMockVersionStore() + base.prompts["report"] = &prompt.Prompt{ + ID: "p1", Name: "report", Scope: prompt.ScopeGlobal, Content: "body", + Status: prompt.StatusApproved, Enabled: true, Version: 1, + } + notified := 0 + wrapped := wrapStore(base, func() { notified++ }) + + vs, ok := wrapped.(prompt.VersionStore) + require.True(t, ok, "the wrapper preserves the versioning capability") + + _, err := vs.CreateDraftVersion(context.Background(), "p1", base.prompts["report"], "a@example.com") + require.NoError(t, err) + assert.Zero(t, notified, "a draft changes nothing served: no notification") + + require.NoError(t, vs.UpdateWithVersion(context.Background(), base.prompts["report"], "a@example.com")) + assert.Equal(t, 1, notified, "an applied versioned update notifies") + + _, err = vs.ApproveVersion(context.Background(), "p1", 2, "admin@example.com") + require.NoError(t, err) + assert.Equal(t, 2, notified, "an approved draft changes served content: notifies") +} + +// Version-write failures pass through the notifying wrapper unwrapped and +// fire no notification. +func TestWrapStore_VersionWriteErrorsDoNotNotify(t *testing.T) { + base := newMockVersionStore() + base.versionErr = errors.New("db down") + notified := 0 + vs, ok := wrapStore(base, func() { notified++ }).(prompt.VersionStore) + require.True(t, ok) + + err := vs.UpdateWithVersion(context.Background(), &prompt.Prompt{ID: "p1"}, "a@example.com") + assert.ErrorIs(t, err, base.versionErr) + _, err = vs.ApproveVersion(context.Background(), "p1", 2, "admin@example.com") + assert.ErrorIs(t, err, base.versionErr) + assert.Zero(t, notified) +} + +// A usage-reader failure leaves the prompt's usage fields empty instead of +// failing the read. +func TestApplyUsage_ReaderErrorLeavesFieldsEmpty(t *testing.T) { + h, _ := newTestHandle() + h.SetUsageReader(&stubUsageReader{err: errors.New("db down")}) + pr := &prompt.Prompt{ID: "p1", Name: "report"} + h.applyUsage(context.Background(), pr) + assert.Zero(t, pr.RunCount) + assert.Nil(t, pr.LastRunAt) +} + +// A search-only base (the pre-versioning shape) still round-trips without the +// capability, and a plain base yields neither extension. +func TestWrapStore_CapabilityMatrix(t *testing.T) { + plain := wrapStore(newMockPromptStore(), func() {}) + _, hasVersions := plain.(prompt.VersionStore) + assert.False(t, hasVersions) + _, hasSearch := plain.(prompt.Searcher) + assert.False(t, hasSearch) + + versioned := wrapStore(newMockVersionStore(), func() {}) + _, hasVersions = versioned.(prompt.VersionStore) + assert.True(t, hasVersions) + _, hasSearch = versioned.(prompt.Searcher) + assert.False(t, hasSearch) +} diff --git a/pkg/admin/prompt_handler.go b/pkg/admin/prompt_handler.go index 23c8e500..0de1afa9 100644 --- a/pkg/admin/prompt_handler.go +++ b/pkg/admin/prompt_handler.go @@ -2,6 +2,7 @@ package admin import ( "context" + "errors" "log/slog" "net/http" "strings" @@ -173,6 +174,7 @@ func (h *Handler) mergeSystemPrompts(prompts []prompt.Prompt, filter prompt.List Scope: promptScopeSystem, Source: promptScopeSystem, Enabled: true, + Version: 1, }) } return prompts @@ -307,13 +309,14 @@ func buildPromptFromCreateRequest(req adminPromptCreateRequest) (result *prompt. // updatePrompt updates an existing prompt. // // @Summary Update prompt -// @Description Updates an existing prompt by ID and re-registers it with the live MCP server. +// @Description Updates an existing prompt by ID and re-registers it with the live MCP server. A content or arguments edit to an approved global or persona prompt is deferred as a pending draft version (202): the approved snapshot keeps being served until the draft is approved via the version approval endpoint. // @Tags Prompts // @Accept json // @Produce json // @Param id path string true "Prompt ID" // @Param body body adminPromptUpdateRequest true "Prompt fields to update" // @Success 200 {object} prompt.Prompt +// @Success 202 {object} prompt.EditOutcome // @Failure 400 {object} problemDetail // @Failure 404 {object} problemDetail // @Failure 409 {object} problemDetail @@ -345,6 +348,7 @@ func (h *Handler) updatePrompt(w http.ResponseWriter, r *http.Request) { oldName := existing.Name oldScope := existing.Scope + before := *existing status, msg := h.applyAdminPromptUpdate(r.Context(), existing, req, adminUserEmail(r)) if status != 0 { @@ -352,8 +356,7 @@ func (h *Handler) updatePrompt(w http.ResponseWriter, r *http.Request) { return } - if err := h.deps.PromptStore.Update(r.Context(), existing); err != nil { - writeError(w, http.StatusInternalServerError, "failed to update prompt") + if !h.applyPromptEdit(w, r, &before, existing) { return } @@ -378,6 +381,29 @@ func (h *Handler) updatePrompt(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, existing) } +// applyPromptEdit lands the edited prompt through the shared review gate +// (prompt.ApplyEdit): a content edit to an approved shared prompt defers to a +// pending draft version (202 with the pending version number; it is served +// only after POST /admin/prompts/{id}/versions/{version}/approve), a gated +// edit mixed with non-versioned changes conflicts, and everything else +// applies. Returns true when the edit applied and the handler should continue. +func (h *Handler) applyPromptEdit(w http.ResponseWriter, r *http.Request, before, existing *prompt.Prompt) bool { + outcome, err := prompt.ApplyEdit(r.Context(), h.deps.PromptStore, before, existing, adminUserEmail(r)) + if errors.Is(err, prompt.ErrReviewRequiredMixedEdit) { + writeError(w, http.StatusConflict, err.Error()) + return false + } + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to update prompt") + return false + } + if !outcome.Applied { + writeJSON(w, http.StatusAccepted, outcome) + return false + } + return true +} + // applyAdminPromptUpdate validates name rename and applies field updates. // Returns (0, "") on success, or (httpStatus, errorMessage) on failure. func (h *Handler) applyAdminPromptUpdate(ctx context.Context, existing *prompt.Prompt, req adminPromptUpdateRequest, actorEmail string) (status int, errMsg string) { diff --git a/pkg/admin/prompt_handler_test.go b/pkg/admin/prompt_handler_test.go index 3fc4eafd..4c479235 100644 --- a/pkg/admin/prompt_handler_test.go +++ b/pkg/admin/prompt_handler_test.go @@ -657,3 +657,105 @@ func TestUpdatePrompt_DirectScopeChangeCollision(t *testing.T) { assert.Equal(t, http.StatusConflict, w.Code) } + +// --- Versioning review gate (#1009) --- + +// mockVersionedPromptStore adds the versioning capability to the mock store, +// with clone-on-read like the real store (a scanned row is never aliased to +// the stored state). +type mockVersionedPromptStore struct { + *mockPromptStore + draftContents []string +} + +func clonePromptRow(p *prompt.Prompt) *prompt.Prompt { + if p == nil { + return nil + } + c := *p + return &c +} + +func (m *mockVersionedPromptStore) GetByID(ctx context.Context, id string) (*prompt.Prompt, error) { + p, err := m.mockPromptStore.GetByID(ctx, id) + return clonePromptRow(p), err +} + +func (m *mockVersionedPromptStore) UpdateWithVersion(ctx context.Context, p *prompt.Prompt, _ string) error { + return m.Update(ctx, p) +} + +func (m *mockVersionedPromptStore) CreateDraftVersion(_ context.Context, _ string, proposed *prompt.Prompt, _ string) (int, error) { + m.draftContents = append(m.draftContents, proposed.Content) + return 3, nil +} + +func (*mockVersionedPromptStore) ListVersions(context.Context, string) ([]prompt.Version, error) { + return nil, nil +} + +func (*mockVersionedPromptStore) GetVersion(context.Context, string, int) (*prompt.Version, error) { + return nil, nil //nolint:nilnil // interface contract +} + +func (*mockVersionedPromptStore) ApproveVersion(context.Context, string, int, string) (*prompt.Prompt, error) { + return nil, nil //nolint:nilnil // unused in these tests +} + +func (*mockVersionedPromptStore) RejectVersion(context.Context, string, int) error { return nil } + +var _ prompt.VersionStore = (*mockVersionedPromptStore)(nil) + +// A content edit to an approved global prompt through the admin API is +// deferred as a pending draft version: 202, live row untouched, no runtime +// re-registration of draft content. +func TestUpdatePrompt_ApprovedSharedContentEditPends(t *testing.T) { + store := &mockVersionedPromptStore{mockPromptStore: newMockPromptStore()} + registrar := &mockPromptRegistrar{} + h := NewHandler(Deps{PromptStore: store, PromptRegistrar: registrar, Config: testConfig()}, nil) + store.prompts["g"] = &prompt.Prompt{ + ID: "uuid-1", Name: "g", Content: "approved body", Scope: prompt.ScopeGlobal, + Status: prompt.StatusApproved, Enabled: true, Version: 2, + } + + update := adminPromptUpdateRequest{} + newContent := "draft body" + update.Content = &newContent + bodyBytes, _ := json.Marshal(update) + req := httptest.NewRequestWithContext(context.Background(), http.MethodPut, "/api/v1/admin/prompts/uuid-1", bytes.NewReader(bodyBytes)) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + require.Equal(t, http.StatusAccepted, w.Code, w.Body.String()) + var out prompt.EditOutcome + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &out)) + assert.False(t, out.Applied) + assert.Equal(t, 3, out.PendingVersion) + assert.Equal(t, []string{"draft body"}, store.draftContents) + assert.Equal(t, "approved body", store.prompts["g"].Content, "the live row keeps the approved snapshot") + assert.Empty(t, registrar.registered, "draft content is never re-registered") +} + +// A gated content edit combined with a status change is rejected whole. +func TestUpdatePrompt_MixedGatedEditConflicts(t *testing.T) { + store := &mockVersionedPromptStore{mockPromptStore: newMockPromptStore()} + h := NewHandler(Deps{PromptStore: store, Config: testConfig()}, nil) + store.prompts["g"] = &prompt.Prompt{ + ID: "uuid-1", Name: "g", Content: "approved body", Scope: prompt.ScopeGlobal, + Status: prompt.StatusApproved, Enabled: true, Version: 2, + } + + update := adminPromptUpdateRequest{} + newContent := "draft body" + newStatus := prompt.StatusDeprecated + update.Content = &newContent + update.Status = &newStatus + bodyBytes, _ := json.Marshal(update) + req := httptest.NewRequestWithContext(context.Background(), http.MethodPut, "/api/v1/admin/prompts/uuid-1", bytes.NewReader(bodyBytes)) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + assert.Equal(t, http.StatusConflict, w.Code, w.Body.String()) + assert.Empty(t, store.draftContents) + assert.Equal(t, "approved body", store.prompts["g"].Content) +} diff --git a/pkg/audit/event.go b/pkg/audit/event.go index 0b5744db..bdc75abd 100644 --- a/pkg/audit/event.go +++ b/pkg/audit/event.go @@ -30,6 +30,13 @@ const ( // write time so the portal Activity view can split these out from // the MCP-only view. EventTypeAPIGatewayInvoke EventType = "apigateway_invoke" + + // EventTypePromptServe categorizes a prompt being served to an agent + // (prompts/get on a database prompt, or a resolved manage_prompt use). + // The event's parameters carry prompt_id, prompt_name, and version, and + // the postgres store aggregates these rows into per-prompt run counts + // and last-run timestamps (issue #1009). + EventTypePromptServe EventType = "prompt_serve" ) // toolkitKindAPIGateway is the toolkit-kind discriminator for the diff --git a/pkg/audit/postgres/usage.go b/pkg/audit/postgres/usage.go new file mode 100644 index 00000000..4a04828a --- /dev/null +++ b/pkg/audit/postgres/usage.go @@ -0,0 +1,61 @@ +package postgres + +import ( + "context" + "fmt" + "time" + + "github.com/lib/pq" + + "github.com/txn2/mcp-data-platform/pkg/audit" + "github.com/txn2/mcp-data-platform/pkg/prompt" +) + +// Compile-time check: the audit store is the platform's prompt usage reader. +var _ prompt.UsageReader = (*Store)(nil) + +// promptUsageQuery inlines the event kind as a literal (it is a compile-time +// constant, not user input) so the predicate textually matches the partial +// index idx_audit_logs_prompt_serve and stays index-eligible even under +// generic query plans, where a bind parameter would not. +// #nosec G202 -- the concatenated value is the compile-time constant +// audit.EventTypePromptServe, not user input; ids bind via $1. +var promptUsageQuery = ` + SELECT parameters->>'prompt_id', COUNT(*), MAX(timestamp) + FROM audit_logs + WHERE event_kind = '` + string(audit.EventTypePromptServe) + `' + AND parameters->>'prompt_id' = ANY($1) + GROUP BY 1` + +// PromptUsage aggregates prompt_serve audit events into per-prompt run counts +// and last-run timestamps for the given prompt IDs. Prompts never served +// (within the audit retention window) are absent from the returned map. The +// scan is bounded by the partial expression index on +// (parameters->>'prompt_id') WHERE event_kind = 'prompt_serve' +// (migration 000085). +func (s *Store) PromptUsage(ctx context.Context, promptIDs []string) (map[string]prompt.Usage, error) { + out := make(map[string]prompt.Usage, len(promptIDs)) + if len(promptIDs) == 0 { + return out, nil + } + rows, err := s.db.QueryContext(ctx, promptUsageQuery, pq.Array(promptIDs)) + if err != nil { + return nil, fmt.Errorf("query prompt usage: %w", err) + } + defer func() { _ = rows.Close() }() + + for rows.Next() { + var id string + var count int64 + var last time.Time + if err := rows.Scan(&id, &count, &last); err != nil { + return nil, fmt.Errorf("scan prompt usage: %w", err) + } + lastAt := last + out[id] = prompt.Usage{RunCount: count, LastRunAt: &lastAt} + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate prompt usage: %w", err) + } + return out, nil +} diff --git a/pkg/audit/postgres/usage_test.go b/pkg/audit/postgres/usage_test.go new file mode 100644 index 00000000..2f3be05d --- /dev/null +++ b/pkg/audit/postgres/usage_test.go @@ -0,0 +1,63 @@ +package postgres + +import ( + "context" + "errors" + "testing" + "time" + + sqlmock "github.com/DATA-DOG/go-sqlmock" + "github.com/lib/pq" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPromptUsage_EmptyIDsSkipsQuery(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + store := New(db, Config{}) + out, err := store.PromptUsage(context.Background(), nil) + assert.NoError(t, err) + assert.Empty(t, out) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestPromptUsage_AggregatesServeEvents(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + store := New(db, Config{}) + last := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC) + + mock.ExpectQuery("SELECT parameters->>'prompt_id', COUNT\\(\\*\\), MAX\\(timestamp\\)"). + WithArgs(pq.Array([]string{"p1", "p2"})). + WillReturnRows(sqlmock.NewRows([]string{"prompt_id", "count", "max"}). + AddRow("p1", int64(37), last)) + + out, err := store.PromptUsage(context.Background(), []string{"p1", "p2"}) + require.NoError(t, err) + require.Len(t, out, 1, "a prompt never served has no entry") + assert.Equal(t, int64(37), out["p1"].RunCount) + require.NotNil(t, out["p1"].LastRunAt) + assert.True(t, out["p1"].LastRunAt.Equal(last)) + _, served := out["p2"] + assert.False(t, served) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestPromptUsage_QueryError(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + store := New(db, Config{}) + mock.ExpectQuery("SELECT parameters->>'prompt_id'"). + WillReturnError(errors.New("db down")) + + _, err = store.PromptUsage(context.Background(), []string{"p1"}) + assert.Error(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/pkg/database/migrate/migrate_realpg_test.go b/pkg/database/migrate/migrate_realpg_test.go index 01200f2b..e0fcc8a4 100644 --- a/pkg/database/migrate/migrate_realpg_test.go +++ b/pkg/database/migrate/migrate_realpg_test.go @@ -14,7 +14,7 @@ import ( // expectedFinalVersion is the highest migration the embedded set defines. Bump // this when adding a migration so the gate asserts the full set applied. -const expectedFinalVersion = 84 +const expectedFinalVersion = 85 // TestMigrationsAgainstRealPostgres applies the embedded migrations to a real // PostgreSQL (pgvector) instance and exercises the full lifecycle: up, seed, diff --git a/pkg/database/migrate/migrate_unit_test.go b/pkg/database/migrate/migrate_unit_test.go index 80d38ea4..f921000d 100644 --- a/pkg/database/migrate/migrate_unit_test.go +++ b/pkg/database/migrate/migrate_unit_test.go @@ -15,7 +15,7 @@ import ( ) const ( - migrateTestFileCount = 168 + migrateTestFileCount = 170 migrateTestSuccess = "success" migrateTestFactoryError = "factory error" ) diff --git a/pkg/database/migrate/migrations/000085_prompt_versions.down.sql b/pkg/database/migrate/migrations/000085_prompt_versions.down.sql new file mode 100644 index 00000000..a4ff7bfc --- /dev/null +++ b/pkg/database/migrate/migrations/000085_prompt_versions.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_audit_logs_prompt_serve; +ALTER TABLE prompts DROP COLUMN IF EXISTS version; +DROP INDEX IF EXISTS idx_prompt_versions_prompt; +DROP TABLE IF EXISTS prompt_versions; diff --git a/pkg/database/migrate/migrations/000085_prompt_versions.up.sql b/pkg/database/migrate/migrations/000085_prompt_versions.up.sql new file mode 100644 index 00000000..16e29391 --- /dev/null +++ b/pkg/database/migrate/migrations/000085_prompt_versions.up.sql @@ -0,0 +1,52 @@ +-- Prompt versioning and approval provenance (#1009). +-- +-- prompt_versions snapshots the reviewable substance of a prompt (content, +-- display name, description, arguments, tags) on each mutation, with a +-- monotonically increasing per-prompt version number, the author, and the +-- approval stamp bound to that specific version. The live prompts row remains +-- the served state; prompts.version records which snapshot it carries. A +-- content edit to an approved shared prompt lands here as a 'draft' row and is +-- served only after an admin approves it (status 'applied'). + +CREATE TABLE IF NOT EXISTS prompt_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + prompt_id UUID NOT NULL REFERENCES prompts(id) ON DELETE CASCADE, + version INTEGER NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + content TEXT NOT NULL DEFAULT '', + arguments JSONB NOT NULL DEFAULT '[]', + tags TEXT[] NOT NULL DEFAULT '{}', + author TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'applied' + CHECK (status IN ('draft', 'applied', 'superseded', 'rejected')), + approved_by TEXT NOT NULL DEFAULT '', + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (prompt_id, version) +); + +CREATE INDEX IF NOT EXISTS idx_prompt_versions_prompt ON prompt_versions(prompt_id); + +-- The live row carries the number of the snapshot it is serving. +ALTER TABLE prompts ADD COLUMN IF NOT EXISTS version INTEGER NOT NULL DEFAULT 1; + +-- Existing prompts migrate as v1 with their current content; the approval +-- stamp carries over to that version. System rows (source='system') are +-- read-only mirrors of server configuration re-ingested at startup and are +-- never versioned. +INSERT INTO prompt_versions (prompt_id, version, display_name, description, + content, arguments, tags, author, status, + approved_by, approved_at, created_at) +SELECT id, 1, display_name, description, content, arguments, tags, + owner_email, 'applied', approved_by, approved_at, updated_at + FROM prompts + WHERE source <> 'system' +ON CONFLICT (prompt_id, version) DO NOTHING; + +-- Usage stats (#1009) aggregate prompt-serve audit events by prompt id. The +-- partial expression index keeps the run-count/last-run rollup off a full +-- partition scan. +CREATE INDEX IF NOT EXISTS idx_audit_logs_prompt_serve + ON audit_logs ((parameters->>'prompt_id')) + WHERE event_kind = 'prompt_serve'; diff --git a/pkg/platform/platform.go b/pkg/platform/platform.go index 2c108463..184feeec 100644 --- a/pkg/platform/platform.go +++ b/pkg/platform/platform.go @@ -2148,6 +2148,14 @@ func (p *Platform) bindPromptCollaborators() { if p.portalStore != nil { p.prompts.SetShareStore(p.portalStore.ShareStore()) } + // Prompt-serve audit events and the usage rollup they feed (#1009). The + // nil checks avoid binding a typed-nil store into the interfaces. + if p.auditLogger != nil { + p.prompts.SetAuditLogger(p.auditLogger) + } + if p.auditStore != nil { + p.prompts.SetUsageReader(p.auditStore) + } } // addReflexiveCaptureMiddleware wires reflexive query-error capture (#635) via diff --git a/pkg/portal/prompt_handler.go b/pkg/portal/prompt_handler.go index 5f35e3fa..79165840 100644 --- a/pkg/portal/prompt_handler.go +++ b/pkg/portal/prompt_handler.go @@ -3,6 +3,7 @@ package portal import ( "context" "encoding/json" + "errors" "net/http" "strings" "time" @@ -21,18 +22,9 @@ type SharedPrompt struct { Permission SharePermission `json:"permission"` } -// PromptStore provides prompt persistence for the portal. -type PromptStore interface { - Create(ctx context.Context, p *prompt.Prompt) error - Get(ctx context.Context, name string) (*prompt.Prompt, error) - GetPersonal(ctx context.Context, ownerEmail, name string) (*prompt.Prompt, error) - GetByID(ctx context.Context, id string) (*prompt.Prompt, error) - Update(ctx context.Context, p *prompt.Prompt) error - Delete(ctx context.Context, name string) error - DeleteByID(ctx context.Context, id string) error - List(ctx context.Context, filter prompt.ListFilter) ([]prompt.Prompt, error) - Count(ctx context.Context, filter prompt.ListFilter) (int, error) -} +// PromptStore provides prompt persistence for the portal: exactly the prompt +// store contract, aliased so portal deps read naturally. +type PromptStore = prompt.Store // PromptInfoProvider returns metadata about system-registered prompts. type PromptInfoProvider interface { @@ -305,13 +297,14 @@ func (h *Handler) createMyPrompt(w http.ResponseWriter, r *http.Request) { // updateMyPrompt handles PUT /api/v1/portal/prompts/{id}. // // @Summary Update personal prompt -// @Description Updates a personal prompt owned by the current user. Admins can update any prompt. +// @Description Updates a personal prompt owned by the current user. Admins can update any prompt; an admin's content edit to an approved shared prompt is deferred as a pending draft version (202) until approved. // @Tags Prompts // @Accept json // @Produce json // @Param id path string true "Prompt ID" // @Param body body portalPromptCreateRequest true "Updated prompt fields" // @Success 200 {object} prompt.Prompt +// @Success 202 {object} prompt.EditOutcome // @Failure 400 {object} problemDetail // @Failure 401 {object} problemDetail // @Failure 403 {object} problemDetail @@ -339,7 +332,7 @@ func (h *Handler) updateMyPrompt(w http.ResponseWriter, r *http.Request) { return } - if errMsg := checkPortalUpdatePermission(user, existing, h.deps.AdminRoles); errMsg != "" { + if errMsg := checkPortalPromptPermission(user, existing, h.deps.AdminRoles, "update"); errMsg != "" { writePortalError(w, http.StatusForbidden, errMsg) return } @@ -351,14 +344,14 @@ func (h *Handler) updateMyPrompt(w http.ResponseWriter, r *http.Request) { } oldName := existing.Name + // The pre-edit snapshot lets the review gate detect a content change. + before := *existing status, msg := h.applyAndValidatePortalUpdate(r.Context(), existing, req, oldName) if status != 0 { writePortalError(w, status, msg) return } - - if err := h.deps.PromptStore.Update(r.Context(), existing); err != nil { - writePortalError(w, http.StatusInternalServerError, "failed to update prompt") + if !h.applyPortalPromptEdit(w, r, &before, existing, user.Email) { return } @@ -384,9 +377,14 @@ func reregisterPrompt(reg PromptRegistrar, oldName string, p *prompt.Prompt) { } } -// checkPortalUpdatePermission checks whether the user may update the given prompt. -// Returns a non-empty error message if denied. -func checkPortalUpdatePermission(user *User, existing *prompt.Prompt, adminRoles []string) string { +// checkPortalPromptPermission checks whether the user may mutate the given +// prompt (verb is "update" or "delete"). System rows are read-only config +// mirrors for every caller, admins may mutate anything else, and a non-admin +// only their own personal prompts. Returns a non-empty error message if denied. +func checkPortalPromptPermission(user *User, existing *prompt.Prompt, adminRoles []string, verb string) string { + if existing.Source == prompt.SourceSystem { + return "this prompt is defined in server configuration and is read-only" + } if hasAnyRole(user.Roles, adminRoles) { return "" } @@ -394,11 +392,32 @@ func checkPortalUpdatePermission(user *User, existing *prompt.Prompt, adminRoles return "non-admins can only manage personal prompts" } if existing.OwnerEmail != user.Email { - return "you can only update your own prompts" + return "you can only " + verb + " your own prompts" } return "" } +// applyPortalPromptEdit lands the edit through the shared review gate: a +// content edit to an approved shared prompt (admin callers only here) defers +// to a pending draft version (202); a gated edit mixed with non-versioned +// changes conflicts. Returns true when the edit applied. +func (h *Handler) applyPortalPromptEdit(w http.ResponseWriter, r *http.Request, before, existing *prompt.Prompt, author string) bool { + outcome, err := prompt.ApplyEdit(r.Context(), h.deps.PromptStore, before, existing, author) + if errors.Is(err, prompt.ErrReviewRequiredMixedEdit) { + writePortalError(w, http.StatusConflict, err.Error()) + return false + } + if err != nil { + writePortalError(w, http.StatusInternalServerError, "failed to update prompt") + return false + } + if !outcome.Applied { + writePortalJSON(w, http.StatusAccepted, outcome) + return false + } + return true +} + // applyAndValidatePortalUpdate applies field updates and checks for name conflicts. // oldName is the prompt's name before any mutations were applied. // Returns (0, "") on success, or (httpStatus, errorMessage) on failure. @@ -487,16 +506,9 @@ func (h *Handler) deleteMyPrompt(w http.ResponseWriter, r *http.Request) { return } - isAdmin := hasAnyRole(user.Roles, h.deps.AdminRoles) - if !isAdmin { - if existing.Scope != prompt.ScopePersonal { - writePortalError(w, http.StatusForbidden, "non-admins can only manage personal prompts") - return - } - if existing.OwnerEmail != user.Email { - writePortalError(w, http.StatusForbidden, "you can only delete your own prompts") - return - } + if errMsg := checkPortalPromptPermission(user, existing, h.deps.AdminRoles, "delete"); errMsg != "" { + writePortalError(w, http.StatusForbidden, errMsg) + return } if err := h.deps.PromptStore.DeleteByID(r.Context(), id); err != nil { @@ -536,6 +548,7 @@ func (h *Handler) systemPrompts(existing []prompt.Prompt) []prompt.Prompt { Scope: "system", Source: "system", Enabled: true, + Version: 1, }) } return result diff --git a/pkg/portal/prompt_handler_test.go b/pkg/portal/prompt_handler_test.go index 69047a39..aa3d3f22 100644 --- a/pkg/portal/prompt_handler_test.go +++ b/pkg/portal/prompt_handler_test.go @@ -4,7 +4,7 @@ import ( "bytes" "context" "encoding/json" - "fmt" + "errors" "net/http" "net/http/httptest" "testing" @@ -504,7 +504,7 @@ func TestRevokePromptShare_OwnerOnly(t *testing.T) { func TestCreatePromptShare_GetByIDError(t *testing.T) { h, store, _ := newTestPortalPromptShareHandler() - store.getByIDErr = fmt.Errorf("db down") + store.getByIDErr = errors.New("db down") body, _ := json.Marshal(createShareRequest{SharedWithEmail: "bob@example.com"}) req := withUser(httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/v1/portal/prompts/p1/shares", bytes.NewReader(body)), "alice@example.com") w := httptest.NewRecorder() @@ -534,7 +534,7 @@ func TestCreatePromptShare_InvalidPermission(t *testing.T) { func TestCreatePromptShare_InsertError(t *testing.T) { h, store, sstore := newTestPortalPromptShareHandler() store.prompts["report"] = &prompt.Prompt{ID: "p1", Name: "report", Scope: prompt.ScopePersonal, OwnerEmail: "alice@example.com"} - sstore.insertErr = fmt.Errorf("db down") + sstore.insertErr = errors.New("db down") body, _ := json.Marshal(createShareRequest{SharedWithEmail: "bob@example.com", Permission: "viewer"}) req := withUser(httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/v1/portal/prompts/p1/shares", bytes.NewReader(body)), "alice@example.com") w := httptest.NewRecorder() @@ -561,7 +561,7 @@ func TestListPromptShares_Unauthenticated(t *testing.T) { func TestListPromptShares_GetByIDError(t *testing.T) { h, store, _ := newTestPortalPromptShareHandler() - store.getByIDErr = fmt.Errorf("db down") + store.getByIDErr = errors.New("db down") req := withUser(httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/v1/portal/prompts/p1/shares", http.NoBody), "alice@example.com") w := httptest.NewRecorder() h.ServeHTTP(w, req) @@ -579,7 +579,7 @@ func TestListPromptShares_NotFound(t *testing.T) { func TestListPromptShares_ListError(t *testing.T) { h, store, sstore := newTestPortalPromptShareHandler() store.prompts["report"] = &prompt.Prompt{ID: "p1", Name: "report", Scope: prompt.ScopePersonal, OwnerEmail: "alice@example.com"} - sstore.listByPromptE = fmt.Errorf("db down") + sstore.listByPromptE = errors.New("db down") req := withUser(httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/v1/portal/prompts/p1/shares", http.NoBody), "alice@example.com") w := httptest.NewRecorder() h.ServeHTTP(w, req) @@ -596,7 +596,7 @@ func TestListSharedPrompts_Unauthenticated(t *testing.T) { func TestListSharedPrompts_ListError(t *testing.T) { h, _, sstore := newTestPortalPromptShareHandler() - sstore.promptRefsErr = fmt.Errorf("db down") + sstore.promptRefsErr = errors.New("db down") req := withUser(httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/v1/portal/shared-prompts", http.NoBody), "bob@example.com") w := httptest.NewRecorder() h.ServeHTTP(w, req) @@ -643,3 +643,151 @@ func TestRevokePromptShare_NoPromptStore(t *testing.T) { h.ServeHTTP(w, req) assert.Equal(t, http.StatusNotFound, w.Code) } + +// --- Versioning and read-only guards (#1009) --- + +// mockVersionPromptStore adds the versioning capability to the mock store, +// with clone-on-read like the real store (a scanned row is never aliased to +// the stored state). +type mockVersionPromptStore struct { + *mockPromptStore + draftContents []string +} + +func (*mockVersionPromptStore) clone(p *prompt.Prompt) *prompt.Prompt { + if p == nil { + return nil + } + c := *p + return &c +} + +func (m *mockVersionPromptStore) GetByID(ctx context.Context, id string) (*prompt.Prompt, error) { + p, err := m.mockPromptStore.GetByID(ctx, id) + return m.clone(p), err +} + +func (m *mockVersionPromptStore) UpdateWithVersion(ctx context.Context, p *prompt.Prompt, _ string) error { + return m.Update(ctx, p) +} + +func (m *mockVersionPromptStore) CreateDraftVersion(_ context.Context, _ string, proposed *prompt.Prompt, _ string) (int, error) { + m.draftContents = append(m.draftContents, proposed.Content) + return 7, nil +} + +func (*mockVersionPromptStore) ListVersions(context.Context, string) ([]prompt.Version, error) { + return nil, nil +} + +func (*mockVersionPromptStore) GetVersion(context.Context, string, int) (*prompt.Version, error) { + return nil, nil //nolint:nilnil // interface contract +} + +func (*mockVersionPromptStore) ApproveVersion(context.Context, string, int, string) (*prompt.Prompt, error) { + return nil, nil //nolint:nilnil // unused in these tests +} + +func (*mockVersionPromptStore) RejectVersion(context.Context, string, int) error { return nil } + +var _ prompt.VersionStore = (*mockVersionPromptStore)(nil) + +func newVersionedPortalPromptHandler() (*Handler, *mockVersionPromptStore, *mockPromptRegistrar) { + store := &mockVersionPromptStore{mockPromptStore: newMockPromptStore()} + registrar := &mockPromptRegistrar{} + h := NewHandler(Deps{ + PromptStore: store, + PromptRegistrar: registrar, + AdminRoles: []string{"admin"}, + AssetStore: &noopAssetStore{}, + }, nil) + return h, store, registrar +} + +// An admin's content edit to an approved global prompt through the portal is +// deferred as a pending draft version: 202, no live-row write, no runtime +// re-registration of the draft content. +func TestPortalUpdatePrompt_ApprovedSharedContentEditPends(t *testing.T) { + h, store, registrar := newVersionedPortalPromptHandler() + store.prompts["g"] = &prompt.Prompt{ + ID: "uuid-1", Name: "g", Content: "approved body", Scope: prompt.ScopeGlobal, + Status: prompt.StatusApproved, Enabled: true, Version: 1, + } + + body := portalPromptCreateRequest{Content: "draft body"} + bodyBytes, _ := json.Marshal(body) + req := withUser(httptest.NewRequestWithContext(context.Background(), http.MethodPut, "/api/v1/portal/prompts/uuid-1", bytes.NewReader(bodyBytes)), "admin@example.com", "admin") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + require.Equal(t, http.StatusAccepted, w.Code, w.Body.String()) + var out prompt.EditOutcome + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &out)) + assert.False(t, out.Applied) + assert.Equal(t, 7, out.PendingVersion) + assert.Equal(t, []string{"draft body"}, store.draftContents) + assert.Equal(t, "approved body", store.prompts["g"].Content, "the live row keeps the approved snapshot") + assert.Empty(t, registrar.registered, "draft content is never re-registered") + assert.Empty(t, registrar.unregistered) +} + +// A gated content edit combined with a rename (a non-versioned change) is +// rejected whole as a conflict. +func TestPortalUpdatePrompt_MixedGatedEditConflicts(t *testing.T) { + h, store, _ := newVersionedPortalPromptHandler() + store.prompts["g"] = &prompt.Prompt{ + ID: "uuid-1", Name: "g", Content: "approved body", Scope: prompt.ScopePersona, + Personas: []string{"analyst"}, Status: prompt.StatusApproved, Enabled: true, Version: 1, + } + + body := portalPromptCreateRequest{Content: "draft body", Name: "g-renamed"} + bodyBytes, _ := json.Marshal(body) + req := withUser(httptest.NewRequestWithContext(context.Background(), http.MethodPut, "/api/v1/portal/prompts/uuid-1", bytes.NewReader(bodyBytes)), "admin@example.com", "admin") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + assert.Equal(t, http.StatusConflict, w.Code, w.Body.String()) + assert.Empty(t, store.draftContents) +} + +// System rows are read-only through the portal for every caller, including +// admins, on both update and delete. +func TestPortalPrompt_SystemRowsReadOnly(t *testing.T) { + h, store, _ := newTestPortalPromptHandler() + store.prompts["sys"] = &prompt.Prompt{ + ID: "uuid-sys", Name: "sys", Content: "config body", Scope: prompt.ScopeGlobal, + Source: prompt.SourceSystem, Status: prompt.StatusApproved, Enabled: true, + } + + body := portalPromptCreateRequest{Content: "hijacked"} + bodyBytes, _ := json.Marshal(body) + req := withUser(httptest.NewRequestWithContext(context.Background(), http.MethodPut, "/api/v1/portal/prompts/uuid-sys", bytes.NewReader(bodyBytes)), "admin@example.com", "admin") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Contains(t, w.Body.String(), "read-only") + assert.Equal(t, "config body", store.prompts["sys"].Content) + + req = withUser(httptest.NewRequestWithContext(context.Background(), http.MethodDelete, "/api/v1/portal/prompts/uuid-sys", http.NoBody), "admin@example.com", "admin") + w = httptest.NewRecorder() + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusForbidden, w.Code) + assert.NotNil(t, store.prompts["sys"]) +} + +// A store failure during the versioned apply is a 500. +func TestPortalUpdatePrompt_StoreFailureIs500(t *testing.T) { + h, store, _ := newTestPortalPromptHandler() + store.prompts["my-prompt"] = &prompt.Prompt{ + ID: "uuid-1", Name: "my-prompt", Content: "old", Scope: prompt.ScopePersonal, + OwnerEmail: "alice@example.com", Enabled: true, + } + store.updateErr = errors.New("db down") + + body := portalPromptCreateRequest{Content: "new"} + bodyBytes, _ := json.Marshal(body) + req := withUser(httptest.NewRequestWithContext(context.Background(), http.MethodPut, "/api/v1/portal/prompts/uuid-1", bytes.NewReader(bodyBytes)), "alice@example.com") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusInternalServerError, w.Code) +} diff --git a/pkg/prompt/edit.go b/pkg/prompt/edit.go new file mode 100644 index 00000000..c6efc597 --- /dev/null +++ b/pkg/prompt/edit.go @@ -0,0 +1,117 @@ +package prompt + +import ( + "context" + "errors" + "fmt" + "slices" +) + +// ErrReviewRequiredMixedEdit rejects an edit that combines a review-gated +// substance change (content or arguments of an approved shared prompt) with +// changes a draft version cannot carry (scope, personas, status, name, +// category, owner, enabled, promotion request). The two must be submitted +// separately so the deferred draft is exactly the reviewable snapshot. +var ErrReviewRequiredMixedEdit = errors.New( + "content changes to an approved shared prompt require review and cannot be combined with " + + "scope, status, or other non-versioned changes; submit them as separate updates") + +// EditOutcome reports how ApplyEdit landed an edit: applied to the live prompt +// row, or deferred as a pending draft version awaiting admin approval. +type EditOutcome struct { + // Applied is true when the live row was updated (the edit is being served). + Applied bool `json:"applied"` + // PendingVersion is the draft version number when the edit was deferred + // for review; zero when Applied. + PendingVersion int `json:"pending_version,omitempty"` +} + +// RequiresReview reports whether an edit must go through admin review before +// being served: a change to the prompt's substance (content or arguments) when +// the pre-edit prompt is an approved shared (global or persona) prompt. +// Personal prompts version silently (the owner is the only consumer), a +// prompt that was never approved has no approved snapshot to protect, and +// system rows are read-only config mirrors that are never versioned or +// reviewed (their content is owned by server configuration and re-ingested at +// startup). +func RequiresReview(before, after *Prompt) bool { + if before.Status != StatusApproved || before.Source == SourceSystem { + return false + } + if before.Scope != ScopeGlobal && before.Scope != ScopePersona { + return false + } + return before.Content != after.Content || !slices.Equal(before.Arguments, after.Arguments) +} + +// SnapshotChanged reports whether any versioned snapshot field (content, +// display name, description, arguments, tags) differs between the two states. +func SnapshotChanged(before, after *Prompt) bool { + return before.Content != after.Content || + before.DisplayName != after.DisplayName || + before.Description != after.Description || + !slices.Equal(before.Arguments, after.Arguments) || + !slices.Equal(before.Tags, after.Tags) +} + +// unversionedFieldsChanged reports whether the edit touches fields a version +// snapshot cannot carry. Used to reject mixed edits when review is required. +func unversionedFieldsChanged(before, after *Prompt) bool { + return identityFieldsChanged(before, after) || lifecycleFieldsChanged(before, after) +} + +// identityFieldsChanged covers the fields naming and placing the prompt. +func identityFieldsChanged(before, after *Prompt) bool { + return before.Name != after.Name || + before.Category != after.Category || + before.Scope != after.Scope || + !slices.Equal(before.Personas, after.Personas) || + before.OwnerEmail != after.OwnerEmail || + before.Source != after.Source +} + +// lifecycleFieldsChanged covers the availability and lifecycle fields. +func lifecycleFieldsChanged(before, after *Prompt) bool { + return before.Enabled != after.Enabled || + before.Status != after.Status || + before.SupersededBy != after.SupersededBy || + before.ReviewRequested != after.ReviewRequested || + before.RequestedScope != after.RequestedScope || + !slices.Equal(before.RequestedPersonas, after.RequestedPersonas) +} + +// ApplyEdit lands a prompt edit through the one shared review gate used by +// every mutation surface (manage_prompt tool, admin REST, portal REST). +// before must be the persisted pre-edit state and after the fully mutated +// copy; author is the actor recorded on any version produced. +// +// A review-gated edit (RequiresReview) becomes a pending draft version and +// leaves the live row untouched, so other users keep being served the +// approved snapshot until an admin approves the draft. Every other edit is +// applied via UpdateWithVersion, which snapshots a new applied version when a +// versioned field changed. The versioning capability is asserted from the +// store itself; a store without it (no-DB deployments, plain test stores) +// degrades to a plain unversioned update. +func ApplyEdit(ctx context.Context, store Store, before, after *Prompt, author string) (EditOutcome, error) { + versions, _ := store.(VersionStore) + if versions == nil { + if err := store.Update(ctx, after); err != nil { + return EditOutcome{}, fmt.Errorf("updating prompt: %w", err) + } + return EditOutcome{Applied: true}, nil + } + if RequiresReview(before, after) { + if unversionedFieldsChanged(before, after) { + return EditOutcome{}, ErrReviewRequiredMixedEdit + } + n, err := versions.CreateDraftVersion(ctx, before.ID, after, author) + if err != nil { + return EditOutcome{}, fmt.Errorf("creating draft version: %w", err) + } + return EditOutcome{PendingVersion: n}, nil + } + if err := versions.UpdateWithVersion(ctx, after, author); err != nil { + return EditOutcome{}, fmt.Errorf("updating prompt: %w", err) + } + return EditOutcome{Applied: true}, nil +} diff --git a/pkg/prompt/edit_test.go b/pkg/prompt/edit_test.go new file mode 100644 index 00000000..2c7fcc50 --- /dev/null +++ b/pkg/prompt/edit_test.go @@ -0,0 +1,225 @@ +package prompt + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// approvedGlobal returns an approved global prompt as the pre-edit state. +func approvedGlobal() *Prompt { + return &Prompt{ + ID: "p1", Name: "report", DisplayName: "Report", Description: "d", + Content: "body", Arguments: []Argument{{Name: "topic", Required: true}}, + Tags: []string{"a"}, Category: "analysis", + Scope: ScopeGlobal, Status: StatusApproved, Enabled: true, + } +} + +func TestRequiresReview(t *testing.T) { + tests := []struct { + name string + mutate func(before, after *Prompt) + want bool + }{ + {"content change on approved global", func(_, a *Prompt) { a.Content = "new" }, true}, + {"arguments change on approved global", func(_, a *Prompt) { a.Arguments = nil }, true}, + {"metadata-only change on approved global", func(_, a *Prompt) { a.Tags = []string{"b"}; a.Description = "x" }, false}, + {"content change on draft global", func(b, a *Prompt) { b.Status = StatusDraft; a.Status = StatusDraft; a.Content = "new" }, false}, + {"content change on approved personal", func(b, a *Prompt) { b.Scope = ScopePersonal; a.Scope = ScopePersonal; a.Content = "new" }, false}, + {"content change on approved persona prompt", func(b, a *Prompt) { b.Scope = ScopePersona; a.Scope = ScopePersona; a.Content = "new" }, true}, + {"content change on deprecated global", func(b, a *Prompt) { b.Status = StatusDeprecated; a.Status = StatusDeprecated; a.Content = "new" }, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + before, after := approvedGlobal(), approvedGlobal() + tt.mutate(before, after) + assert.Equal(t, tt.want, RequiresReview(before, after)) + }) + } +} + +func TestSnapshotChanged(t *testing.T) { + tests := []struct { + name string + mutate func(*Prompt) + want bool + }{ + {"identical", func(*Prompt) {}, false}, + {"content", func(p *Prompt) { p.Content = "x" }, true}, + {"display name", func(p *Prompt) { p.DisplayName = "x" }, true}, + {"description", func(p *Prompt) { p.Description = "x" }, true}, + {"arguments", func(p *Prompt) { p.Arguments = append(p.Arguments, Argument{Name: "n"}) }, true}, + {"tags", func(p *Prompt) { p.Tags = []string{"b"} }, true}, + {"category is not snapshotted", func(p *Prompt) { p.Category = "x" }, false}, + {"status is not snapshotted", func(p *Prompt) { p.Status = StatusDeprecated }, false}, + {"nil versus empty slices are equal", func(p *Prompt) { p.Arguments = nil; p.Tags = nil }, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + before, after := approvedGlobal(), approvedGlobal() + if tt.name == "nil versus empty slices are equal" { + before.Arguments = []Argument{} + before.Tags = []string{} + } + tt.mutate(after) + assert.Equal(t, tt.want, SnapshotChanged(before, after)) + }) + } +} + +// --- ApplyEdit fakes --- + +// plainStore implements Store only (no versioning capability). +type plainStore struct { + updated *Prompt + err error +} + +func (*plainStore) Create(context.Context, *Prompt) error { return nil } +func (*plainStore) Get(context.Context, string) (*Prompt, error) { + return nil, nil //nolint:nilnil // interface contract +} + +func (*plainStore) GetPersonal(context.Context, string, string) (*Prompt, error) { + return nil, nil //nolint:nilnil // interface contract +} + +func (*plainStore) GetByID(context.Context, string) (*Prompt, error) { + return nil, nil //nolint:nilnil // interface contract +} + +func (s *plainStore) Update(_ context.Context, p *Prompt) error { + if s.err != nil { + return s.err + } + s.updated = p + return nil +} +func (*plainStore) Delete(context.Context, string) error { return nil } +func (*plainStore) DeleteByID(context.Context, string) error { return nil } +func (*plainStore) List(context.Context, ListFilter) ([]Prompt, error) { return nil, nil } +func (*plainStore) Count(context.Context, ListFilter) (int, error) { return 0, nil } + +// versionedStore adds a recording VersionStore capability to plainStore. +type versionedStore struct { + plainStore + versioned *Prompt + draft *Prompt + draftN int + draftErr error + updateErr error +} + +func (s *versionedStore) UpdateWithVersion(_ context.Context, p *Prompt, _ string) error { + if s.updateErr != nil { + return s.updateErr + } + s.versioned = p + return nil +} + +func (s *versionedStore) CreateDraftVersion(_ context.Context, _ string, proposed *Prompt, _ string) (int, error) { + if s.draftErr != nil { + return 0, s.draftErr + } + s.draft = proposed + s.draftN = 5 + return s.draftN, nil +} + +func (*versionedStore) ListVersions(context.Context, string) ([]Version, error) { return nil, nil } +func (*versionedStore) GetVersion(context.Context, string, int) (*Version, error) { + return nil, nil //nolint:nilnil // interface contract +} + +func (*versionedStore) ApproveVersion(context.Context, string, int, string) (*Prompt, error) { + return nil, nil //nolint:nilnil // unused in these tests +} +func (*versionedStore) RejectVersion(context.Context, string, int) error { return nil } + +func TestApplyEdit_NoVersionCapabilityFallsBackToPlainUpdate(t *testing.T) { + store := &plainStore{} + before, after := approvedGlobal(), approvedGlobal() + after.Content = "new" + + out, err := ApplyEdit(context.Background(), store, before, after, "jane@example.com") + require.NoError(t, err) + assert.True(t, out.Applied) + assert.Same(t, after, store.updated, "plain update applied even for a gated edit: no versioning capability") +} + +func TestApplyEdit_ReviewGatedEditBecomesDraft(t *testing.T) { + store := &versionedStore{} + before, after := approvedGlobal(), approvedGlobal() + after.Content = "new body" + + out, err := ApplyEdit(context.Background(), store, before, after, "jane@example.com") + require.NoError(t, err) + assert.False(t, out.Applied) + assert.Equal(t, 5, out.PendingVersion) + assert.Same(t, after, store.draft, "the proposed state is snapshotted as the draft") + assert.Nil(t, store.versioned, "the live row is not updated") + assert.Nil(t, store.updated) +} + +func TestApplyEdit_MixedGatedEditRejected(t *testing.T) { + store := &versionedStore{} + tests := []struct { + name string + mutate func(*Prompt) + }{ + {"scope change", func(p *Prompt) { p.Scope = ScopePersona }}, + {"status change", func(p *Prompt) { p.Status = StatusDeprecated }}, + {"category change", func(p *Prompt) { p.Category = "other" }}, + {"rename", func(p *Prompt) { p.Name = "renamed" }}, + {"personas change", func(p *Prompt) { p.Personas = []string{"analyst"} }}, + {"enabled change", func(p *Prompt) { p.Enabled = false }}, + {"promotion request", func(p *Prompt) { p.ReviewRequested = true; p.RequestedScope = ScopeGlobal }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + before, after := approvedGlobal(), approvedGlobal() + after.Content = "new body" + tt.mutate(after) + + _, err := ApplyEdit(context.Background(), store, before, after, "jane@example.com") + require.ErrorIs(t, err, ErrReviewRequiredMixedEdit) + assert.Nil(t, store.draft, "no draft is created for a rejected mixed edit") + }) + } +} + +func TestApplyEdit_UngatedEditAppliesWithVersion(t *testing.T) { + store := &versionedStore{} + before, after := approvedGlobal(), approvedGlobal() + after.Tags = []string{"b"} + after.Status = StatusDeprecated + + out, err := ApplyEdit(context.Background(), store, before, after, "jane@example.com") + require.NoError(t, err) + assert.True(t, out.Applied) + assert.Same(t, after, store.versioned, "metadata edits apply through the versioned update") + assert.Nil(t, store.draft) +} + +func TestApplyEdit_ErrorsPropagate(t *testing.T) { + boom := errors.New("boom") + + _, err := ApplyEdit(context.Background(), &plainStore{err: boom}, + approvedGlobal(), approvedGlobal(), "a@example.com") + assert.ErrorIs(t, err, boom) + + gated := approvedGlobal() + gated.Content = "new" + _, err = ApplyEdit(context.Background(), &versionedStore{draftErr: boom}, + approvedGlobal(), gated, "a@example.com") + assert.ErrorIs(t, err, boom) + + _, err = ApplyEdit(context.Background(), &versionedStore{updateErr: boom}, + approvedGlobal(), approvedGlobal(), "a@example.com") + assert.ErrorIs(t, err, boom) +} diff --git a/pkg/prompt/postgres/store.go b/pkg/prompt/postgres/store.go index ecaee0ad..2506aaca 100644 --- a/pkg/prompt/postgres/store.go +++ b/pkg/prompt/postgres/store.go @@ -33,7 +33,7 @@ func New(db *sql.DB) *Store { const promptColumns = `id, name, display_name, description, content, arguments, category, scope, personas, owner_email, source, enabled, tags, status, approved_by, approved_at, deprecated_at, superseded_by, - review_requested, requested_scope, requested_personas, created_at, updated_at` + review_requested, requested_scope, requested_personas, version, created_at, updated_at` // promptSelect is the base SELECT for the prompt columns. const promptSelect = "SELECT " + promptColumns + " FROM prompts" @@ -54,7 +54,7 @@ func promptScanDest(p *prompt.Prompt, argsJSON *[]byte) []any { &p.Source, &p.Enabled, pq.Array(&p.Tags), &p.Status, &p.ApprovedBy, &p.ApprovedAt, &p.DeprecatedAt, &p.SupersededBy, &p.ReviewRequested, &p.RequestedScope, pq.Array(&p.RequestedPersonas), - &p.CreatedAt, &p.UpdatedAt, + &p.Version, &p.CreatedAt, &p.UpdatedAt, } } @@ -98,6 +98,9 @@ func normalizeSlices(p *prompt.Prompt) { } // Create persists a new prompt. If p.ID is empty the database generates one. +// Every non-system prompt is created with version 1 and a matching applied +// snapshot in prompt_versions, in one transaction; system rows (read-only +// config mirrors re-ingested at startup) are never versioned. func (s *Store) Create(ctx context.Context, p *prompt.Prompt) error { // Normalize nil slices to empty before binding: pq.Array(nil) binds SQL NULL, // which violates the NOT NULL constraints on personas, tags, and @@ -120,18 +123,33 @@ func (s *Store) Create(ctx context.Context, p *prompt.Prompt) error { superseded_by, review_requested, requested_scope, requested_personas) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) - RETURNING id, created_at, updated_at` - - err = s.db.QueryRowContext(ctx, query, - p.Name, p.DisplayName, p.Description, p.Content, argsJSON, - p.Category, p.Scope, pq.Array(p.Personas), p.OwnerEmail, p.Source, p.Enabled, - pq.Array(p.Tags), p.Status, p.ApprovedBy, p.ApprovedAt, p.DeprecatedAt, - p.SupersededBy, p.ReviewRequested, p.RequestedScope, pq.Array(p.RequestedPersonas), - ).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt) - if err != nil { - return fmt.Errorf("create prompt: %w", err) - } - return nil + RETURNING id, version, created_at, updated_at` + + return s.withTx(ctx, "create prompt", func(tx *sql.Tx) error { + err := tx.QueryRowContext(ctx, query, + p.Name, p.DisplayName, p.Description, p.Content, argsJSON, + p.Category, p.Scope, pq.Array(p.Personas), p.OwnerEmail, p.Source, p.Enabled, + pq.Array(p.Tags), p.Status, p.ApprovedBy, p.ApprovedAt, p.DeprecatedAt, + p.SupersededBy, p.ReviewRequested, p.RequestedScope, pq.Array(p.RequestedPersonas), + ).Scan(&p.ID, &p.Version, &p.CreatedAt, &p.UpdatedAt) + if err != nil { + return fmt.Errorf("create prompt: %w", err) + } + if p.Source == prompt.SourceSystem { + return nil + } + // The creating prompt may already carry an approval stamp (backfills, + // tests); bind it to v1 so the stamp and the snapshot stay together. + return insertVersionRow(ctx, tx, versionInsert{ + PromptID: p.ID, + Version: p.Version, + Snapshot: p, + Author: p.OwnerEmail, + Status: prompt.VersionStatusApplied, + ApprovedBy: p.ApprovedBy, + ApprovedAt: p.ApprovedAt, + }) + }) } // Get retrieves a non-personal (global or persona) prompt by name. Personal @@ -166,11 +184,37 @@ func (s *Store) queryOne(ctx context.Context, query string, args ...any) (*promp return p, nil } -// Update modifies an existing prompt identified by ID. +// Update modifies an existing prompt identified by ID. It does not create a +// version snapshot (use UpdateWithVersion for versioned edits), but it does +// bind a first approval to the current version: when the update carries the +// draft-to-approved transition, the approval stamp is copied onto the current +// prompt_versions row in the same transaction, so "approved" always names a +// specific snapshot regardless of which path performed the approval. Like +// UpdateWithVersion, it re-validates the review gate under the row lock, so a +// write racing an approval cannot slip unreviewed content past it. func (s *Store) Update(ctx context.Context, p *prompt.Prompt) error { // See Create: nil slices must be normalized to empty so pq.Array binds '{}' // rather than NULL into the NOT NULL personas/tags/requested_personas columns. normalizeSlices(p) + return s.withTx(ctx, "update prompt", func(tx *sql.Tx) error { + before, err := lockPrompt(ctx, tx, p.ID) + if err != nil { + return err + } + if err := requireUngated(before, p); err != nil { + return err + } + if err := updateTx(ctx, tx, p); err != nil { + return err + } + return stampApprovalTransition(ctx, tx, p, before.Status) + }) +} + +// updateTx writes every prompt column within the caller's transaction. Shared +// by Update, UpdateWithVersion, and ApproveVersion so the embedding +// invalidation below applies identically on every write path. +func updateTx(ctx context.Context, tx *sql.Tx, p *prompt.Prompt) error { argsJSON, err := json.Marshal(p.Arguments) if err != nil { return fmt.Errorf("marshal arguments: %w", err) @@ -193,6 +237,7 @@ func (s *Store) Update(ctx context.Context, p *prompt.Prompt) error { status = $14, approved_by = $15, approved_at = $16, deprecated_at = $17, superseded_by = $18, review_requested = $19, requested_scope = $20, requested_personas = $21, + version = CASE WHEN $23 > 0 THEN $23 ELSE version END, embedding = CASE WHEN embedding_text_hash IS DISTINCT FROM $22 THEN NULL ELSE embedding END, embedding_model = CASE WHEN embedding_text_hash IS DISTINCT FROM $22 @@ -202,12 +247,12 @@ func (s *Store) Update(ctx context.Context, p *prompt.Prompt) error { updated_at = NOW() WHERE id = $1` - res, err := s.db.ExecContext(ctx, query, + res, err := tx.ExecContext(ctx, query, p.ID, p.Name, p.DisplayName, p.Description, p.Content, argsJSON, p.Category, p.Scope, pq.Array(p.Personas), p.OwnerEmail, p.Source, p.Enabled, pq.Array(p.Tags), p.Status, p.ApprovedBy, p.ApprovedAt, p.DeprecatedAt, p.SupersededBy, p.ReviewRequested, p.RequestedScope, pq.Array(p.RequestedPersonas), - newHash, + newHash, p.Version, ) if err != nil { return fmt.Errorf("update prompt: %w", err) @@ -219,6 +264,23 @@ func (s *Store) Update(ctx context.Context, p *prompt.Prompt) error { return nil } +// withTx runs fn inside a transaction, rolling back on error. op labels the +// begin/commit errors. +func (s *Store) withTx(ctx context.Context, op string, fn func(tx *sql.Tx) error) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin %s: %w", op, err) + } + if err := fn(tx); err != nil { + _ = tx.Rollback() + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit %s: %w", op, err) + } + return nil +} + // Delete removes a non-personal prompt by name. func (s *Store) Delete(ctx context.Context, name string) error { _, err := s.db.ExecContext(ctx, `DELETE FROM prompts WHERE name = $1 AND scope <> 'personal'`, name) diff --git a/pkg/prompt/postgres/store_test.go b/pkg/prompt/postgres/store_test.go index 0b755db5..02a2a290 100644 --- a/pkg/prompt/postgres/store_test.go +++ b/pkg/prompt/postgres/store_test.go @@ -23,7 +23,7 @@ var selectColumns = []string{ "category", "scope", "personas", "owner_email", "source", "enabled", "tags", "status", "approved_by", "approved_at", "deprecated_at", "superseded_by", "review_requested", "requested_scope", "requested_personas", - "created_at", "updated_at", + "version", "created_at", "updated_at", } // testRowTime is the fixed created_at/updated_at value used by promptRow; the @@ -31,13 +31,13 @@ var selectColumns = []string{ var testRowTime = time.Unix(1700000000, 0).UTC() // promptRow returns a full result row in promptColumns order for a global -// prompt, so SELECT-mocking tests do not each repeat 23 values. +// prompt, so SELECT-mocking tests do not each repeat 24 values. func promptRow(id, name, scope string, argsJSON []byte, owner string) []driver.Value { return []driver.Value{ id, name, "Test Prompt", "A test prompt", "Do something with {topic}", argsJSON, "workflow", scope, pq.Array([]string{}), owner, "operator", true, pq.Array([]string{}), "approved", "", nil, nil, "", - false, "", pq.Array([]string{}), + false, "", pq.Array([]string{}), 1, testRowTime, testRowTime, } } @@ -86,18 +86,48 @@ func TestCreate_Success(t *testing.T) { argsJSON, err := json.Marshal(p.Arguments) require.NoError(t, err) + mock.ExpectBegin() mock.ExpectQuery("INSERT INTO prompts").WithArgs( p.Name, p.DisplayName, p.Description, p.Content, argsJSON, p.Category, p.Scope, pq.Array(p.Personas), p.OwnerEmail, p.Source, p.Enabled, pq.Array(p.Tags), prompt.StatusDraft, "", nil, nil, "", false, "", pq.Array(p.RequestedPersonas), - ).WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}). - AddRow("uuid-123", now, now)) + ).WillReturnRows(sqlmock.NewRows([]string{"id", "version", "created_at", "updated_at"}). + AddRow("uuid-123", 1, now, now)) + mock.ExpectExec("INSERT INTO prompt_versions").WithArgs( + "uuid-123", 1, p.DisplayName, p.Description, p.Content, argsJSON, + pq.Array(p.Tags), p.OwnerEmail, prompt.VersionStatusApplied, "", nil, + ).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() err = store.Create(context.Background(), p) assert.NoError(t, err) assert.Equal(t, "uuid-123", p.ID) + assert.Equal(t, 1, p.Version) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestCreate_SystemPromptSkipsVersionRow(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + store := New(db) + p := newTestPrompt() + p.Source = prompt.SourceSystem + now := time.Now().UTC() + + // System rows (config mirrors) create the prompts row only: no + // prompt_versions INSERT is expected before the commit. + mock.ExpectBegin() + mock.ExpectQuery("INSERT INTO prompts"). + WillReturnRows(sqlmock.NewRows([]string{"id", "version", "created_at", "updated_at"}). + AddRow("uuid-sys", 1, now, now)) + mock.ExpectCommit() + + err = store.Create(context.Background(), p) + assert.NoError(t, err) assert.NoError(t, mock.ExpectationsWereMet()) } @@ -109,8 +139,10 @@ func TestCreate_DBError(t *testing.T) { store := New(db) p := newTestPrompt() + mock.ExpectBegin() mock.ExpectQuery("INSERT INTO prompts"). WillReturnError(errors.New("connection refused")) + mock.ExpectRollback() err = store.Create(context.Background(), p) assert.Error(t, err) @@ -184,18 +216,88 @@ func TestUpdate_Success(t *testing.T) { store := New(db) p := newTestPrompt() p.ID = "uuid-123" + p.Version = 1 + p.Status = prompt.StatusApproved argsJSON, err := json.Marshal(p.Arguments) require.NoError(t, err) + mock.ExpectBegin() + expectLockPrompt(mock, p, argsJSON) mock.ExpectExec("UPDATE prompts").WithArgs( p.ID, p.Name, p.DisplayName, p.Description, p.Content, argsJSON, p.Category, p.Scope, pq.Array(p.Personas), p.OwnerEmail, p.Source, p.Enabled, pq.Array(p.Tags), p.Status, p.ApprovedBy, p.ApprovedAt, p.DeprecatedAt, p.SupersededBy, p.ReviewRequested, p.RequestedScope, pq.Array(p.RequestedPersonas), - indexjobs.TextHash(prompt.IndexText(p)), + indexjobs.TextHash(prompt.IndexText(p)), p.Version, ).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + err = store.Update(context.Background(), p) + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +// TestUpdate_RejectsRacingGatedEdit verifies the store-level review gate: a +// plain Update carrying a content change against a row that is (as locked) +// an approved shared prompt is rejected as a conflict rather than applied. +func TestUpdate_RejectsRacingGatedEdit(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + store := New(db) + p := newTestPrompt() + p.ID = "uuid-123" + p.Version = 1 + p.Status = prompt.StatusApproved + p.Content = "unreviewed new content" + + locked := *p + locked.Content = "the approved content" + argsJSON, err := json.Marshal(p.Arguments) + require.NoError(t, err) + + mock.ExpectBegin() + expectLockPrompt(mock, &locked, argsJSON) + mock.ExpectRollback() + + err = store.Update(context.Background(), p) + require.ErrorIs(t, err, prompt.ErrVersionConflict) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +// TestUpdate_ApprovalTransitionStampsVersion verifies the draft-to-approved +// transition copies the prompt's approval stamp onto its current version row +// within the same transaction, binding the approval to a specific snapshot. +func TestUpdate_ApprovalTransitionStampsVersion(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + store := New(db) + p := newTestPrompt() + p.ID = "uuid-123" + p.Version = 1 + p.Status = prompt.StatusApproved + p.ApprovedBy = "admin@example.com" + now := time.Now().UTC() + p.ApprovedAt = &now + + locked := *p + locked.Status = prompt.StatusDraft + argsJSON, err := json.Marshal(p.Arguments) + require.NoError(t, err) + + mock.ExpectBegin() + expectLockPrompt(mock, &locked, argsJSON) + mock.ExpectExec("UPDATE prompts"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("UPDATE prompt_versions").WithArgs( + p.ID, p.ApprovedBy, p.ApprovedAt, + ).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() err = store.Update(context.Background(), p) assert.NoError(t, err) @@ -211,8 +313,10 @@ func TestUpdate_NotFound(t *testing.T) { p := newTestPrompt() p.ID = "missing" - mock.ExpectExec("UPDATE prompts"). - WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectBegin() + mock.ExpectQuery("SELECT .+ FROM prompts WHERE id = .+ FOR UPDATE").WithArgs(p.ID). + WillReturnRows(sqlmock.NewRows(selectColumns)) + mock.ExpectRollback() err = store.Update(context.Background(), p) assert.Error(t, err) diff --git a/pkg/prompt/postgres/version.go b/pkg/prompt/postgres/version.go new file mode 100644 index 00000000..6c63a2b6 --- /dev/null +++ b/pkg/prompt/postgres/version.go @@ -0,0 +1,374 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/lib/pq" + + "github.com/txn2/mcp-data-platform/pkg/prompt" +) + +// Compile-time check: the PostgreSQL store provides prompt versioning. +var _ prompt.VersionStore = (*Store)(nil) + +// versionColumns is the column list read by every prompt_versions SELECT, +// mirrored by scanVersion so the scan order cannot drift from the query. +const versionColumns = `id, prompt_id, version, display_name, description, content, + arguments, tags, author, status, approved_by, approved_at, created_at` + +// versionSelect is the base SELECT for the version columns. +const versionSelect = "SELECT " + versionColumns + " FROM prompt_versions" + +// scanVersion reads one row in versionColumns order into a Version. +func scanVersion(sc rowScanner) (*prompt.Version, error) { + v := &prompt.Version{} + var argsJSON []byte + err := sc.Scan(&v.ID, &v.PromptID, &v.Version, &v.DisplayName, &v.Description, + &v.Content, &argsJSON, pq.Array(&v.Tags), &v.Author, &v.Status, + &v.ApprovedBy, &v.ApprovedAt, &v.CreatedAt) + if err != nil { + return nil, fmt.Errorf("scanning prompt version row: %w", err) + } + if err := json.Unmarshal(argsJSON, &v.Arguments); err != nil { + return nil, fmt.Errorf("unmarshal version arguments: %w", err) + } + if v.Arguments == nil { + v.Arguments = []prompt.Argument{} + } + if v.Tags == nil { + v.Tags = []string{} + } + return v, nil +} + +// versionInsert carries one prompt_versions INSERT: the snapshot source, its +// author, the row status, and an optional approval stamp bound to the row. +type versionInsert struct { + PromptID string + Version int + Snapshot *prompt.Prompt + Author string + Status string + ApprovedBy string + ApprovedAt *time.Time +} + +// insertVersionRow snapshots the versioned fields of ins.Snapshot as one +// immutable prompt_versions row within the caller's transaction. +func insertVersionRow(ctx context.Context, tx *sql.Tx, ins versionInsert) error { + argsJSON, err := json.Marshal(ins.Snapshot.Arguments) + if err != nil { + return fmt.Errorf("marshal version arguments: %w", err) + } + tags := ins.Snapshot.Tags + if tags == nil { + tags = []string{} + } + _, err = tx.ExecContext(ctx, ` + INSERT INTO prompt_versions (prompt_id, version, display_name, description, + content, arguments, tags, author, status, + approved_by, approved_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + ins.PromptID, ins.Version, ins.Snapshot.DisplayName, ins.Snapshot.Description, + ins.Snapshot.Content, argsJSON, pq.Array(tags), ins.Author, ins.Status, + ins.ApprovedBy, ins.ApprovedAt, + ) + if err != nil { + return fmt.Errorf("insert prompt version: %w", err) + } + return nil +} + +// lockPromptStatus locks the prompt row for the transaction and returns its +// current status, mapping a missing row to a not-found error. +func lockPromptStatus(ctx context.Context, tx *sql.Tx, id string) (string, error) { + var status string + err := tx.QueryRowContext(ctx, `SELECT status FROM prompts WHERE id = $1 FOR UPDATE`, id).Scan(&status) + if errors.Is(err, sql.ErrNoRows) { + return "", fmt.Errorf("prompt %s not found", id) + } + if err != nil { + return "", fmt.Errorf("lock prompt: %w", err) + } + return status, nil +} + +// lockPrompt locks and returns the full prompt row for the transaction, or an +// error when the prompt does not exist. +func lockPrompt(ctx context.Context, tx *sql.Tx, id string) (*prompt.Prompt, error) { + p, err := scanPrompt(tx.QueryRowContext(ctx, promptSelect+` WHERE id = $1 FOR UPDATE`, id)) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("prompt %s not found", id) + } + if err != nil { + return nil, fmt.Errorf("lock prompt: %w", err) + } + return p, nil +} + +// nextVersionNumber allocates the next version number for a prompt. Callers +// hold the prompt row lock, which serializes allocation. GREATEST guards a row +// whose live version somehow exceeds its history (no snapshot rows). +func nextVersionNumber(ctx context.Context, tx *sql.Tx, promptID string) (int, error) { + var n int + err := tx.QueryRowContext(ctx, ` + SELECT GREATEST(COALESCE(MAX(pv.version), 0), + (SELECT version FROM prompts WHERE id = $1)) + 1 + FROM prompt_versions pv WHERE pv.prompt_id = $1`, promptID).Scan(&n) + if err != nil { + return 0, fmt.Errorf("next version number: %w", err) + } + return n, nil +} + +// stampApprovalTransition binds a first approval to the snapshot it approved: +// when this write carried the draft-to-approved transition, the prompt's +// approval stamp is copied onto its current (already applied) version row. +// Later approvals of later versions never touch this row again, so the stamp +// recorded for a version is immutable. +func stampApprovalTransition(ctx context.Context, tx *sql.Tx, p *prompt.Prompt, oldStatus string) error { + if oldStatus == prompt.StatusApproved || p.Status != prompt.StatusApproved { + return nil + } + _, err := tx.ExecContext(ctx, ` + UPDATE prompt_versions + SET approved_by = $2, approved_at = $3 + WHERE prompt_id = $1 + AND version = (SELECT version FROM prompts WHERE id = $1) + AND approved_at IS NULL`, + p.ID, p.ApprovedBy, p.ApprovedAt) + if err != nil { + return fmt.Errorf("stamp version approval: %w", err) + } + return nil +} + +// requireUngated re-validates the review gate against the row as locked in +// this transaction. Callers decide gating from an earlier unlocked read; if +// the prompt was approved between that read and the write (an edit racing an +// approval), applying the edit here would slip unreviewed content into an +// approved shared prompt and overwrite the fresh approval with stale state, +// so the write is rejected as a conflict for the caller to re-read and retry. +func requireUngated(locked, p *prompt.Prompt) error { + if prompt.RequiresReview(locked, p) { + return fmt.Errorf("prompt %s was approved while this edit was in flight; re-read and retry (content changes to an approved shared prompt require review): %w", + p.ID, prompt.ErrVersionConflict) + } + return nil +} + +// UpdateWithVersion persists p like Update and, when any versioned snapshot +// field changed against the stored row, records a new applied version authored +// by author and advances p.Version to it. System rows are written without +// versioning (config mirrors). The review gate is re-validated under the row +// lock (see requireUngated). +func (s *Store) UpdateWithVersion(ctx context.Context, p *prompt.Prompt, author string) error { + normalizeSlices(p) + return s.withTx(ctx, "update prompt with version", func(tx *sql.Tx) error { + before, err := lockPrompt(ctx, tx, p.ID) + if err != nil { + return err + } + if err := requireUngated(before, p); err != nil { + return err + } + if prompt.SnapshotChanged(before, p) && p.Source != prompt.SourceSystem { + n, err := nextVersionNumber(ctx, tx, p.ID) + if err != nil { + return err + } + p.Version = n + if err := insertVersionRow(ctx, tx, versionInsert{ + PromptID: p.ID, Version: n, Snapshot: p, + Author: author, Status: prompt.VersionStatusApplied, + }); err != nil { + return err + } + } + if err := updateTx(ctx, tx, p); err != nil { + return err + } + return stampApprovalTransition(ctx, tx, p, before.Status) + }) +} + +// CreateDraftVersion snapshots proposed's versioned fields as a new draft +// version of the prompt without touching the live row, returning the new +// version number. System rows are read-only config mirrors and refuse drafts. +func (s *Store) CreateDraftVersion(ctx context.Context, promptID string, proposed *prompt.Prompt, author string) (int, error) { + var n int + err := s.withTx(ctx, "create draft version", func(tx *sql.Tx) error { + locked, err := lockPrompt(ctx, tx, promptID) + if err != nil { + return err + } + if locked.Source == prompt.SourceSystem { + return fmt.Errorf("prompt %s is a read-only system prompt and cannot have draft versions: %w", + promptID, prompt.ErrVersionConflict) + } + n, err = nextVersionNumber(ctx, tx, promptID) + if err != nil { + return err + } + return insertVersionRow(ctx, tx, versionInsert{ + PromptID: promptID, Version: n, Snapshot: proposed, + Author: author, Status: prompt.VersionStatusDraft, + }) + }) + if err != nil { + return 0, err + } + return n, nil +} + +// ListVersions returns every version of the prompt, newest first. +func (s *Store) ListVersions(ctx context.Context, promptID string) ([]prompt.Version, error) { + rows, err := s.db.QueryContext(ctx, + versionSelect+` WHERE prompt_id = $1 ORDER BY version DESC`, promptID) + if err != nil { + return nil, fmt.Errorf("list prompt versions: %w", err) + } + defer func() { _ = rows.Close() }() + + var out []prompt.Version + for rows.Next() { + v, err := scanVersion(rows) + if err != nil { + return nil, err + } + out = append(out, *v) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate prompt versions: %w", err) + } + return out, nil +} + +// GetVersion returns one version with its full content, or nil, nil when the +// prompt has no such version. +func (s *Store) GetVersion(ctx context.Context, promptID string, version int) (*prompt.Version, error) { + v, err := scanVersion(s.db.QueryRowContext(ctx, + versionSelect+` WHERE prompt_id = $1 AND version = $2`, promptID, version)) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil //nolint:nilnil // VersionStore contract: nil, nil means not found + } + if err != nil { + return nil, fmt.Errorf("get prompt version: %w", err) + } + return v, nil +} + +// getVersionTx reads one version row within the caller's transaction, mapping +// a missing row to nil, nil. +func getVersionTx(ctx context.Context, tx *sql.Tx, promptID string, version int) (*prompt.Version, error) { + v, err := scanVersion(tx.QueryRowContext(ctx, + versionSelect+` WHERE prompt_id = $1 AND version = $2 FOR UPDATE`, promptID, version)) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil //nolint:nilnil // internal contract matches GetVersion: nil, nil means not found + } + if err != nil { + return nil, fmt.Errorf("get prompt version: %w", err) + } + return v, nil +} + +// lockApprovableDraft locks and returns the prompt and the pending draft +// version to approve, rejecting a retired prompt, a missing version, or a +// version that is not a pending draft. +func lockApprovableDraft(ctx context.Context, tx *sql.Tx, promptID string, version int) (*prompt.Prompt, *prompt.Version, error) { + p, err := lockPrompt(ctx, tx, promptID) + if err != nil { + return nil, nil, err + } + if p.Status != prompt.StatusApproved && p.Status != prompt.StatusDraft { + return nil, nil, fmt.Errorf("cannot approve a version of a %s prompt: %w", p.Status, prompt.ErrVersionConflict) + } + v, err := getVersionTx(ctx, tx, promptID, version) + if err != nil { + return nil, nil, err + } + if v == nil { + return nil, nil, fmt.Errorf("prompt %s has no version %d: %w", promptID, version, prompt.ErrVersionConflict) + } + if v.Status != prompt.VersionStatusDraft { + return nil, nil, fmt.Errorf("version %d is %s, not a pending draft: %w", version, v.Status, prompt.ErrVersionConflict) + } + return p, v, nil +} + +// applyDraftSnapshot copies the draft's versioned fields onto the live prompt +// and stamps the approval. +func applyDraftSnapshot(p *prompt.Prompt, v *prompt.Version, approver string, now time.Time) { + p.DisplayName, p.Description, p.Content = v.DisplayName, v.Description, v.Content + p.Arguments = v.Arguments + p.Tags = v.Tags + p.Version = v.Version + p.Status = prompt.StatusApproved + p.ApprovedBy = approver + p.ApprovedAt = &now +} + +// ApproveVersion applies draft version's snapshot to the live prompt row, +// marks the prompt approved with approver's stamp, binds the same stamp to the +// version row, and supersedes any other pending drafts. Returns the updated +// prompt. +func (s *Store) ApproveVersion(ctx context.Context, promptID string, version int, approver string) (*prompt.Prompt, error) { + var out *prompt.Prompt + err := s.withTx(ctx, "approve version", func(tx *sql.Tx) error { + p, v, err := lockApprovableDraft(ctx, tx, promptID, version) + if err != nil { + return err + } + now := time.Now().UTC() + applyDraftSnapshot(p, v, approver, now) + if err := updateTx(ctx, tx, p); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, ` + UPDATE prompt_versions SET status = $3, approved_by = $4, approved_at = $5 + WHERE prompt_id = $1 AND version = $2`, + promptID, version, prompt.VersionStatusApplied, approver, now); err != nil { + return fmt.Errorf("stamp approved version: %w", err) + } + // Any other draft was proposed against a snapshot that is no longer + // current; keep it in history but take it out of the pending queue. + if _, err := tx.ExecContext(ctx, ` + UPDATE prompt_versions SET status = $3 + WHERE prompt_id = $1 AND status = $2`, + promptID, prompt.VersionStatusDraft, prompt.VersionStatusSuperseded); err != nil { + return fmt.Errorf("supersede stale drafts: %w", err) + } + out = p + return nil + }) + if err != nil { + return nil, err + } + return out, nil +} + +// RejectVersion marks a pending draft version rejected, leaving the live +// prompt row untouched. +func (s *Store) RejectVersion(ctx context.Context, promptID string, version int) error { + return s.withTx(ctx, "reject version", func(tx *sql.Tx) error { + if _, err := lockPromptStatus(ctx, tx, promptID); err != nil { + return err + } + res, err := tx.ExecContext(ctx, ` + UPDATE prompt_versions SET status = $3 + WHERE prompt_id = $1 AND version = $2 AND status = $4`, + promptID, version, prompt.VersionStatusRejected, prompt.VersionStatusDraft) + if err != nil { + return fmt.Errorf("reject version: %w", err) + } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("prompt %s has no pending draft version %d: %w", promptID, version, prompt.ErrVersionConflict) + } + return nil + }) +} diff --git a/pkg/prompt/postgres/version_realdb_integration_test.go b/pkg/prompt/postgres/version_realdb_integration_test.go new file mode 100644 index 00000000..48439349 --- /dev/null +++ b/pkg/prompt/postgres/version_realdb_integration_test.go @@ -0,0 +1,152 @@ +//go:build integration + +package postgres + +// Real-Postgres proof for the prompt versioning lifecycle (#1009): create +// snapshots v1, versioned updates append applied snapshots, a review-gated +// draft leaves the live row untouched until ApproveVersion applies it, and +// approval stamps bind immutably to the version they approved. Runs against +// the actual prompt_versions schema (constraints, defaults, FK cascade) that +// the sqlmock tests rubber-stamp. + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/txn2/mcp-data-platform/internal/testdb" + "github.com/txn2/mcp-data-platform/pkg/prompt" +) + +func TestVersioning_RealDB_Lifecycle(t *testing.T) { + store := New(testdb.New(t)) + ctx := context.Background() + + // Create -> v1 applied snapshot. + p := &prompt.Prompt{ + Name: "sales-report", DisplayName: "Sales Report", Content: "v1 body", + Scope: prompt.ScopeGlobal, Source: prompt.SourceOperator, Enabled: true, + OwnerEmail: "jane@example.com", + } + require.NoError(t, store.Create(ctx, p)) + assert.Equal(t, 1, p.Version) + + v1, err := store.GetVersion(ctx, p.ID, 1) + require.NoError(t, err) + require.NotNil(t, v1, "create writes the v1 snapshot") + assert.Equal(t, "v1 body", v1.Content) + assert.Equal(t, "jane@example.com", v1.Author) + assert.Equal(t, prompt.VersionStatusApplied, v1.Status) + assert.Empty(t, v1.ApprovedBy, "an unapproved draft prompt has no approval stamp") + + // First approval (draft -> approved via plain Update) stamps v1. + loaded, err := store.Get(ctx, "sales-report") + require.NoError(t, err) + require.NoError(t, loaded.ApplyStatusTransition(prompt.StatusApproved, "", "admin@example.com", true, time.Now().UTC())) + require.NoError(t, store.Update(ctx, loaded)) + + v1, err = store.GetVersion(ctx, p.ID, 1) + require.NoError(t, err) + assert.Equal(t, "admin@example.com", v1.ApprovedBy, "first approval binds to the v1 snapshot") + require.NotNil(t, v1.ApprovedAt) + v1Stamp := *v1.ApprovedAt + + // A versioned metadata edit appends an applied v2 without re-approval. + loaded, err = store.Get(ctx, "sales-report") + require.NoError(t, err) + loaded.Description = "now with a description" + require.NoError(t, store.UpdateWithVersion(ctx, loaded, "jane@example.com")) + assert.Equal(t, 2, loaded.Version) + + // A review-gated content edit lands as a draft v3; the live row is untouched. + proposed := *loaded + proposed.Content = "v3 proposed body" + n, err := store.CreateDraftVersion(ctx, p.ID, &proposed, "sam@example.com") + require.NoError(t, err) + assert.Equal(t, 3, n) + + live, err := store.Get(ctx, "sales-report") + require.NoError(t, err) + assert.Equal(t, "v1 body", live.Content, "the draft does not change the served content") + assert.Equal(t, 2, live.Version) + + // A second competing draft. + proposed.Content = "v4 competing body" + n2, err := store.CreateDraftVersion(ctx, p.ID, &proposed, "kim@example.com") + require.NoError(t, err) + assert.Equal(t, 4, n2) + + // Approving v3 applies its snapshot, stamps it, and supersedes v4. + updated, err := store.ApproveVersion(ctx, p.ID, 3, "admin2@example.com") + require.NoError(t, err) + assert.Equal(t, "v3 proposed body", updated.Content) + assert.Equal(t, 3, updated.Version) + assert.Equal(t, "admin2@example.com", updated.ApprovedBy) + + live, err = store.Get(ctx, "sales-report") + require.NoError(t, err) + assert.Equal(t, "v3 proposed body", live.Content, "the approved snapshot is now served") + + v3, err := store.GetVersion(ctx, p.ID, 3) + require.NoError(t, err) + assert.Equal(t, prompt.VersionStatusApplied, v3.Status) + assert.Equal(t, "admin2@example.com", v3.ApprovedBy) + + v4, err := store.GetVersion(ctx, p.ID, 4) + require.NoError(t, err) + assert.Equal(t, prompt.VersionStatusSuperseded, v4.Status, "a stale competing draft is superseded") + + // Approving v3 did not alter v1's recorded approval. + v1Again, err := store.GetVersion(ctx, p.ID, 1) + require.NoError(t, err) + assert.Equal(t, "admin@example.com", v1Again.ApprovedBy) + assert.True(t, v1Again.ApprovedAt.Equal(v1Stamp), "the v1 approval stamp is immutable") + + // History is complete, newest first, with full content and author. + versions, err := store.ListVersions(ctx, p.ID) + require.NoError(t, err) + require.Len(t, versions, 4) + assert.Equal(t, []int{4, 3, 2, 1}, []int{versions[0].Version, versions[1].Version, versions[2].Version, versions[3].Version}) + assert.Equal(t, "sam@example.com", versions[1].Author) + + // A fresh draft can be rejected without touching the live row. + proposed.Content = "v5 rejected body" + n5, err := store.CreateDraftVersion(ctx, p.ID, &proposed, "sam@example.com") + require.NoError(t, err) + require.NoError(t, store.RejectVersion(ctx, p.ID, n5)) + v5, err := store.GetVersion(ctx, p.ID, n5) + require.NoError(t, err) + assert.Equal(t, prompt.VersionStatusRejected, v5.Status) + live, err = store.Get(ctx, "sales-report") + require.NoError(t, err) + assert.Equal(t, "v3 proposed body", live.Content) + + // Approving a draft NUMBERED BELOW the live version (a metadata edit + // advanced the live row past a pending draft) must set the live row to + // exactly the draft's number, not keep the higher one. + proposed.Content = "v6 draft body" + n6, err := store.CreateDraftVersion(ctx, p.ID, &proposed, "sam@example.com") + require.NoError(t, err) + loaded, err = store.Get(ctx, "sales-report") + require.NoError(t, err) + loaded.Description = "metadata edit after the draft" + require.NoError(t, store.UpdateWithVersion(ctx, loaded, "jane@example.com")) + require.Greater(t, loaded.Version, n6, "the applied metadata edit outnumbers the pending draft") + + updated, err = store.ApproveVersion(ctx, p.ID, n6, "admin2@example.com") + require.NoError(t, err) + assert.Equal(t, n6, updated.Version) + live, err = store.Get(ctx, "sales-report") + require.NoError(t, err) + assert.Equal(t, "v6 draft body", live.Content) + assert.Equal(t, n6, live.Version, "the live row reports exactly the snapshot it serves") + + // Deleting the prompt cascades its history. + require.NoError(t, store.DeleteByID(ctx, p.ID)) + versions, err = store.ListVersions(ctx, p.ID) + require.NoError(t, err) + assert.Empty(t, versions, "prompt_versions rows cascade with the prompt") +} diff --git a/pkg/prompt/postgres/version_test.go b/pkg/prompt/postgres/version_test.go new file mode 100644 index 00000000..eec78f2c --- /dev/null +++ b/pkg/prompt/postgres/version_test.go @@ -0,0 +1,568 @@ +package postgres + +import ( + "context" + "database/sql" + "database/sql/driver" + "encoding/json" + "errors" + "testing" + + sqlmock "github.com/DATA-DOG/go-sqlmock" + "github.com/lib/pq" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/txn2/mcp-data-platform/pkg/prompt" +) + +var versionSelectColumns = []string{ + "id", "prompt_id", "version", "display_name", "description", "content", + "arguments", "tags", "author", "status", "approved_by", "approved_at", "created_at", +} + +// versionRow returns a full prompt_versions row in versionColumns order. +func versionRow(id string, version int, content, status string) []any { + const promptID = "uuid-123" + return []any{ + id, promptID, version, "Test Prompt", "A test prompt", content, + []byte(`[]`), pq.Array([]string{}), "author@example.com", status, "", nil, testRowTime, + } +} + +// expectLockPrompt mocks the FOR UPDATE read of the full prompt row. +func expectLockPrompt(mock sqlmock.Sqlmock, p *prompt.Prompt, argsJSON []byte) { + mock.ExpectQuery("SELECT .+ FROM prompts WHERE id = .+ FOR UPDATE").WithArgs(p.ID). + WillReturnRows(sqlmock.NewRows(selectColumns).AddRow( + p.ID, p.Name, p.DisplayName, p.Description, p.Content, argsJSON, + p.Category, p.Scope, pq.Array(p.Personas), p.OwnerEmail, p.Source, p.Enabled, + pq.Array(p.Tags), p.Status, p.ApprovedBy, p.ApprovedAt, p.DeprecatedAt, p.SupersededBy, + p.ReviewRequested, p.RequestedScope, pq.Array(p.RequestedPersonas), p.Version, + testRowTime, testRowTime, + )) +} + +func TestUpdateWithVersion_SnapshotChangedInsertsAppliedVersion(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + store := New(db) + p := newTestPrompt() + p.ID = "uuid-123" + p.Version = 1 + // A draft shared prompt: content edits apply directly (no review gate), + // which is the legitimate content path through UpdateWithVersion — + // approved shared content only changes via ApproveVersion. + p.Status = prompt.StatusDraft + + stored := *p + stored.Content = "old content" + argsJSON, err := json.Marshal(p.Arguments) + require.NoError(t, err) + + mock.ExpectBegin() + expectLockPrompt(mock, &stored, argsJSON) + mock.ExpectQuery("SELECT GREATEST").WithArgs(p.ID). + WillReturnRows(sqlmock.NewRows([]string{"next"}).AddRow(2)) + mock.ExpectExec("INSERT INTO prompt_versions").WithArgs( + p.ID, 2, p.DisplayName, p.Description, p.Content, argsJSON, + pq.Array(p.Tags), "editor@example.com", prompt.VersionStatusApplied, "", nil, + ).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("UPDATE prompts").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + err = store.UpdateWithVersion(context.Background(), p, "editor@example.com") + assert.NoError(t, err) + assert.Equal(t, 2, p.Version, "the live row advances to the new snapshot") + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestUpdateWithVersion_NoSnapshotChangeSkipsVersionRow(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + store := New(db) + p := newTestPrompt() + p.ID = "uuid-123" + p.Version = 3 + p.Status = prompt.StatusApproved + + argsJSON, err := json.Marshal(p.Arguments) + require.NoError(t, err) + + // The stored row matches p on every snapshot field: no INSERT expected. + mock.ExpectBegin() + expectLockPrompt(mock, p, argsJSON) + mock.ExpectExec("UPDATE prompts").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + err = store.UpdateWithVersion(context.Background(), p, "editor@example.com") + assert.NoError(t, err) + assert.Equal(t, 3, p.Version, "no snapshot change leaves the version untouched") + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestUpdateWithVersion_SystemRowSkipsVersioning(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + store := New(db) + p := newTestPrompt() + p.ID = "uuid-sys" + p.Source = prompt.SourceSystem + p.Status = prompt.StatusApproved + p.Version = 1 + + stored := *p + stored.Content = "old config content" + argsJSON, err := json.Marshal(p.Arguments) + require.NoError(t, err) + + mock.ExpectBegin() + expectLockPrompt(mock, &stored, argsJSON) + mock.ExpectExec("UPDATE prompts").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + err = store.UpdateWithVersion(context.Background(), p, "ingest") + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestUpdateWithVersion_NotFound(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + store := New(db) + p := newTestPrompt() + p.ID = "missing" + + mock.ExpectBegin() + mock.ExpectQuery("SELECT .+ FROM prompts WHERE id = .+ FOR UPDATE").WithArgs(p.ID). + WillReturnRows(sqlmock.NewRows(selectColumns)) + mock.ExpectRollback() + + err = store.UpdateWithVersion(context.Background(), p, "editor@example.com") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestCreateDraftVersion_Success(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + store := New(db) + proposed := newTestPrompt() + proposed.Content = "proposed content" + argsJSON, err := json.Marshal(proposed.Arguments) + require.NoError(t, err) + + locked := newTestPrompt() + locked.ID = "uuid-123" + + mock.ExpectBegin() + expectLockPrompt(mock, locked, argsJSON) + mock.ExpectQuery("SELECT GREATEST").WithArgs("uuid-123"). + WillReturnRows(sqlmock.NewRows([]string{"next"}).AddRow(4)) + mock.ExpectExec("INSERT INTO prompt_versions").WithArgs( + "uuid-123", 4, proposed.DisplayName, proposed.Description, proposed.Content, + argsJSON, pq.Array(proposed.Tags), "author@example.com", prompt.VersionStatusDraft, "", nil, + ).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + n, err := store.CreateDraftVersion(context.Background(), "uuid-123", proposed, "author@example.com") + assert.NoError(t, err) + assert.Equal(t, 4, n) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestCreateDraftVersion_PromptNotFound(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + store := New(db) + + mock.ExpectBegin() + mock.ExpectQuery("SELECT .+ FROM prompts WHERE id = .+ FOR UPDATE").WithArgs("missing"). + WillReturnRows(sqlmock.NewRows(selectColumns)) + mock.ExpectRollback() + + _, err = store.CreateDraftVersion(context.Background(), "missing", newTestPrompt(), "a@example.com") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") + assert.NoError(t, mock.ExpectationsWereMet()) +} + +// TestCreateDraftVersion_RefusesSystemRow: system rows are read-only config +// mirrors; the store refuses drafts for them regardless of which surface asks. +func TestCreateDraftVersion_RefusesSystemRow(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + store := New(db) + locked := newTestPrompt() + locked.ID = "uuid-sys" + locked.Source = prompt.SourceSystem + argsJSON, err := json.Marshal(locked.Arguments) + require.NoError(t, err) + + mock.ExpectBegin() + expectLockPrompt(mock, locked, argsJSON) + mock.ExpectRollback() + + _, err = store.CreateDraftVersion(context.Background(), "uuid-sys", newTestPrompt(), "a@example.com") + require.ErrorIs(t, err, prompt.ErrVersionConflict) + assert.Contains(t, err.Error(), "read-only system prompt") + assert.NoError(t, mock.ExpectationsWereMet()) +} + +// TestUpdateWithVersion_RejectsRacingGatedEdit: the review gate is re-checked +// against the locked row, so an edit that raced an approval conflicts instead +// of silently replacing just-approved content. +func TestUpdateWithVersion_RejectsRacingGatedEdit(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + store := New(db) + p := newTestPrompt() + p.ID = "uuid-123" + p.Version = 1 + // The caller read the prompt as a draft (no gate) and edited content... + p.Status = prompt.StatusDraft + p.Content = "editor's new content" + + // ...but by lock time an admin approved it. + locked := *p + locked.Status = prompt.StatusApproved + locked.Content = "the approved content" + argsJSON, err := json.Marshal(p.Arguments) + require.NoError(t, err) + + mock.ExpectBegin() + expectLockPrompt(mock, &locked, argsJSON) + mock.ExpectRollback() + + err = store.UpdateWithVersion(context.Background(), p, "editor@example.com") + require.ErrorIs(t, err, prompt.ErrVersionConflict) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestListVersions_Success(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + store := New(db) + rows := sqlmock.NewRows(versionSelectColumns) + r2 := versionRow("v2", 2, "new content", prompt.VersionStatusDraft) + r1 := versionRow("v1", 1, "old content", prompt.VersionStatusApplied) + rows.AddRow(toDriverValues(r2)...) + rows.AddRow(toDriverValues(r1)...) + + mock.ExpectQuery("SELECT .+ FROM prompt_versions WHERE prompt_id = .+ ORDER BY version DESC"). + WithArgs("uuid-123").WillReturnRows(rows) + + out, err := store.ListVersions(context.Background(), "uuid-123") + assert.NoError(t, err) + require.Len(t, out, 2) + assert.Equal(t, 2, out[0].Version) + assert.Equal(t, prompt.VersionStatusDraft, out[0].Status) + assert.Equal(t, "author@example.com", out[0].Author) + assert.Equal(t, 1, out[1].Version) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestGetVersion_FoundAndNotFound(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + store := New(db) + + rows := sqlmock.NewRows(versionSelectColumns) + rows.AddRow(toDriverValues(versionRow("v1", 1, "old content", prompt.VersionStatusApplied))...) + mock.ExpectQuery("SELECT .+ FROM prompt_versions WHERE prompt_id = .+ AND version = "). + WithArgs("uuid-123", 1).WillReturnRows(rows) + + v, err := store.GetVersion(context.Background(), "uuid-123", 1) + assert.NoError(t, err) + require.NotNil(t, v) + assert.Equal(t, "old content", v.Content) + + mock.ExpectQuery("SELECT .+ FROM prompt_versions WHERE prompt_id = .+ AND version = "). + WithArgs("uuid-123", 9).WillReturnRows(sqlmock.NewRows(versionSelectColumns)) + + v, err = store.GetVersion(context.Background(), "uuid-123", 9) + assert.NoError(t, err) + assert.Nil(t, v) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestApproveVersion_AppliesSnapshotAndStamps(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + store := New(db) + live := newTestPrompt() + live.ID = "uuid-123" + live.Version = 1 + live.Status = prompt.StatusApproved + argsJSON, err := json.Marshal(live.Arguments) + require.NoError(t, err) + + mock.ExpectBegin() + expectLockPrompt(mock, live, argsJSON) + draftRows := sqlmock.NewRows(versionSelectColumns) + draftRows.AddRow(toDriverValues(versionRow("v2", 2, "draft content", prompt.VersionStatusDraft))...) + mock.ExpectQuery("SELECT .+ FROM prompt_versions WHERE prompt_id = .+ AND version = .+ FOR UPDATE"). + WithArgs("uuid-123", 2).WillReturnRows(draftRows) + mock.ExpectExec("UPDATE prompts").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("UPDATE prompt_versions SET status"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("UPDATE prompt_versions SET status"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + updated, err := store.ApproveVersion(context.Background(), "uuid-123", 2, "admin@example.com") + require.NoError(t, err) + assert.Equal(t, "draft content", updated.Content, "the draft snapshot is applied to the live row") + assert.Equal(t, 2, updated.Version) + assert.Equal(t, prompt.StatusApproved, updated.Status) + assert.Equal(t, "admin@example.com", updated.ApprovedBy) + require.NotNil(t, updated.ApprovedAt) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestApproveVersion_RejectsNonDraft(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + store := New(db) + live := newTestPrompt() + live.ID = "uuid-123" + live.Status = prompt.StatusApproved + argsJSON, err := json.Marshal(live.Arguments) + require.NoError(t, err) + + mock.ExpectBegin() + expectLockPrompt(mock, live, argsJSON) + rows := sqlmock.NewRows(versionSelectColumns) + rows.AddRow(toDriverValues(versionRow("v1", 1, "content", prompt.VersionStatusApplied))...) + mock.ExpectQuery("SELECT .+ FROM prompt_versions WHERE prompt_id = .+ AND version = .+ FOR UPDATE"). + WithArgs("uuid-123", 1).WillReturnRows(rows) + mock.ExpectRollback() + + _, err = store.ApproveVersion(context.Background(), "uuid-123", 1, "admin@example.com") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not a pending draft") + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestApproveVersion_RejectsRetiredPrompt(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + store := New(db) + live := newTestPrompt() + live.ID = "uuid-123" + live.Status = prompt.StatusDeprecated + argsJSON, err := json.Marshal(live.Arguments) + require.NoError(t, err) + + mock.ExpectBegin() + expectLockPrompt(mock, live, argsJSON) + mock.ExpectRollback() + + _, err = store.ApproveVersion(context.Background(), "uuid-123", 2, "admin@example.com") + assert.Error(t, err) + assert.Contains(t, err.Error(), "deprecated") + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestApproveVersion_MissingVersion(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + store := New(db) + live := newTestPrompt() + live.ID = "uuid-123" + live.Status = prompt.StatusApproved + argsJSON, err := json.Marshal(live.Arguments) + require.NoError(t, err) + + mock.ExpectBegin() + expectLockPrompt(mock, live, argsJSON) + mock.ExpectQuery("SELECT .+ FROM prompt_versions WHERE prompt_id = .+ AND version = .+ FOR UPDATE"). + WithArgs("uuid-123", 9).WillReturnRows(sqlmock.NewRows(versionSelectColumns)) + mock.ExpectRollback() + + _, err = store.ApproveVersion(context.Background(), "uuid-123", 9, "admin@example.com") + assert.Error(t, err) + assert.Contains(t, err.Error(), "no version 9") + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestRejectVersion_Success(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + store := New(db) + + mock.ExpectBegin() + mock.ExpectQuery("SELECT status FROM prompts WHERE id").WithArgs("uuid-123"). + WillReturnRows(sqlmock.NewRows([]string{"status"}).AddRow(prompt.StatusApproved)) + mock.ExpectExec("UPDATE prompt_versions SET status").WithArgs( + "uuid-123", 2, prompt.VersionStatusRejected, prompt.VersionStatusDraft, + ).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + err = store.RejectVersion(context.Background(), "uuid-123", 2) + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestRejectVersion_NoPendingDraft(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + store := New(db) + + mock.ExpectBegin() + mock.ExpectQuery("SELECT status FROM prompts WHERE id").WithArgs("uuid-123"). + WillReturnRows(sqlmock.NewRows([]string{"status"}).AddRow(prompt.StatusApproved)) + mock.ExpectExec("UPDATE prompt_versions SET status"). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectRollback() + + err = store.RejectVersion(context.Background(), "uuid-123", 2) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no pending draft") + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestListVersions_QueryError(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + store := New(db) + mock.ExpectQuery("SELECT .+ FROM prompt_versions").WillReturnError(errors.New("db down")) + + _, err = store.ListVersions(context.Background(), "uuid-123") + assert.Error(t, err) +} + +func TestListVersions_ScanError(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + store := New(db) + // A malformed arguments payload fails the version scanner's unmarshal. + row := versionRow("v1", 1, "content", prompt.VersionStatusApplied) + row[6] = []byte(`{not json`) + rows := sqlmock.NewRows(versionSelectColumns) + rows.AddRow(toDriverValues(row)...) + mock.ExpectQuery("SELECT .+ FROM prompt_versions").WillReturnRows(rows) + + _, err = store.ListVersions(context.Background(), "uuid-123") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unmarshal version arguments") +} + +func TestCreateDraftVersion_InsertError(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + store := New(db) + + locked := newTestPrompt() + locked.ID = "uuid-123" + lockedArgs, err := json.Marshal(locked.Arguments) + require.NoError(t, err) + + mock.ExpectBegin() + expectLockPrompt(mock, locked, lockedArgs) + mock.ExpectQuery("SELECT GREATEST").WithArgs("uuid-123"). + WillReturnRows(sqlmock.NewRows([]string{"next"}).AddRow(2)) + mock.ExpectExec("INSERT INTO prompt_versions").WillReturnError(errors.New("constraint")) + mock.ExpectRollback() + + _, err = store.CreateDraftVersion(context.Background(), "uuid-123", newTestPrompt(), "a@example.com") + assert.Error(t, err) + assert.Contains(t, err.Error(), "insert prompt version") + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestRejectVersion_PromptNotFound(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + store := New(db) + + mock.ExpectBegin() + mock.ExpectQuery("SELECT status FROM prompts WHERE id").WithArgs("missing"). + WillReturnRows(sqlmock.NewRows([]string{"status"})) + mock.ExpectRollback() + + err = store.RejectVersion(context.Background(), "missing", 2) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestRejectVersion_ExecError(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + store := New(db) + + mock.ExpectBegin() + mock.ExpectQuery("SELECT status FROM prompts WHERE id").WithArgs("uuid-123"). + WillReturnRows(sqlmock.NewRows([]string{"status"}).AddRow(prompt.StatusApproved)) + mock.ExpectExec("UPDATE prompt_versions SET status").WillReturnError(errors.New("db down")) + mock.ExpectRollback() + + err = store.RejectVersion(context.Background(), "uuid-123", 2) + assert.Error(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestWithTx_BeginError(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = db.Close() }() + + store := New(db) + mock.ExpectBegin().WillReturnError(errors.New("db down")) + + err = store.withTx(context.Background(), "test op", func(*sql.Tx) error { return nil }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "begin test op") +} + +// toDriverValues converts an []any row to sqlmock driver values. +func toDriverValues(row []any) []driver.Value { + out := make([]driver.Value, len(row)) + for i, v := range row { + out[i] = v + } + return out +} diff --git a/pkg/prompt/prompt.go b/pkg/prompt/prompt.go index a624e014..cdd676af 100644 --- a/pkg/prompt/prompt.go +++ b/pkg/prompt/prompt.go @@ -261,6 +261,18 @@ type Prompt struct { DeprecatedAt *time.Time `json:"deprecated_at,omitempty"` SupersededBy string `json:"superseded_by,omitempty" example:"daily-sales-report-v2"` + // Version is the number of the snapshot the live row currently serves + // (see VersionStore). Pending draft versions above this number exist in + // the version history but are not served until approved. + Version int `json:"version" example:"4"` + + // Serve-derived usage (see UsageReader). Populated only on read surfaces + // wired to a usage reader (manage_prompt get, the prompt usage REST + // endpoints); omitted elsewhere. Zero/absent means never served within the + // audit retention window. + RunCount int64 `json:"run_count,omitempty" example:"37"` + LastRunAt *time.Time `json:"last_run_at,omitempty"` + // Promotion request: an owner asks to move a personal prompt into a shared // scope. ReviewRequested marks the prompt as pending in the admin queue; // RequestedScope/RequestedPersonas record the target. Cleared on approve or diff --git a/pkg/prompt/version.go b/pkg/prompt/version.go new file mode 100644 index 00000000..afebe5d9 --- /dev/null +++ b/pkg/prompt/version.go @@ -0,0 +1,104 @@ +package prompt + +import ( + "context" + "errors" + "time" +) + +// ErrVersionConflict marks a version write rejected because the prompt or +// version state changed underneath the caller (a draft already resolved, a +// prompt retired, an edit racing an approval). REST handlers map it to 409; +// any other store error is an internal failure. +var ErrVersionConflict = errors.New("prompt version conflict") + +// Version status constants. A version row is the immutable snapshot of a +// prompt's reviewable substance at one mutation: +// +// - draft: a proposed edit to an approved shared prompt, awaiting admin +// approval; the live prompt row continues to serve the previously applied +// snapshot. +// - applied: the snapshot was applied to the live row (either directly, for +// edits that need no review, or via ApproveVersion). The version the live +// row currently serves is Prompt.Version; older applied rows are history. +// - superseded: a draft that was still pending when a different draft was +// approved; kept for history, never applicable. +// - rejected: a draft an admin explicitly rejected. +const ( + VersionStatusDraft = "draft" + VersionStatusApplied = "applied" + VersionStatusSuperseded = "superseded" + VersionStatusRejected = "rejected" +) + +// Version is one immutable snapshot of a prompt's versioned fields (content, +// display name, description, arguments, tags), with the author who produced it +// and the approval stamp bound to this specific version. Approval stamps on a +// version never change once set: approving v5 does not alter the recorded +// approval of v4. +type Version struct { + ID string `json:"id" example:"ver_a1b2c3d4"` + PromptID string `json:"prompt_id" example:"prompt_a1b2c3d4"` + Version int `json:"version" example:"4"` + DisplayName string `json:"display_name" example:"Daily Sales Report"` + Description string `json:"description" example:"Generate a daily sales summary by region"` + Content string `json:"content" example:"Analyze sales data for {date} grouped by region."` + Arguments []Argument `json:"arguments"` + Tags []string `json:"tags" example:"sales,reporting"` + Author string `json:"author" example:"jane@example.com"` + Status string `json:"status" example:"applied"` + ApprovedBy string `json:"approved_by,omitempty" example:"admin@example.com"` + ApprovedAt *time.Time `json:"approved_at,omitempty"` + CreatedAt time.Time `json:"created_at" example:"2026-06-12T14:30:00Z"` +} + +// VersionStore is the optional versioning capability of a prompt store. The +// PostgreSQL store implements it; a store without it (some tests) degrades to +// unversioned updates via ApplyEdit's fallback. All write methods are +// transactional with the prompts row they touch. +type VersionStore interface { + // UpdateWithVersion persists p like Store.Update and, when any versioned + // snapshot field (content, display name, description, arguments, tags) + // differs from the stored row, records a new applied version authored by + // author and advances p.Version to it. + UpdateWithVersion(ctx context.Context, p *Prompt, author string) error + + // CreateDraftVersion snapshots proposed's versioned fields as a new draft + // version of the prompt without touching the live row, returning the new + // version number. The live row continues to be served unchanged until the + // draft is approved. + CreateDraftVersion(ctx context.Context, promptID string, proposed *Prompt, author string) (int, error) + + // ListVersions returns every version of the prompt, newest first. + ListVersions(ctx context.Context, promptID string) ([]Version, error) + + // GetVersion returns one version with its full content, or nil, nil when + // the prompt has no such version. + GetVersion(ctx context.Context, promptID string, version int) (*Version, error) + + // ApproveVersion applies draft version's snapshot to the live prompt row, + // marks the prompt approved with approver's stamp, binds the same stamp to + // the version row (status applied), and supersedes any other pending + // drafts. Returns the updated prompt. + ApproveVersion(ctx context.Context, promptID string, version int, approver string) (*Prompt, error) + + // RejectVersion marks a pending draft version rejected, leaving the live + // prompt row untouched. + RejectVersion(ctx context.Context, promptID string, version int) error +} + +// Usage is the audit-derived usage rollup for one prompt: how many times it +// was served (prompts/get or manage_prompt use) within the audit retention +// window, and when it was last served. A prompt that was never served has no +// entry in a usage map. +type Usage struct { + RunCount int64 `json:"run_count" example:"37"` + LastRunAt *time.Time `json:"last_run_at,omitempty"` +} + +// UsageReader reports serve-derived usage for a set of prompt IDs. The audit +// store implements it by aggregating prompt_serve audit events; prompts absent +// from the returned map have never been served (within retention). +type UsageReader interface { + PromptUsage(ctx context.Context, promptIDs []string) (map[string]Usage, error) +} diff --git a/pkg/prompt/versionhttp/handler.go b/pkg/prompt/versionhttp/handler.go new file mode 100644 index 00000000..770b5c11 --- /dev/null +++ b/pkg/prompt/versionhttp/handler.go @@ -0,0 +1,306 @@ +// Package versionhttp exposes prompt version history, draft review actions, +// and audit-derived usage stats over REST (#1009). It serves both operator +// surfaces — the admin API (any prompt, approve/reject) and the portal API +// (own prompts, read-only history plus usage for the caller's visible set) — +// from one implementation. It lives beside pkg/prompt rather than inside +// pkg/admin or pkg/portal so those packages stay within the package-size +// budget; the composition root (internal/httpserver) mounts it under each +// surface's path prefix wrapped in that surface's own authentication +// middleware, and injects the identity accessors, so this package never +// imports either surface. +package versionhttp + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/txn2/mcp-data-platform/pkg/prompt" +) + +// Registrar refreshes a prompt's name-keyed runtime metadata after an +// approved version changes the live row. The prompt layer satisfies it. +type Registrar interface { + RegisterRuntimePrompt(p *prompt.Prompt) + UnregisterRuntimePrompt(name string) +} + +// PortalIdentity is the portal caller resolved by the injected accessor. +type PortalIdentity struct { + Email string + Persona string + IsAdmin bool +} + +// Deps carries the collaborators the version handlers need. Store and +// Versions are required; Usage is optional (audit disabled leaves usage +// empty); Registrar is optional. AdminEmail and PortalUser are the surface +// identity accessors injected by the composition root — each Register* call +// requires its accessor. +type Deps struct { + Store prompt.Store + Versions prompt.VersionStore + Usage prompt.UsageReader + Registrar Registrar + + // AdminEmail returns the authenticated admin's email for approval stamps. + AdminEmail func(r *http.Request) string + // PortalUser resolves the authenticated portal caller, or nil when the + // request carries no user. + PortalUser func(r *http.Request) *PortalIdentity +} + +// Handler serves the prompt version and usage routes. +type Handler struct { + deps Deps +} + +// New builds the handler. +func New(deps Deps) *Handler { + return &Handler{deps: deps} +} + +// Shared literals. +const ( + pathID = "id" + pathVersion = "version" + errPromptNot = "prompt not found" + errVersionNot = "version not found" + errGetPrompt = "failed to get prompt" + errListVers = "failed to list versions" +) + +// RegisterAdmin mounts the admin version routes under prefix (the admin API +// path prefix, e.g. /api/v1/admin), each wrapped in the admin auth middleware. +// The composition root registers these on the top-level mux, where their +// literal patterns take precedence over the admin subtree mount. +func (h *Handler) RegisterAdmin(mux *http.ServeMux, prefix string, wrap func(http.Handler) http.Handler) { + mux.Handle("GET "+prefix+"/prompts/usage", wrap(http.HandlerFunc(h.adminUsage))) + mux.Handle("GET "+prefix+"/prompts/{id}/versions", wrap(http.HandlerFunc(h.adminListVersions))) + mux.Handle("GET "+prefix+"/prompts/{id}/versions/{version}", wrap(http.HandlerFunc(h.adminGetVersion))) + mux.Handle("POST "+prefix+"/prompts/{id}/versions/{version}/approve", wrap(http.HandlerFunc(h.approveVersion))) + mux.Handle("POST "+prefix+"/prompts/{id}/versions/{version}/reject", wrap(http.HandlerFunc(h.rejectVersion))) +} + +// RegisterPortal mounts the portal version routes, wrapped in the portal auth +// middleware. +func (h *Handler) RegisterPortal(mux *http.ServeMux, wrap func(http.Handler) http.Handler) { + mux.Handle("GET /api/v1/portal/prompts/usage", wrap(http.HandlerFunc(h.portalUsage))) + mux.Handle("GET /api/v1/portal/prompts/{id}/versions", wrap(http.HandlerFunc(h.portalListVersions))) +} + +// versionListResponse is the version history payload. +type versionListResponse struct { + Data []prompt.Version `json:"data"` + Total int `json:"total" example:"4"` +} + +// adminListVersions returns a prompt's full version history, newest first. +// +// @Summary List prompt versions +// @Description Returns every version of a prompt with full content and author, newest first. +// @Tags Prompts +// @Produce json +// @Param id path string true "Prompt ID" +// @Success 200 {object} versionListResponse +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Security ApiKeyAuth +// @Security BearerAuth +// @Router /admin/prompts/{id}/versions [get] +func (h *Handler) adminListVersions(w http.ResponseWriter, r *http.Request) { + pr, ok := h.loadPrompt(w, r) + if !ok { + return + } + h.writeVersionList(r.Context(), w, pr.ID) +} + +// writeVersionList writes the version history for a prompt id. +func (h *Handler) writeVersionList(ctx context.Context, w http.ResponseWriter, promptID string) { + versions, err := h.deps.Versions.ListVersions(ctx, promptID) + if err != nil { + writeError(w, http.StatusInternalServerError, errListVers) + return + } + if versions == nil { + versions = []prompt.Version{} + } + writeJSON(w, http.StatusOK, versionListResponse{Data: versions, Total: len(versions)}) +} + +// adminGetVersion returns one version with its full content. +// +// @Summary Get prompt version +// @Description Returns a single prompt version snapshot. +// @Tags Prompts +// @Produce json +// @Param id path string true "Prompt ID" +// @Param version path int true "Version number" +// @Success 200 {object} prompt.Version +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Security ApiKeyAuth +// @Security BearerAuth +// @Router /admin/prompts/{id}/versions/{version} [get] +func (h *Handler) adminGetVersion(w http.ResponseWriter, r *http.Request) { + pr, ok := h.loadPrompt(w, r) + if !ok { + return + } + n, ok := versionParam(w, r) + if !ok { + return + } + v, err := h.deps.Versions.GetVersion(r.Context(), pr.ID, n) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to get version") + return + } + if v == nil { + writeError(w, http.StatusNotFound, errVersionNot) + return + } + writeJSON(w, http.StatusOK, v) +} + +// approveVersion applies a pending draft version to the live prompt. +// +// @Summary Approve prompt version +// @Description Applies a pending draft version's snapshot to the live prompt, stamping the approval on that specific version. Other pending drafts are superseded. +// @Tags Prompts +// @Produce json +// @Param id path string true "Prompt ID" +// @Param version path int true "Version number" +// @Success 200 {object} prompt.Prompt +// @Failure 404 {object} map[string]string +// @Failure 409 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Security ApiKeyAuth +// @Security BearerAuth +// @Router /admin/prompts/{id}/versions/{version}/approve [post] +func (h *Handler) approveVersion(w http.ResponseWriter, r *http.Request) { + pr, n, ok := h.loadDraft(w, r) + if !ok { + return + } + updated, err := h.deps.Versions.ApproveVersion(r.Context(), pr.ID, n, h.deps.AdminEmail(r)) + if err != nil { + // The draft state was validated above, so a conflict here is a race + // that changed it; anything else is an internal store failure whose + // detail (possibly driver text) must not reach the client. + writeVersionWriteError(w, err, "failed to approve version") + return + } + if h.deps.Registrar != nil && updated.Scope != prompt.ScopePersonal { + h.deps.Registrar.UnregisterRuntimePrompt(updated.Name) + if updated.Enabled { + h.deps.Registrar.RegisterRuntimePrompt(updated) + } + } + writeJSON(w, http.StatusOK, updated) +} + +// rejectVersion marks a pending draft version rejected. +// +// @Summary Reject prompt version +// @Description Rejects a pending draft version, leaving the live prompt unchanged. +// @Tags Prompts +// @Produce json +// @Param id path string true "Prompt ID" +// @Param version path int true "Version number" +// @Success 200 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 409 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Security ApiKeyAuth +// @Security BearerAuth +// @Router /admin/prompts/{id}/versions/{version}/reject [post] +func (h *Handler) rejectVersion(w http.ResponseWriter, r *http.Request) { + pr, n, ok := h.loadDraft(w, r) + if !ok { + return + } + if err := h.deps.Versions.RejectVersion(r.Context(), pr.ID, n); err != nil { + writeVersionWriteError(w, err, "failed to reject version") + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "rejected"}) +} + +// writeVersionWriteError maps a version-write failure: a state conflict +// (prompt.ErrVersionConflict) surfaces its message as 409, anything else is a +// 500 with a generic message so store/driver detail never reaches the client. +func writeVersionWriteError(w http.ResponseWriter, err error, public string) { + if errors.Is(err, prompt.ErrVersionConflict) { + writeError(w, http.StatusConflict, err.Error()) + return + } + writeError(w, http.StatusInternalServerError, public) +} + +// loadDraft resolves the prompt and version path params and verifies the +// version is a pending draft, writing the error response otherwise. +func (h *Handler) loadDraft(w http.ResponseWriter, r *http.Request) (*prompt.Prompt, int, bool) { + pr, ok := h.loadPrompt(w, r) + if !ok { + return nil, 0, false + } + n, ok := versionParam(w, r) + if !ok { + return nil, 0, false + } + v, err := h.deps.Versions.GetVersion(r.Context(), pr.ID, n) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to get version") + return nil, 0, false + } + if v == nil { + writeError(w, http.StatusNotFound, errVersionNot) + return nil, 0, false + } + if v.Status != prompt.VersionStatusDraft { + writeError(w, http.StatusConflict, "version "+strconv.Itoa(n)+" is "+v.Status+", not a pending draft") + return nil, 0, false + } + return pr, n, true +} + +// loadPrompt resolves the {id} path param to a stored prompt, writing the +// error response when it is missing. +func (h *Handler) loadPrompt(w http.ResponseWriter, r *http.Request) (*prompt.Prompt, bool) { + pr, err := h.deps.Store.GetByID(r.Context(), r.PathValue(pathID)) + if err != nil { + writeError(w, http.StatusInternalServerError, errGetPrompt) + return nil, false + } + if pr == nil { + writeError(w, http.StatusNotFound, errPromptNot) + return nil, false + } + return pr, true +} + +// versionParam parses the {version} path param. +func versionParam(w http.ResponseWriter, r *http.Request) (int, bool) { + n, err := strconv.Atoi(r.PathValue(pathVersion)) + if err != nil || n < 1 { + writeError(w, http.StatusNotFound, errVersionNot) + return 0, false + } + return n, true +} + +// writeJSON writes a JSON response. +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// writeError writes a JSON error response. +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} diff --git a/pkg/prompt/versionhttp/handler_test.go b/pkg/prompt/versionhttp/handler_test.go new file mode 100644 index 00000000..7eb391ce --- /dev/null +++ b/pkg/prompt/versionhttp/handler_test.go @@ -0,0 +1,448 @@ +package versionhttp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "slices" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/txn2/mcp-data-platform/pkg/prompt" +) + +// --- fakes --- + +type fakeStore struct { + prompts map[string]*prompt.Prompt // by id + getErr error + listErr error +} + +func (*fakeStore) Create(context.Context, *prompt.Prompt) error { return nil } +func (*fakeStore) Get(context.Context, string) (*prompt.Prompt, error) { + return nil, nil //nolint:nilnil // interface contract +} + +func (*fakeStore) GetPersonal(context.Context, string, string) (*prompt.Prompt, error) { + return nil, nil //nolint:nilnil // interface contract +} + +func (s *fakeStore) GetByID(_ context.Context, id string) (*prompt.Prompt, error) { + if s.getErr != nil { + return nil, s.getErr + } + return s.prompts[id], nil //nolint:nilnil // interface contract +} +func (*fakeStore) Update(context.Context, *prompt.Prompt) error { return nil } +func (*fakeStore) Delete(context.Context, string) error { return nil } +func (*fakeStore) DeleteByID(context.Context, string) error { return nil } +func (s *fakeStore) List(_ context.Context, f prompt.ListFilter) ([]prompt.Prompt, error) { + if s.listErr != nil { + return nil, s.listErr + } + var out []prompt.Prompt + for _, p := range s.prompts { + if f.Scope != "" && p.Scope != f.Scope { + continue + } + if f.OwnerEmail != "" && p.OwnerEmail != f.OwnerEmail { + continue + } + if f.ExcludeSource != "" && p.Source == f.ExcludeSource { + continue + } + if len(f.Personas) > 0 { + match := false + for _, per := range f.Personas { + if slices.Contains(p.Personas, per) { + match = true + } + } + if !match { + continue + } + } + out = append(out, *p) + } + return out, nil +} +func (*fakeStore) Count(context.Context, prompt.ListFilter) (int, error) { return 0, nil } + +type fakeVersions struct { + versions map[string][]prompt.Version // by prompt id + listErr error + getErr error + approveErr error + rejectErr error + approved []int + rejected []int +} + +func (*fakeVersions) UpdateWithVersion(context.Context, *prompt.Prompt, string) error { return nil } +func (*fakeVersions) CreateDraftVersion(context.Context, string, *prompt.Prompt, string) (int, error) { + return 0, nil +} + +func (v *fakeVersions) ListVersions(_ context.Context, promptID string) ([]prompt.Version, error) { + if v.listErr != nil { + return nil, v.listErr + } + return v.versions[promptID], nil +} + +func (v *fakeVersions) GetVersion(_ context.Context, promptID string, n int) (*prompt.Version, error) { + if v.getErr != nil { + return nil, v.getErr + } + for _, ver := range v.versions[promptID] { + if ver.Version == n { + return &ver, nil + } + } + return nil, nil //nolint:nilnil // interface contract +} + +func (v *fakeVersions) ApproveVersion(_ context.Context, promptID string, n int, approver string) (*prompt.Prompt, error) { + if v.approveErr != nil { + return nil, v.approveErr + } + v.approved = append(v.approved, n) + now := time.Now().UTC() + return &prompt.Prompt{ + ID: promptID, Name: "report", Scope: prompt.ScopeGlobal, Enabled: true, + Version: n, Status: prompt.StatusApproved, ApprovedBy: approver, ApprovedAt: &now, + }, nil +} + +func (v *fakeVersions) RejectVersion(_ context.Context, _ string, n int) error { + if v.rejectErr != nil { + return v.rejectErr + } + v.rejected = append(v.rejected, n) + return nil +} + +type fakeRegistrar struct { + registered []string + unregistered []string +} + +func (r *fakeRegistrar) RegisterRuntimePrompt(p *prompt.Prompt) { + r.registered = append(r.registered, p.Name) +} + +func (r *fakeRegistrar) UnregisterRuntimePrompt(name string) { + r.unregistered = append(r.unregistered, name) +} + +type fakeUsage struct { + usage map[string]prompt.Usage + gotIDs []string + err error +} + +func (u *fakeUsage) PromptUsage(_ context.Context, ids []string) (map[string]prompt.Usage, error) { + u.gotIDs = ids + return u.usage, u.err +} + +// passthrough is the identity middleware used in place of the surface auth. +func passthrough(h http.Handler) http.Handler { return h } + +// newTestMux builds a handler over the fakes with both surfaces registered. +func newTestMux(t *testing.T, deps Deps) *http.ServeMux { + t.Helper() + if deps.AdminEmail == nil { + deps.AdminEmail = func(*http.Request) string { return "admin@example.com" } + } + mux := http.NewServeMux() + h := New(deps) + h.RegisterAdmin(mux, "/api/v1/admin", passthrough) + h.RegisterPortal(mux, passthrough) + return mux +} + +// fixture bundles the seeded fakes with the deps built over them. +type fixture struct { + deps Deps + store *fakeStore + versions *fakeVersions + reg *fakeRegistrar +} + +func seededDeps() fixture { + store := &fakeStore{prompts: map[string]*prompt.Prompt{ + "p1": { + ID: "p1", Name: "report", Scope: prompt.ScopeGlobal, Enabled: true, + Status: prompt.StatusApproved, Version: 1, + }, + "p2": { + ID: "p2", Name: "mine", Scope: prompt.ScopePersonal, OwnerEmail: "sarah@example.com", + Enabled: true, Status: prompt.StatusDraft, Version: 1, + }, + }} + versions := &fakeVersions{versions: map[string][]prompt.Version{ + "p1": { + {ID: "v2", PromptID: "p1", Version: 2, Content: "draft", Status: prompt.VersionStatusDraft, Author: "jane@example.com"}, + {ID: "v1", PromptID: "p1", Version: 1, Content: "live", Status: prompt.VersionStatusApplied, Author: "jane@example.com"}, + }, + }} + reg := &fakeRegistrar{} + return fixture{ + deps: Deps{Store: store, Versions: versions, Registrar: reg}, + store: store, + versions: versions, + reg: reg, + } +} + +func doReq(t *testing.T, mux *http.ServeMux, method, path string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), method, path, http.NoBody)) + return rec +} + +func TestAdminListVersions(t *testing.T) { + deps := seededDeps().deps + mux := newTestMux(t, deps) + + rec := doReq(t, mux, http.MethodGet, "/api/v1/admin/prompts/p1/versions") + require.Equal(t, http.StatusOK, rec.Code) + var out versionListResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out)) + require.Equal(t, 2, out.Total) + assert.Equal(t, 2, out.Data[0].Version, "newest first") + assert.Equal(t, "jane@example.com", out.Data[0].Author) + assert.Equal(t, "draft", out.Data[0].Content, "versions carry full content") + + rec = doReq(t, mux, http.MethodGet, "/api/v1/admin/prompts/missing/versions") + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestAdminGetVersion(t *testing.T) { + deps := seededDeps().deps + mux := newTestMux(t, deps) + + rec := doReq(t, mux, http.MethodGet, "/api/v1/admin/prompts/p1/versions/1") + require.Equal(t, http.StatusOK, rec.Code) + var v prompt.Version + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &v)) + assert.Equal(t, "live", v.Content) + + assert.Equal(t, http.StatusNotFound, doReq(t, mux, http.MethodGet, "/api/v1/admin/prompts/p1/versions/9").Code) + assert.Equal(t, http.StatusNotFound, doReq(t, mux, http.MethodGet, "/api/v1/admin/prompts/p1/versions/zero").Code) +} + +func TestApproveVersion(t *testing.T) { + fx := seededDeps() + deps, versions, reg := fx.deps, fx.versions, fx.reg + mux := newTestMux(t, deps) + + rec := doReq(t, mux, http.MethodPost, "/api/v1/admin/prompts/p1/versions/2/approve") + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + var out prompt.Prompt + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out)) + assert.Equal(t, 2, out.Version) + assert.Equal(t, "admin@example.com", out.ApprovedBy) + assert.Equal(t, []int{2}, versions.approved) + assert.Equal(t, []string{"report"}, reg.unregistered, "runtime metadata refreshed") + assert.Equal(t, []string{"report"}, reg.registered) + + // An already-applied version is not approvable. + assert.Equal(t, http.StatusConflict, doReq(t, mux, http.MethodPost, "/api/v1/admin/prompts/p1/versions/1/approve").Code) + // A missing version 404s. + assert.Equal(t, http.StatusNotFound, doReq(t, mux, http.MethodPost, "/api/v1/admin/prompts/p1/versions/9/approve").Code) +} + +func TestApproveVersion_StoreConflictSurfaces(t *testing.T) { + fx := seededDeps() + deps, versions := fx.deps, fx.versions + versions.approveErr = fmt.Errorf("cannot approve a version of a deprecated prompt: %w", prompt.ErrVersionConflict) + mux := newTestMux(t, deps) + + rec := doReq(t, mux, http.MethodPost, "/api/v1/admin/prompts/p1/versions/2/approve") + assert.Equal(t, http.StatusConflict, rec.Code) + assert.Contains(t, rec.Body.String(), "deprecated") +} + +// A non-conflict store failure on approve is a 500 with a generic message: +// driver detail never reaches the client. +func TestApproveVersion_StoreFailureIs500Generic(t *testing.T) { + fx := seededDeps() + deps, versions := fx.deps, fx.versions + versions.approveErr = errors.New("dial tcp 10.0.0.5:5432: connection refused") + mux := newTestMux(t, deps) + + rec := doReq(t, mux, http.MethodPost, "/api/v1/admin/prompts/p1/versions/2/approve") + assert.Equal(t, http.StatusInternalServerError, rec.Code) + assert.NotContains(t, rec.Body.String(), "dial tcp") +} + +func TestRejectVersion(t *testing.T) { + fx := seededDeps() + deps, versions := fx.deps, fx.versions + mux := newTestMux(t, deps) + + rec := doReq(t, mux, http.MethodPost, "/api/v1/admin/prompts/p1/versions/2/reject") + require.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, []int{2}, versions.rejected) + + assert.Equal(t, http.StatusConflict, doReq(t, mux, http.MethodPost, "/api/v1/admin/prompts/p1/versions/1/reject").Code) +} + +func TestAdminUsage(t *testing.T) { + deps := seededDeps().deps + last := time.Date(2026, 7, 20, 9, 0, 0, 0, time.UTC) + usage := &fakeUsage{usage: map[string]prompt.Usage{"p1": {RunCount: 12, LastRunAt: &last}}} + deps.Usage = usage + mux := newTestMux(t, deps) + + rec := doReq(t, mux, http.MethodGet, "/api/v1/admin/prompts/usage") + require.Equal(t, http.StatusOK, rec.Code) + var out map[string]prompt.Usage + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out)) + assert.Equal(t, int64(12), out["p1"].RunCount) + assert.ElementsMatch(t, []string{"p1", "p2"}, usage.gotIDs) +} + +func TestAdminUsage_NoReaderReturnsEmpty(t *testing.T) { + deps := seededDeps().deps + mux := newTestMux(t, deps) + + rec := doReq(t, mux, http.MethodGet, "/api/v1/admin/prompts/usage") + require.Equal(t, http.StatusOK, rec.Code) + assert.JSONEq(t, "{}", rec.Body.String()) +} + +func TestPortalListVersions_Authorization(t *testing.T) { + deps := seededDeps().deps + + // No identity: 401. + deps.PortalUser = func(*http.Request) *PortalIdentity { return nil } + mux := newTestMux(t, deps) + assert.Equal(t, http.StatusUnauthorized, doReq(t, mux, http.MethodGet, "/api/v1/portal/prompts/p2/versions").Code) + + // The owner reads their own history. + deps.PortalUser = func(*http.Request) *PortalIdentity { + return &PortalIdentity{Email: "sarah@example.com"} + } + mux = newTestMux(t, deps) + assert.Equal(t, http.StatusOK, doReq(t, mux, http.MethodGet, "/api/v1/portal/prompts/p2/versions").Code) + // But not a shared prompt's history (admin curation surface). + assert.Equal(t, http.StatusForbidden, doReq(t, mux, http.MethodGet, "/api/v1/portal/prompts/p1/versions").Code) + + // A non-owner is denied; an admin is not. + deps.PortalUser = func(*http.Request) *PortalIdentity { + return &PortalIdentity{Email: "bob@example.com"} + } + mux = newTestMux(t, deps) + assert.Equal(t, http.StatusForbidden, doReq(t, mux, http.MethodGet, "/api/v1/portal/prompts/p2/versions").Code) + + deps.PortalUser = func(*http.Request) *PortalIdentity { + return &PortalIdentity{Email: "root@example.com", IsAdmin: true} + } + mux = newTestMux(t, deps) + assert.Equal(t, http.StatusOK, doReq(t, mux, http.MethodGet, "/api/v1/portal/prompts/p1/versions").Code) +} + +func TestPortalUsage_ScopesToVisiblePrompts(t *testing.T) { + fx := seededDeps() + deps, store := fx.deps, fx.store + store.prompts["p3"] = &prompt.Prompt{ + ID: "p3", Name: "team", Scope: prompt.ScopePersona, + Personas: []string{"analyst"}, Enabled: true, + } + store.prompts["p4"] = &prompt.Prompt{ + ID: "p4", Name: "other", Scope: prompt.ScopePersonal, + OwnerEmail: "someone-else@example.com", Enabled: true, + } + usage := &fakeUsage{usage: map[string]prompt.Usage{}} + deps.Usage = usage + deps.PortalUser = func(*http.Request) *PortalIdentity { + return &PortalIdentity{Email: "sarah@example.com", Persona: "analyst"} + } + mux := newTestMux(t, deps) + + rec := doReq(t, mux, http.MethodGet, "/api/v1/portal/prompts/usage") + require.Equal(t, http.StatusOK, rec.Code) + assert.ElementsMatch(t, []string{"p1", "p2", "p3"}, usage.gotIDs, + "own personal + global + persona prompts; never another user's personal prompt") +} + +func TestPortalUsage_AdminSeesAll(t *testing.T) { + deps := seededDeps().deps + usage := &fakeUsage{usage: map[string]prompt.Usage{}} + deps.Usage = usage + deps.PortalUser = func(*http.Request) *PortalIdentity { + return &PortalIdentity{Email: "root@example.com", IsAdmin: true} + } + mux := newTestMux(t, deps) + + rec := doReq(t, mux, http.MethodGet, "/api/v1/portal/prompts/usage") + require.Equal(t, http.StatusOK, rec.Code) + assert.ElementsMatch(t, []string{"p1", "p2"}, usage.gotIDs) +} + +func TestPortalUsage_Unauthorized(t *testing.T) { + deps := seededDeps().deps + deps.PortalUser = func(*http.Request) *PortalIdentity { return nil } + mux := newTestMux(t, deps) + assert.Equal(t, http.StatusUnauthorized, doReq(t, mux, http.MethodGet, "/api/v1/portal/prompts/usage").Code) +} + +func TestUsage_ReaderErrorIs500(t *testing.T) { + deps := seededDeps().deps + deps.Usage = &fakeUsage{err: errors.New("db down")} + mux := newTestMux(t, deps) + assert.Equal(t, http.StatusInternalServerError, doReq(t, mux, http.MethodGet, "/api/v1/admin/prompts/usage").Code) +} + +// Store and version-store failures surface as 500s on every read route, and +// as a conflict on a reject that fails after validation. +func TestStoreErrorsSurfaceAs500(t *testing.T) { + fx := seededDeps() + deps, store, versions := fx.deps, fx.store, fx.versions + mux := newTestMux(t, deps) + + versions.listErr = errors.New("db down") + assert.Equal(t, http.StatusInternalServerError, doReq(t, mux, http.MethodGet, "/api/v1/admin/prompts/p1/versions").Code) + versions.listErr = nil + + versions.getErr = errors.New("db down") + assert.Equal(t, http.StatusInternalServerError, doReq(t, mux, http.MethodGet, "/api/v1/admin/prompts/p1/versions/1").Code) + assert.Equal(t, http.StatusInternalServerError, doReq(t, mux, http.MethodPost, "/api/v1/admin/prompts/p1/versions/2/approve").Code) + versions.getErr = nil + + store.getErr = errors.New("db down") + assert.Equal(t, http.StatusInternalServerError, doReq(t, mux, http.MethodGet, "/api/v1/admin/prompts/p1/versions").Code) + store.getErr = nil + + store.listErr = errors.New("db down") + assert.Equal(t, http.StatusInternalServerError, doReq(t, mux, http.MethodGet, "/api/v1/admin/prompts/usage").Code) + store.listErr = nil + + versions.rejectErr = fmt.Errorf("draft changed underneath: %w", prompt.ErrVersionConflict) + assert.Equal(t, http.StatusConflict, doReq(t, mux, http.MethodPost, "/api/v1/admin/prompts/p1/versions/2/reject").Code) + versions.rejectErr = errors.New("dial tcp: connection refused") + assert.Equal(t, http.StatusInternalServerError, doReq(t, mux, http.MethodPost, "/api/v1/admin/prompts/p1/versions/2/reject").Code) +} + +func TestPortalUsage_ListErrorIs500(t *testing.T) { + fx := seededDeps() + deps, store := fx.deps, fx.store + deps.Usage = &fakeUsage{usage: map[string]prompt.Usage{}} + deps.PortalUser = func(*http.Request) *PortalIdentity { + return &PortalIdentity{Email: "sarah@example.com", Persona: "analyst"} + } + store.listErr = errors.New("db down") + mux := newTestMux(t, deps) + assert.Equal(t, http.StatusInternalServerError, doReq(t, mux, http.MethodGet, "/api/v1/portal/prompts/usage").Code) +} diff --git a/pkg/prompt/versionhttp/usage.go b/pkg/prompt/versionhttp/usage.go new file mode 100644 index 00000000..8ede1f7b --- /dev/null +++ b/pkg/prompt/versionhttp/usage.go @@ -0,0 +1,148 @@ +package versionhttp + +import ( + "context" + "net/http" + + "github.com/txn2/mcp-data-platform/pkg/prompt" +) + +// adminUsage returns the audit-derived usage rollup for every database prompt. +// +// @Summary Prompt usage stats +// @Description Returns run count and last-run timestamp per prompt id, aggregated from prompt_serve audit events within the audit retention window. Prompts never served are absent from the map. +// @Tags Prompts +// @Produce json +// @Success 200 {object} map[string]prompt.Usage +// @Failure 500 {object} map[string]string +// @Security ApiKeyAuth +// @Security BearerAuth +// @Router /admin/prompts/usage [get] +func (h *Handler) adminUsage(w http.ResponseWriter, r *http.Request) { + // System rows are read-only config mirrors; their ids are excluded like + // the admin prompt list excludes them. + ids, err := h.promptIDs(r.Context(), prompt.ListFilter{ExcludeSource: prompt.SourceSystem}) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list prompts") + return + } + h.writeUsage(r.Context(), w, ids) +} + +// portalUsage returns the usage rollup for the prompts visible to the caller: +// their own personal prompts, global prompts, and their persona's prompts. +// +// @Summary Prompt usage stats (portal) +// @Description Returns run count and last-run timestamp per prompt id for the prompts visible to the caller. +// @Tags Prompts +// @Produce json +// @Success 200 {object} map[string]prompt.Usage +// @Failure 401 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Security ApiKeyAuth +// @Security BearerAuth +// @Router /portal/prompts/usage [get] +func (h *Handler) portalUsage(w http.ResponseWriter, r *http.Request) { + user := h.deps.PortalUser(r) + if user == nil { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + ids, err := h.visiblePromptIDs(r.Context(), user) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list prompts") + return + } + h.writeUsage(r.Context(), w, ids) +} + +// portalListVersions returns a prompt's version history to its owner (or an +// admin). Shared-scope history is an admin curation surface; non-admin viewers +// see only the served version. +// +// @Summary List prompt versions (portal) +// @Description Returns the version history of a prompt the caller owns; admins may read any prompt's history. +// @Tags Prompts +// @Produce json +// @Param id path string true "Prompt ID" +// @Success 200 {object} versionListResponse +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Security ApiKeyAuth +// @Security BearerAuth +// @Router /portal/prompts/{id}/versions [get] +func (h *Handler) portalListVersions(w http.ResponseWriter, r *http.Request) { + user := h.deps.PortalUser(r) + if user == nil { + writeError(w, http.StatusUnauthorized, "authentication required") + return + } + pr, ok := h.loadPrompt(w, r) + if !ok { + return + } + if !user.IsAdmin && (pr.Scope != prompt.ScopePersonal || pr.OwnerEmail != user.Email) { + writeError(w, http.StatusForbidden, "only the owner or an admin can view a prompt's version history") + return + } + h.writeVersionList(r.Context(), w, pr.ID) +} + +// writeUsage writes the usage map for the given prompt ids. Without a usage +// reader (audit disabled) the map is empty: no serve events exist to count. +func (h *Handler) writeUsage(ctx context.Context, w http.ResponseWriter, ids []string) { + usage := map[string]prompt.Usage{} + if h.deps.Usage != nil && len(ids) > 0 { + var err error + usage, err = h.deps.Usage.PromptUsage(ctx, ids) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to read prompt usage") + return + } + } + writeJSON(w, http.StatusOK, usage) +} + +// promptIDs lists the ids of enabled and disabled prompts matching the filter. +func (h *Handler) promptIDs(ctx context.Context, filter prompt.ListFilter) ([]string, error) { + prompts, err := h.deps.Store.List(ctx, filter) + if err != nil { + return nil, err //nolint:wrapcheck // callers map any store failure to one HTTP error + } + ids := make([]string, 0, len(prompts)) + for i := range prompts { + ids = append(ids, prompts[i].ID) + } + return ids, nil +} + +// visiblePromptIDs collects the ids of the prompts the portal caller can see: +// their own personal prompts, global prompts, and their persona's prompts — +// the same enabled-only scope the portal prompt list serves, so a disabled +// prompt's usage is as invisible as the prompt itself. Admins get every +// non-system prompt. +func (h *Handler) visiblePromptIDs(ctx context.Context, user *PortalIdentity) ([]string, error) { + if user.IsAdmin { + return h.promptIDs(ctx, prompt.ListFilter{ExcludeSource: prompt.SourceSystem}) + } + enabled := true + ids, err := h.promptIDs(ctx, prompt.ListFilter{Scope: prompt.ScopePersonal, OwnerEmail: user.Email, Enabled: &enabled}) + if err != nil { + return nil, err + } + globals, err := h.promptIDs(ctx, prompt.ListFilter{Scope: prompt.ScopeGlobal, ExcludeSource: prompt.SourceSystem, Enabled: &enabled}) + if err != nil { + return nil, err + } + ids = append(ids, globals...) + if user.Persona != "" { + personas, err := h.promptIDs(ctx, prompt.ListFilter{Scope: prompt.ScopePersona, Personas: []string{user.Persona}, Enabled: &enabled}) + if err != nil { + return nil, err + } + ids = append(ids, personas...) + } + return ids, nil +} diff --git a/testdata/allowed_internal_imports.txt b/testdata/allowed_internal_imports.txt index 84cf5cb7..71e30ebe 100644 --- a/testdata/allowed_internal_imports.txt +++ b/testdata/allowed_internal_imports.txt @@ -25,6 +25,8 @@ internal/httpserver -> pkg/portal internal/httpserver -> pkg/portal/datahubapi internal/httpserver -> pkg/portal/shareaccess internal/httpserver -> pkg/portal/shareguest +internal/httpserver -> pkg/prompt +internal/httpserver -> pkg/prompt/versionhttp internal/httpserver -> pkg/ratelimit internal/httpserver -> pkg/registry internal/httpserver -> pkg/resource @@ -87,6 +89,7 @@ internal/platform/portalstore -> pkg/embedding internal/platform/portalstore -> pkg/portal internal/platform/portalstore -> pkg/portal/knowledgepage internal/platform/portalstore -> pkg/toolkits/portal +internal/platform/promptlayer -> pkg/audit internal/platform/promptlayer -> pkg/embedding internal/platform/promptlayer -> pkg/middleware internal/platform/promptlayer -> pkg/portal @@ -161,6 +164,7 @@ pkg/admin -> pkg/toolkits/knowledge pkg/admin -> pkg/user pkg/audit -> pkg/observability pkg/audit/postgres -> pkg/audit +pkg/audit/postgres -> pkg/prompt pkg/auth -> internal/logsan pkg/auth -> pkg/middleware pkg/auth -> pkg/oauth/signkey @@ -336,6 +340,7 @@ pkg/prompt/postgres -> pkg/indexjobs pkg/prompt/postgres -> pkg/prompt pkg/prompt/promptindex -> pkg/indexjobs pkg/prompt/promptindex -> pkg/prompt +pkg/prompt/versionhttp -> pkg/prompt pkg/query/trino -> pkg/observability pkg/query/trino -> pkg/query pkg/query/trino -> pkg/urnbuild