Skip to content
17 changes: 11 additions & 6 deletions docs/openapi/prompts-v2-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -84,17 +84,22 @@ v2-prompts-by-brand:
schema:
type: string
description: >-
Filter by prompt source (exact match), e.g. `gsc`, `semrush`,
`base_url`, `config`. Free-form string — sources are open-ended and
added by upstream producers (e.g. DRS) without API changes, so no enum
is enforced.
Filter by prompt source (the producing system), e.g. `gsc`, `semrush`,
`base-url`, `config`. Matched on the canonical form, so `citation-attempt`
also finds rows stored as `citation_attempt` (drift spellings fold
together, case-insensitive). Free-form string — sources are open-ended and
added by upstream producers (e.g. DRS) without API changes, so no enum is
enforced.
- name: sort
in: query
required: false
schema:
type: string
enum: [topic, prompt, category, origin, status, updatedAt]
description: Sort column. Default is updatedAt descending
enum: [topic, prompt, category, origin, source, status, updatedAt]
description: >-
Sort column. Default is updatedAt descending. `source` sorts on the
canonical form, so a producer's drift spellings (`citation_attempt` /
`citation-attempt`) order together.
- name: order
in: query
required: false
Expand Down
12 changes: 6 additions & 6 deletions docs/openapi/schemas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6553,9 +6553,13 @@ V2Prompt:
default: human
source:
type: string
description: The source system or process that created the prompt
readOnly: true
description: >-
The producing system (source-dimension.md). READ-ONLY: server-owned, with
no write surface — a create/update body cannot set it. Returned in its
CANONICAL form (`lower(replace(source, '_', '-'))`), so `agentic_traffic`
reads as `agentic-traffic`.
example: "config"

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.

Fixed (bot nit). Dropped default: "config" from this readOnly V2Prompt.source field — a default on a read-only output field is an OpenAPI 3.0 no-op (clients never send it, servers never default output). example: "config" stays for docs.

default: "config"
intent:
type: [string, 'null']
description: >-
Expand Down Expand Up @@ -6656,10 +6660,6 @@ V2PromptInput:
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
default: "config"
intent:
type: string
description: >-
Expand Down
36 changes: 19 additions & 17 deletions docs/openapi/serenity-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -478,28 +478,30 @@ v2-serenity-tags:
description: |
Registers a BARE-NAMED prompt tag on the market addressed by the
`(geoTargetId, languageCode)` slice. Every tag is a descendant of one of
the four dimension roots (`category`, `intent`, `source`, `type`); a tag's
dimension is that root ancestor, never a prefix on its name. `type` names
the dimension the value belongs to, and `name` must not contain `:` nor
shadow a dimension root's name.
the five dimension roots (`category`, `intent`, `origin`, `type`,
`source`); a tag's dimension is that root ancestor, never a prefix on its
name. `type` names the dimension the value belongs to, and `name` must not
contain `:` nor shadow a dimension root's name.

`category` is the one OPEN dimension: its values are customer-authored. A
duplicate name under the same parent is a hard upstream error, so
resolve-before-create is the CALLER's job. Omit `parentId` to hang the new
`category` is the one CUSTOMER-AUTHORED open dimension: its values are the
customer's. A duplicate name under the same parent is a hard upstream error,
so resolve-before-create is the CALLER's job. Omit `parentId` to hang the new
value directly under the `category` root; pass an upstream tag id (from a
prior tags list) to nest it under an existing category as a sub-category.
The "Categories" view is the `category` root's subtree across a brand's
markets.

`intent` / `source` / `type` are CLOSED dimensions with a fixed value enum:
`name` must be one of that enum's values, and `parentId` is not accepted
because their values are always direct children of the dimension root. A
closed-dimension create is resolve-OR-create (idempotent): if the project
already carries that value its existing id is returned (200,
`created: false`) instead of attempting another create.
`intent` / `origin` / `type` / `source` are SERVER-OWNED: `parentId` is not
accepted (their values are always direct children of the dimension root),
and the create is resolve-OR-create (idempotent) — if the project already
carries that value its existing id is returned (200, `created: false`)
instead of attempting another create. `intent` / `origin` / `type` are
additionally CLOSED (their `name` must be one of a fixed value enum);
`source` is OPEN (the producing system — source-dimension.md), so any bare
`name` resolves-or-creates with no enum.

