Skip to content
Merged
5 changes: 5 additions & 0 deletions docs/openapi/prompts-v2-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,11 @@ v2-prompts-by-brand-and-id:
tags:
- customer-config
summary: Update a single prompt
description: >-
Updates a single prompt. `origin` is NOT patchable — it is fixed by the
writer that created the row and is never re-derived on update
(origin-dimension.md §3 item 3); an `origin` in the body is ignored,
leaving the stored value untouched.
operationId: updatePromptByBrandAndId
security:
- ims_key: [ ]
Expand Down
13 changes: 13 additions & 0 deletions docs/openapi/schemas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6510,6 +6510,12 @@ V2PromptListResponse:
V2Prompt:
type: object
description: A prompt with brand, category, and topic enrichment
# FIX (MysticatBot suggestion): `origin` is required (non-nullable) in the
# response — it is NOT NULL in production and `mapRowToPrompt` returns it
# verbatim with no fallback (origin-dimension.md §2.3 / §3 item 4), so
# SDK-generated types must reflect it as always-present, never optional.
required:
- origin

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix (suggestion): origin now required on the V2Prompt response schema. origin is NOT NULL in production and mapRowToPrompt returns it verbatim with no fallback (origin-dimension.md §2.3 / §3 item 4), so SDK-generated types now reflect it as always-present rather than optional.

properties:
id:
type: string
Expand Down Expand Up @@ -6643,6 +6649,13 @@ V2PromptInput:
type: string
enum: [ai, human]
default: human
description: >-
Who authored the prompt's text. SERVICE-PRINCIPAL-ONLY and read-only for

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

origin retained on V2PromptInput (enum [ai,human], default human) with the service-principal-only description — NOT removed. This is the DRS guard: the service principal must still be able to assert origin:'ai', which deriveV2PromptOrigin honours. Correct per the lens.