The four dimension roots are provisioned on demand, so a project that
predates this taxonomy is brought forward by the first write to it.
The dimension roots are provisioned on demand, so a project that predates
this taxonomy is brought forward by the first write to it.
operationId: createSerenityTag
security:
- ims_key: []
Expand All @@ -514,8 +516,8 @@ v2-serenity-tags:
properties:
type:
type: string
enum: [category, intent, source, type]
description: The tag's dimension. `category` is open (customer-authored); `intent`/`source`/`type` are closed (fixed enum).
enum: [category, intent, origin, type, source]
description: The tag's dimension. `category` is customer-authored (open); `intent`/`origin`/`type`/`source` are server-owned — the first three closed (fixed enum), `source` open (producing system, resolve-or-create).
name:
type: string
minLength: 1
Expand Down
10 changes: 9 additions & 1 deletion src/controllers/brands.js
Original file line number Diff line number Diff line change
Expand Up @@ -591,10 +591,18 @@ function BrandsController(ctx, log, env) {
// 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.
//
// `source` (the producing system) has NO write surface (source-dimension.md
// §1 item 6): a caller-supplied `source` is ignored, so a v2 create becomes
// the store's `config` default (item 5, gate §6.4/§6.6). It is dropped here
// rather than passed to upsertPrompts, whose `source: p.source || 'config'`
// write-side default stays load-bearing for the internal writers that DO set
// it. `updatePromptById` likewise never patches source (producer is fixed at
// creation).
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) => ({
const derivedPrompts = prompts.map(({ source: _, ...p }) => ({

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.

{ source: _, ...p } correctly drops any body source (no write surface) and deriveV2PromptOrigin derives origin from the principal (user→human, service→believed). Confirmed the drop makes a v2 create fall to the store's config default, and the write-side || 'config' in upsertPrompts stays load-bearing for internal writers that DO set source. Clean item-5/6 handling.

...p,
origin: deriveV2PromptOrigin(p?.origin, isUserPrincipal),
}));
Expand Down
31 changes: 27 additions & 4 deletions src/support/prompts-storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { classifyIntents } from './intent-classifier.js';
import { throwOnPgConstraintViolation } from './errors.js';
import { assertPermittedSource } from './prompt-sources.js';
import { INTENT_VALUES, normalizeIntent } from './intent.js';
import { canonicalizeSource, foldSourceValue } from './serenity/prompt-tags.js';

// Re-exported for backward compatibility — `normalizeIntent`/`INTENT_VALUES` now
// live in `./intent.js` so the LLM intent classifier can reuse them without an
Expand Down Expand Up @@ -427,6 +428,11 @@ const SORT_COLUMN_MAP = {
origin: 'origin',
status: 'status',
updatedAt: 'updated_at',
// Sort on the `source_canonical` generated column (WP-S4:
// `GENERATED ALWAYS AS (lower(replace(source,'_','-'))) STORED`, btree-indexed),
// so the two drift spellings of a producer order together and the label — which
// lives in elmo, never on the server — plays no part (source-dimension.md §3.1).
source: 'source_canonical',
};

function mapRowToPrompt(row) {
Expand Down Expand Up @@ -455,7 +461,13 @@ function mapRowToPrompt(row) {
// fail-loud choice over a fabricated `human`; unlike `source`/`status`, whose
// fallbacks are cosmetic, an origin fallback is a correctness hazard.
origin: row.origin,
source: row.source || 'config',
// Second derivation boundary (source-dimension.md §3.1): the v2 read surface
// returns the CANONICAL slug, so elmo's badge — which keys on the API's value —
// resolves (`agentic_traffic` → `agentic-traffic`). A value that fails the guard
// (empty, `:`, over-long, root-shadowing) returns the RAW string rather than
// null: the grid must still show the operator what is stored. `?? 'config'` only
// guards a nullish column (in-memory/test rows); the DB column is NOT NULL.
source: canonicalizeSource(row.source) ?? row.source ?? 'config',

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.

Second derivation boundary correct: canonicalizeSource(row.source) ?? row.source ?? 'config' returns the canonical slug, falls back to the RAW string on a guard failure (so the grid still shows what's stored — deliberately not null here, unlike the tag-write boundary), and ?? 'config' only guards a nullish column. origin: row.origin with no || 'human' fallback is also right (NOT NULL in prod; a fallback would mislabel).

intent: row.intent ?? null,
createdAt: row.created_at,
createdBy: row.created_by,
Expand Down Expand Up @@ -502,9 +514,11 @@ function mapRowToPrompt(row) {
* topic name, category name
* @param {string} [params.region] - Filter by region (array containment)
* @param {string} [params.origin] - Filter by origin (ai, human)
* @param {string} [params.source] - Filter by source (e.g. gsc, semrush, base_url, config)
* @param {string} [params.source] - Filter by source, matched on the
* `source_canonical` generated column: `citation-attempt` also matches rows
* stored as `citation_attempt` (drift spellings fold together, case-insensitive).
* @param {string} [params.sort] - Sort column (topic, prompt, category, origin,
* status, updatedAt)
* source, status, updatedAt)
* @param {string} [params.order] - Sort direction (asc, desc). Default desc
* @param {number} [params.limit] - Page size (default 100, max 5000)
* @param {number} [params.page] - Page number, 1-based (default 1)
Expand Down Expand Up @@ -633,7 +647,13 @@ export async function listPrompts({
}

if (hasText(source)) {
baseQuery = baseQuery.eq('source', source);
// Match on the `source_canonical` generated column (WP-S4) — the DB
// canonicalizes `lower(replace(source,'_','-'))` at write time, so a single
// equality on the folded incoming value catches every drift spelling AND is
// case-insensitive (`citation-attempt` finds rows stored `citation_attempt`
// or `Citation_Attempt`). Fold the query-param value with the same transform
// (`foldSourceValue` — the single definition) so the two sides align.
baseQuery = baseQuery.eq('source_canonical', foldSourceValue(source));
}

if (hasText(region)) {
Expand Down Expand Up @@ -1103,6 +1123,9 @@ export async function updatePromptById({
}

const patch = { updated_by: updatedBy };
// `source` is deliberately NOT patchable (source-dimension.md §1 item 6): a
// prompt's producer is fixed at creation, and the dimension has no write surface.
// A caller-supplied `updates.source` is ignored rather than written.
if (updates.prompt !== undefined) {
patch.text = updates.prompt;
}
Expand Down
32 changes: 25 additions & 7 deletions src/support/serenity/handlers/markets-subworkspace.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ import { withResourceLock } from '../resource-lock.js';
import {
modelChangeUnits, releaseAiSurplus, PROJECT_BLOCK, PROMPT_BLOCK,
} from '../resource-manager.js';
import { DIMENSION, STANDARD_PROMPT_TAG_VALUES } from '../prompt-tags.js';
import { provisionDimensionTree } from '../tag-tree.js';
import { DIMENSION, STANDARD_PROMPT_TAG_VALUES, GENERATED_PROMPT_SOURCE_VALUE } from '../prompt-tags.js';
import { provisionDimensionTree, ensureServerOwnedValue } from '../tag-tree.js';
import { classifyBrandedTag, needlesFromNames } from '../branded-classifier.js';
import { collectBrandUrlEntries, attachBrandUrlsToProject, primaryDomainSet } from '../brand-urls.js';
import { resolveProjects } from '../resolve-projects.js';
Expand Down Expand Up @@ -179,10 +179,10 @@ function validateCreateBody(body) {
* Generates topics + prompts for (domain, country) via the AI-SEO service
* (transport.getBrandTopics) and attaches them to the project. Keeps the top
* `topicCap` topics by search volume (0 = keep all) and tags every prompt with
* the standard closed-dimension values ({@link STANDARD_PROMPT_TAG_VALUES}) plus
* a branded / non-branded `type` value derived from `brandNames` (brand name +
* aliases). Returns the topic/prompt counts. A generation that yields nothing is
* a clean no-op (no upstream write).
* the standard closed-dimension values ({@link STANDARD_PROMPT_TAG_VALUES}), the
* producing `source/semrush` value, and a branded / non-branded `type` value
* derived from `brandNames` (brand name + aliases). Returns the topic/prompt
* counts. A generation that yields nothing is a clean no-op (no upstream write).
*
* The generated topic name is NOT attached. Under the dimension-root model a
* topic is a sub-category — a depth-3 descendant of a customer category — and
Expand Down Expand Up @@ -263,6 +263,19 @@ async function generateAndAttachPrompts(transport, workspaceId, projectId, {
const standardIds = STANDARD_PROMPT_TAG_VALUES.map(
({ dimension, name }) => /** @type {string} */ (values.get(dimension)?.get(name)),
);
// Stamp the producing system. This generator builds its prompts from Semrush's
// own `getBrandTopics`, so every generated prompt is `source/semrush` — the
// persisted SR-AI-Visibility key, a constant at THIS write site, NOT `config`
// (source-dimension.md §1 item 2). `source` is open, so the value is resolved-or-
// created on demand rather than pre-provisioned in `provisioned.values`.
const { id: sourceId } = await ensureServerOwnedValue(
transport,
workspaceId,
projectId,
DIMENSION.SOURCE,
GENERATED_PROMPT_SOURCE_VALUE,
log,
);
const typeValues = /** @type {Map<string, string>} */ (values.get(DIMENSION.TYPE));

/** @type {Map<string, string[]>} bare type value → prompt texts */
Expand All @@ -288,7 +301,12 @@ async function generateAndAttachPrompts(transport, workspaceId, projectId, {
// top-up) — route through `headroom.retryOnQuota` (no-op passthrough when the flag is OFF).
// eslint-disable-next-line no-await-in-loop
await headroom.retryOnQuota(
() => transport.createPromptsByIds(workspaceId, projectId, items, [...standardIds, typeId]),
() => transport.createPromptsByIds(

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.

(c) Rebase conflict #1 sound: the S4 sourceId (resolved via ensureServerOwnedValue(..., GENERATED_PROMPT_SOURCE_VALUE)) is threaded into the create's tag_ids ([...standardIds, sourceId, typeId]) AND the write is wrapped in the LLMO-6190 headroom.retryOnQuota. Both concerns coexist correctly. createPromptsByIds (not createPromptsWithMetadata) is right for S2's scope — WP1 #2877 layers the metadata create on top separately.

workspaceId,
projectId,
items,
[...standardIds, sourceId, typeId],

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.

Merge resolution (conflict). Base wrapped this createPromptsByIds call in headroom.retryOnQuota (#2874, the ~9s Semrush write-enforcement lag); WP-S2 added sourceId to the tag list. Combined both — the call stays inside retryOnQuota AND stamps source/semrush. Verified by markets-subworkspace.test.js (green).

),
{ callSite: 'createPromptsByIds' },
);
}
Expand Down
9 changes: 5 additions & 4 deletions src/support/serenity/handlers/prompts-subworkspace.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
BULK_CREATE_CONCURRENCY,
BULK_PROMPTS_MAX_ITEMS,
} from './prompts.js';
import { ORIGIN_VALUE } from '../prompt-tags.js';
import { ORIGIN_VALUE, PROXY_CREATE_SOURCE_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 @@ -141,14 +141,15 @@ export async function handleCreatePromptsSubworkspace(
}

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

// PROMPT metering seam (Rainer, live-verified LLMO-6190): the metered write is
Expand Down
Loading
Loading