end users: a user-authenticated request (IMS/JWT) has this field IGNORED
(never rejected) and the value derived as `human`; only a service
principal (e.g. the generation pipeline) may assert it, validated against
the enum. It is never patched on update — it is fixed by the writer that
created the row (origin-dimension.md §3).
source:
type: string
description: The source system or process that created the prompt
Expand Down
29 changes: 28 additions & 1 deletion src/controllers/brands.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
getPromptStats,
resolveBrandUuid,
findPromptsBlockingRegionRemoval,
deriveV2PromptOrigin,
} from '../support/prompts-storage.js';
import {
listBrands,
Expand Down Expand Up @@ -572,10 +573,36 @@ function BrandsController(ctx, log, env) {
return notFound(`Brand not found: ${brandId}`);
}

// `origin` is derived from the request PRINCIPAL, never trusted from the
// body (origin-dimension.md §3): a user (IMS/JWT) write is `human`, body
// ignored; a service principal (e.g. DRS via admin x-api-key, whose auth
// type is neither `ims` nor `jwt`) is believed. The auth type is read from
// the per-request context — the same source as `updatedBy` above — so it
// reflects the actual caller. Stamp it here so the store writes the derived
// value on insert; on update the stored origin is preserved (upsertPrompts)
// and never patched (updatePromptById).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (blocking): getType?.() optional chaining means that if authInfo is absent or getType is not a function, authType evaluates to undefined, making isUserPrincipal = false. This treats the request as a service principal, granting it the ability to assert arbitrary origin values.

The auth middleware (authWrapper) currently prevents unauthenticated requests from reaching this handler, so this is not exploitable today. However, the fail-open stance is a defense-in-depth concern: any future code path that calls this controller without the auth wrapper (e.g. an internal queue consumer, a new middleware ordering) would silently grant service-principal privilege.

Consider defaulting to user-principal when auth type is indeterminate:

const isUserPrincipal = !authType || authType === 'jwt' || authType === 'ims';

Or add an explicit guard that returns an error when authType is falsy, making the security boundary self-documenting and fail-closed regardless of upstream wrapper behavior.

//
// Fail SAFE to the least-privileged (USER) principal: an ABSENT or
// indeterminate auth type must NEVER fall through to the privileged service
// path that honours a body-supplied `origin`. Only a KNOWN non-user auth
// type (jwt/ims are user; anything else, e.g. DRS admin x-api-key, is
// service) is trusted as a service principal. `authWrapper` blocks
// unauthenticated requests today, but a future unwrapped caller (an internal
// queue consumer, re-ordered middleware) must not silently gain service
// privilege — hence `!authType → user`, and a non-function `getType` resolves
// to `undefined` (→ user) rather than throwing.
const { authInfo } = context.attributes ?? {};
const authType = typeof authInfo?.getType === 'function' ? authInfo.getType() : undefined;
const isUserPrincipal = !authType || authType === 'jwt' || authType === 'ims';
const derivedPrompts = prompts.map((p) => ({
...p,
origin: deriveV2PromptOrigin(p?.origin, isUserPrincipal),
}));

const { created, updated, prompts: outPrompts } = await upsertPrompts({
organizationId: spaceCatId,
brandUuid,
prompts,
prompts: derivedPrompts,
postgrestClient,
updatedBy,
classifyIntent: classifyIntent ?? undefined,
Expand Down
76 changes: 71 additions & 5 deletions src/support/prompts-storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,45 @@ import { INTENT_VALUES, normalizeIntent } from './intent.js';
// import cycle. Existing importers of these from `prompts-storage.js` keep working.
export { INTENT_VALUES, normalizeIntent };

/**
* The closed `origin` vocabulary — who authored the prompt's text
* (origin-dimension.md §1). Matches the `category_origin` enum on `prompts.origin`.
*/
export const V2_PROMPT_ORIGINS = Object.freeze(['ai', 'human']);
const DEFAULT_ORIGIN = 'human';

/**
* Derives the `origin` to store for a v2-prompts write, as a function of the
* request PRINCIPAL, never of the caller-supplied body value (origin-dimension.md
* §3). `origin` records who authored the prompt's text and is read-only wherever a
* user can reach it:
*
* - a USER-authenticated principal (IMS / JWT) always writes `human`; any
* `origin` in the body is IGNORED (never rejected — the derived value is
* authoritative, so the caller loses nothing);
* - a SERVICE principal (e.g. DRS via admin `x-api-key`) is believed: its body
* value is honoured, validated against {@link V2_PROMPT_ORIGINS}, defaulting
* to `human` only when absent or out-of-vocabulary. This is the DRS contract
* (`origin: 'ai'`); dropping it would relabel every generated prompt `human`
* on its next upsert (origin-dimension.md §3 consequence 1).
*
* This governs CREATE only — `origin` is never patched on update (it is fixed by
* the writer that created the row), which the update path enforces by not writing
* the column at all.
*
* @param {unknown} bodyOrigin - the caller-supplied `origin`, or undefined.
* @param {boolean} isUserPrincipal - true for an IMS/JWT user request.
* @returns {string} the origin to store (`ai` or `human`).
*/
export function deriveV2PromptOrigin(bodyOrigin, isUserPrincipal) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deriveV2PromptOrigin: user principal → always human (body ignored, never rejected); service principal → body value honoured, validated against V2_PROMPT_ORIGINS, defaulting to human only when absent/out-of-vocabulary. Correct DRS contract — an out-of-vocab service value coerces rather than 400s, and a user's assertion is silently overridden. Directly unit-tested in prompts-storage.test.js.

if (isUserPrincipal) {
return DEFAULT_ORIGIN;
}
return V2_PROMPT_ORIGINS.includes(/** @type {string} */ (bodyOrigin))
? /** @type {string} */ (bodyOrigin)
: DEFAULT_ORIGIN;
}

/**
* Per-client cache of whether `prompts.intent` is selectable/writable. Keyed by
* the PostgREST client so unit tests (fresh mock clients) never bleed state and
Expand Down Expand Up @@ -406,7 +445,16 @@ function mapRowToPrompt(row) {
name: row.name,
regions: row.regions || [],
status: row.status || 'active',
origin: row.origin || 'human',
// Return the stored `origin` verbatim — deliberately NO `|| 'human'` AND no
// `?? 'human'` fallback (origin-dimension.md §WP-O2b item 4 / §2.3).
// INVARIANT: `prompts.origin` is NOT NULL in production (zero NULLs in
// 265,980 rows, §2.3). Any fallback — including nullish-coalescing, which
// masks NULL exactly as `||` masks it for a NULL — would silently mislabel a
// model-written (`ai`) prompt as `human` were a NULL ever present, the exact
// corruption this dimension exists to prevent. Surfacing the raw value is the
// fail-loud choice over a fabricated `human`; unlike `source`/`status`, whose
// fallbacks are cosmetic, an origin fallback is a correctness hazard.
origin: row.origin,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

origin: row.origin with no || 'human' fallback — correct: the column is NOT NULL in prod, and a fallback would mislabel a model-written row as human were a NULL ever present. Paired with the match.origin ?? row.origin preserve on update (line 921) so a later user-principal write can never relabel a stored ai.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (blocking): Removing row.origin || 'human' in favor of row.origin means that if the DB ever returns a row with NULL origin (race with a migration, test DB, staging env with stale data), the API response will surface null/undefined to consumers. The OpenAPI schema documents origin with enum: [ai, human] and default: human - consumers may depend on this always being a non-null string.

The PR correctly notes zero NULLs in 265,980 production rows. However, the other fields in the same function (source: row.source || 'config', status: row.status || 'active') retain defensive fallbacks for the same reason.

Consider using nullish coalescing instead: origin: row.origin ?? 'human'. This preserves the "no silent mislabeling" goal (since 'ai' is truthy and passes through both || and ??) while honoring the response schema contract on edge-case NULLs. Alternatively, add a NOT NULL database constraint (if not already present) to make the invariant enforceable at the storage layer.

source: row.source || 'config',
intent: row.intent ?? null,
createdAt: row.created_at,
Expand Down Expand Up @@ -850,6 +898,12 @@ export async function upsertPrompts({
status: 'active',
intent: row.intent ?? match.intent,
source: match.source ?? source,
// `origin` is fixed by the writer that created the row and is never
// re-derived on a later write (origin-dimension.md §3): preserve the
// stored value across a reactivation. `?? row.origin` is a defensive
// fallback for an in-memory/test match without an origin, mirroring
// `source` above — not a backfill path (prod has zero NULL origins).
origin: match.origin ?? row.origin,
};
toUpdate.push(reactivated);
processed.push({ ...reactivated, prompt_id: promptId });
Expand All @@ -859,7 +913,18 @@ export async function upsertPrompts({
}

if (match) {
const updated = { ...row, id: match.id, source: match.source ?? source };
// `source` AND `origin` are both immutable on an UPDATE: source names the
// producing system, origin names the writer that created the row, and
// neither is re-derived on a later write (origin-dimension.md §3). Preserve
// the stored values so a user-principal derive of `human` cannot relabel an
// existing `ai` prompt. `?? row.*` is the same defensive in-memory/test
// fallback used for `source` — not a backfill.
const updated = {
...row,
id: match.id,
source: match.source ?? source,
origin: match.origin ?? row.origin,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update-path preserve: origin: match.origin ?? row.origin keeps the stored origin across a reactivation/update — origin is fixed by the writer that created the row and never re-derived. The ?? row.origin is a defensive in-memory/test fallback, not a backfill (prod has zero NULL origins). Consistent with updatePromptById not patching origin at all.

};
toUpdate.push(updated);
processed.push({ ...updated, prompt_id: promptId });
} else {
Expand Down Expand Up @@ -1050,9 +1115,10 @@ export async function updatePromptById({
if (updates.status !== undefined) {
patch.status = updates.status;
}
if (updates.origin !== undefined) {
patch.origin = updates.origin;
}
// `origin` is deliberately NOT patchable: it is fixed by the writer that
// created the row and is never re-derived on update (origin-dimension.md §3
// item 3 / §1 item 5). A caller-supplied `origin` in the PATCH body is ignored,
// leaving the stored value — including an `ai` prompt's — untouched.
if (updates.intent !== undefined) {
// The shared fallback strips intent when the column is known-absent.
patch.intent = normalizeIntent(updates.intent);
Expand Down
25 changes: 19 additions & 6 deletions src/support/serenity/handlers/prompts-subworkspace.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
buildPromptDto,
normalizePromptInput,
createOnePrompt,
makeTypeInjector,
makePromptTagInjector,
parseUpdatePromptBody,
mapLimit,
publishAffected,
Expand All @@ -32,6 +32,7 @@ import {
BULK_CREATE_CONCURRENCY,
BULK_PROMPTS_MAX_ITEMS,
} from './prompts.js';
import { ORIGIN_VALUE } from '../prompt-tags.js';
import { resolveProject, buildSliceProjectMap, sliceKey } from '../subworkspace-projects.js';
import { redactUpstreamMessage } from '../rest-transport.js';
import { createHeadroomGuard } from '../dynamic-allocation-active.js';
Expand Down Expand Up @@ -140,7 +141,15 @@ export async function handleCreatePromptsSubworkspace(
}

const projectsBySlice = await buildSliceProjectMap(transport, workspaceId, log);
const injectComputedType = makeTypeInjector(transport, workspaceId, classifyPromptType, log);
// CREATE: user-authenticated write → derived `origin` is `human` (see the
// flat-mode twin handleCreatePrompts and origin-dimension.md §3).
const injectComputedTags = makePromptTagInjector(
transport,
workspaceId,
classifyPromptType,
log,
{ originValue: ORIGIN_VALUE.HUMAN },
);

// PROMPT metering seam (Rainer, live-verified LLMO-6190): the metered write is
// `createPromptsByIds` (inside `createOnePrompt` below), NOT publish — a disguised-quota 405
Expand Down Expand Up @@ -176,8 +185,9 @@ export async function handleCreatePromptsSubworkspace(
}
const projectId = String(project.id);
try {
// Unified layer: strip any caller-supplied type + inject the computed one.
const typed = await injectComputedType(projectId, input);
// Unified layer: strip any caller-supplied type/origin + inject the
// computed type and the derived origin (`human`).
const typed = await injectComputedTags(projectId, input);
// LLMO-6190 follow-up: the metered write itself can still 405 as a disguised metered-quota
// rejection despite the pre-loop sizing above (the live-verified ~9s gateway
// write-enforcement lag after a JIT top-up) — route it through `headroom.retryOnQuota` (a
Expand Down Expand Up @@ -299,8 +309,11 @@ export async function handleUpdatePromptSubworkspace(
// Recompute the type tag from the NEW text BEFORE any upstream write (see
// the flat-mode twin): a classification failure aborts cleanly with the
// prompt completely untouched.
const injectComputedType = makeTypeInjector(transport, workspaceId, classifyPromptType, log);
const typed = await injectComputedType(projectId, {
// No `originValue`: origin is never re-derived on edit (origin-dimension.md §3
// item 3); the stored origin the caller echoes rides through untouched. See the
// flat-mode twin handleUpdatePrompt.
const injectComputedTags = makePromptTagInjector(transport, workspaceId, classifyPromptType, log);
const typed = await injectComputedTags(projectId, {
text: nextText, geoTargetId, tagIds: nextTagIds,
});

Expand Down
Loading
Loading