diff --git a/docs/openapi/prompts-v2-api.yaml b/docs/openapi/prompts-v2-api.yaml index 6e12e0abbd..cf3bb66f0c 100644 --- a/docs/openapi/prompts-v2-api.yaml +++ b/docs/openapi/prompts-v2-api.yaml @@ -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 diff --git a/docs/openapi/schemas.yaml b/docs/openapi/schemas.yaml index 47107daae2..fef4e1e52e 100644 --- a/docs/openapi/schemas.yaml +++ b/docs/openapi/schemas.yaml @@ -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" - default: "config" intent: type: [string, 'null'] description: >- @@ -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: >- diff --git a/docs/openapi/serenity-api.yaml b/docs/openapi/serenity-api.yaml index e309d4cde2..1462b34cba 100644 --- a/docs/openapi/serenity-api.yaml +++ b/docs/openapi/serenity-api.yaml @@ -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: [] @@ -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 diff --git a/src/controllers/brands.js b/src/controllers/brands.js index 0a9f3e8f50..189509e3be 100644 --- a/src/controllers/brands.js +++ b/src/controllers/brands.js @@ -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 }) => ({ ...p, origin: deriveV2PromptOrigin(p?.origin, isUserPrincipal), })); diff --git a/src/support/prompts-storage.js b/src/support/prompts-storage.js index 9e0d74b7d6..2ed3c8f421 100644 --- a/src/support/prompts-storage.js +++ b/src/support/prompts-storage.js @@ -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 @@ -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) { @@ -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', intent: row.intent ?? null, createdAt: row.created_at, createdBy: row.created_by, @@ -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) @@ -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)) { @@ -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; } diff --git a/src/support/serenity/handlers/markets-subworkspace.js b/src/support/serenity/handlers/markets-subworkspace.js index 2534d27c9d..1a1c7f1ebb 100644 --- a/src/support/serenity/handlers/markets-subworkspace.js +++ b/src/support/serenity/handlers/markets-subworkspace.js @@ -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'; @@ -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 @@ -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} */ (values.get(DIMENSION.TYPE)); /** @type {Map} bare type value → prompt texts */ @@ -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( + workspaceId, + projectId, + items, + [...standardIds, sourceId, typeId], + ), { callSite: 'createPromptsByIds' }, ); } diff --git a/src/support/serenity/handlers/prompts-subworkspace.js b/src/support/serenity/handlers/prompts-subworkspace.js index 5abda87001..e7ee8e6c8e 100644 --- a/src/support/serenity/handlers/prompts-subworkspace.js +++ b/src/support/serenity/handlers/prompts-subworkspace.js @@ -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'; @@ -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 diff --git a/src/support/serenity/handlers/prompts.js b/src/support/serenity/handlers/prompts.js index e70b467521..77a364dc05 100644 --- a/src/support/serenity/handlers/prompts.js +++ b/src/support/serenity/handlers/prompts.js @@ -20,7 +20,7 @@ import { ERROR_CODES, isUpstreamGone } from '../errors.js'; import { normalizeGeoTargetId, normalizeLanguageCode, isValidTagIdFormat } from '../validation.js'; import { invalidateTagCacheForProject } from './markets.js'; import { resolveTypeValueInjection, resolveClosedValueInjection } from '../tag-tree.js'; -import { DIMENSION, ORIGIN_VALUE } from '../prompt-tags.js'; +import { DIMENSION, ORIGIN_VALUE, PROXY_CREATE_SOURCE_VALUE } from '../prompt-tags.js'; // TWIN FILE: the slice→project orchestration here is paralleled by the // subworkspace-mode handlers in prompts-subworkspace.js. The duplication is @@ -402,11 +402,21 @@ export async function createOnePrompt(transport, semrushWorkspaceId, projectId, * stripping without injecting would leave the prompt invisible to the * dimension's filter — both are illegal, so the update path does neither. * + * **`source`** — the PRODUCING SYSTEM (source-dimension.md), and like `origin` it + * is a fact about CREATION, not a classification, so it carries the same asymmetry: + * - on CREATE (`sourceValue` set — the constant `config` for this proxy dialog, + * the value the same prompt gets in Postgres on the v2 path), any caller-supplied + * tag id beneath the `source` root is stripped (by RESOLVED ID, never by name — a + * customer category may legitimately be called `gsc`) and the derived value + * injected. The dimension has no client write surface; + * - on UPDATE (`sourceValue` unset) the injector leaves source ALONE — a prompt's + * producer is fixed at creation. + * * Resolution ({@link resolveTypeValueInjection} / {@link resolveClosedValueInjection}, * two tag-tree reads per distinct value per project — the root level plus the * root's children) is memoized for the request, so a bulk create fans out over - * the distinct computed values rather than over the items. The origin value is - * constant per request, so its resolution is memoized per project. + * the distinct computed values rather than over the items. The origin and source + * values are constant per request, so each resolution is memoized per project. * * Resolution resolves or throws, so a server tag is always attached; it is never * dropped, and a resolution failure aborts the write (which is free — the upstream @@ -417,8 +427,9 @@ export async function createOnePrompt(transport, semrushWorkspaceId, projectId, * @param {string} semrushWorkspaceId * @param {((text: string, geoTargetId: number) => string) | undefined} classifyPromptType * @param {object} [log] - * @param {{ originValue?: string }} [options] - `originValue` is the derived - * `origin` to inject on CREATE; omit it on UPDATE so origin is left untouched. + * @param {{ originValue?: string, sourceValue?: string }} [options] - `originValue` + * / `sourceValue` are the derived `origin` / `source` to inject on CREATE; omit + * each on UPDATE so that dimension is left untouched. * @returns {(projectId: string, input: { text: string, geoTargetId: number, * tagIds: string[] }) => * Promise<{ text: string, geoTargetId: number, tagIds: string[] }>} @@ -430,11 +441,13 @@ export function makePromptTagInjector( log, options = {}, ) { - const { originValue } = options; + const { originValue, sourceValue } = options; /** @type {Map>} */ const typeCache = new Map(); /** @type {Map>} */ const originCache = new Map(); + /** @type {Map>} */ + const sourceCache = new Map(); return async function injectComputedTags(projectId, input) { let { tagIds } = input; @@ -476,6 +489,26 @@ export function makePromptTagInjector( tagIds = [...tagIds.filter((id) => !valueTagIds.includes(id)), computedId]; } + // source — CREATE only, same asymmetry as origin. `sourceValue` unset means + // UPDATE: leave the producer alone (fixed at creation). Stripped by resolved id + // beneath the `source` root, never by name (a category may be `gsc`). + if (sourceValue) { + let pending = sourceCache.get(projectId); + if (!pending) { + pending = resolveClosedValueInjection( + transport, + semrushWorkspaceId, + projectId, + DIMENSION.SOURCE, + sourceValue, + log, + ); + sourceCache.set(projectId, pending); + } + const { computedId, valueTagIds } = await pending; + tagIds = [...tagIds.filter((id) => !valueTagIds.includes(id)), computedId]; + } + return { ...input, tagIds }; }; } @@ -586,12 +619,14 @@ export async function handleCreatePrompts( // (origin-dimension.md §3). Any caller-supplied origin tag id is stripped and // this value injected; on the twin AI-generation path a service producer stamps // `ai` via STANDARD_PROMPT_TAG_VALUES instead (that path does not run here). + // The producing `source` is the constant `config` — this human create dialog is + // what the same prompt gets in Postgres on the v2 path (source-dimension.md §1). const injectComputedTags = makePromptTagInjector( transport, semrushWorkspaceId, classifyPromptType, log, - { originValue: ORIGIN_VALUE.HUMAN }, + { originValue: ORIGIN_VALUE.HUMAN, sourceValue: PROXY_CREATE_SOURCE_VALUE }, ); const results = await mapLimit(inputs, BULK_CREATE_CONCURRENCY, async (raw) => { diff --git a/src/support/serenity/handlers/tags.js b/src/support/serenity/handlers/tags.js index 9c6d4d8859..d44d85a25a 100644 --- a/src/support/serenity/handlers/tags.js +++ b/src/support/serenity/handlers/tags.js @@ -21,11 +21,12 @@ import { } from '../validation.js'; import { resolveProject } from '../subworkspace-projects.js'; import { - ALL_DIMENSIONS, CLOSED_DIMENSIONS, - isClosedDimension, closedValuesOf, isDimensionRootName, + ALL_DIMENSIONS, CLOSED_DIMENSIONS, SERVER_OWNED_DIMENSIONS, + isClosedDimension, isServerOwnedDimension, closedValuesOf, isDimensionRootName, + MAX_TAG_NAME_LEN, } from '../prompt-tags.js'; import { - ensureClosedValue, + ensureServerOwnedValue, ensureDimensionRoots, findTagsInTree, assertParentPlacement, @@ -36,28 +37,27 @@ import { republishBestEffort } from '../brand-urls.js'; /** * POST /serenity/tags — create a prompt TAG on a single market. * - * Every tag is BARE-NAMED and lives under one of the four dimension roots - * (`category`, `intent`, `origin`, `type`) on a market's project — the + * Every tag is BARE-NAMED and lives under one of the five dimension roots + * (`category`, `intent`, `origin`, `type`, `source`) on a market's project — the * `aio/tags` surface, via {@link createProjectTags}. A tag's dimension is its * root ancestor, never a prefix on its name, so `type` in the request body * names the dimension the value belongs to rather than something written into * the name. * - * The three CLOSED dimensions (`intent` / `origin` / `type`) have a fixed value - * enum: `name` must be one of those values, no `parentId` is accepted (their - * values are always direct children of the dimension root), and the create is - * resolve-or-create — a small, project-wide-shared set every caller may need - * the id of. The one OPEN dimension (`category`) carries customer-authored - * values: a category hangs under the `category` root, a sub-category under a - * category (via `parentId`). The UI's "Categories" view is the `category` + * The four SERVER-OWNED dimensions (`intent` / `origin` / `type` / `source`) + * accept no `parentId` (their values are always direct children of the dimension + * root) and are created resolve-or-create — a small, project-wide-shared set every + * caller may need the id of. The three CLOSED ones additionally enum-check the + * `name`; `source` is open (source-dimension.md) so any bare name resolves-or- + * creates. The one CUSTOMER-AUTHORED open dimension (`category`) carries + * customer values: a category hangs under the `category` root, a sub-category + * under a category (via `parentId`). The UI's "Categories" view is the `category` * root's subtree across the brand's markets. * * Both the flat-mode and subworkspace-mode handlers resolve the market's project * id from the `(geoTargetId, languageCode)` slice and register one tag. */ -const MAX_TAG_NAME_LEN = 100; - /** * Length + whitespace/control-char validation shared by every parentId parser * below, given an already-trimmed, already-known-to-be-a-string, non-empty id. @@ -153,15 +153,24 @@ function parseUpdateParentId(body) { * `type` names one of {@link ALL_DIMENSIONS}. `name` is BARE — a `:` is * rejected rather than rewritten, and a reserved dimension-root name is refused * so no value can shadow a root. `parentId` (optional) is the upstream id of - * the tag the new one nests under; it is only legal for the OPEN dimension, - * since a closed dimension's values are always direct children of its root. - * `isClosed` is true for the dimensions in {@link CLOSED_DIMENSIONS}, whose + * the tag the new one nests under; it is only legal for the CUSTOMER-AUTHORED open + * dimension (`category`), since a server-owned dimension's values are always + * direct children of its root. + * + * The two flags answer two independent questions. `isClosed` + * ({@link CLOSED_DIMENSIONS}) drives VOCABULARY validation: a closed value's * `name` must be one of that dimension's fixed values ({@link closedValuesOf}). + * `isServerOwned` ({@link SERVER_OWNED_DIMENSIONS}) drives the WRITE GUARD and + * CREATE SEMANTICS: it forbids a `parentId` (server-owned values hang directly off + * their root) and routes the create through resolve-or-create. `source` is + * server-owned yet open — a `parentId` is refused and the value is resolved-or- + * created, but there is no enum to check against. * * @param {object} body - request body. * @returns {{ * type: string, name: string, geoTargetId: number, - * languageCode: string, parentId: string | undefined, isClosed: boolean, + * languageCode: string, parentId: string | undefined, + * isClosed: boolean, isServerOwned: boolean, * }} */ function parseCreateTagBody(body) { @@ -176,6 +185,7 @@ function parseCreateTagBody(body) { ); } const isClosed = isClosedDimension(type); + const isServerOwned = isServerOwnedDimension(type); const rawName = hasText(body?.name) ? String(body.name).trim() : ''; if (!rawName) { throw new ErrorWithStatusCode('name is required', 400); @@ -227,15 +237,15 @@ function parseCreateTagBody(body) { ); } const parentId = parseParentId(body?.parentId); - if (isClosed && parentId !== undefined) { + if (isServerOwned && parentId !== undefined) { throw new ErrorWithStatusCode( - `parentId is not allowed for a closed dimension (${CLOSED_DIMENSIONS.join(', ')}): ` + `parentId is not allowed for a server-owned dimension (${SERVER_OWNED_DIMENSIONS.join(', ')}): ` + 'its values are always direct children of the dimension root', 400, ); } return { - type, name: rawName, geoTargetId, languageCode, parentId, isClosed, + type, name: rawName, geoTargetId, languageCode, parentId, isClosed, isServerOwned, }; } @@ -368,7 +378,7 @@ export async function handleCreateTag( log, ) { const { - type, name, geoTargetId, languageCode, parentId, isClosed, + type, name, geoTargetId, languageCode, parentId, isServerOwned, } = parseCreateTagBody(body); const row = await dataAccess.BrandSemrushProject.findBySlice( brandId, @@ -380,8 +390,8 @@ export async function handleCreateTag( } const projectId = row.getSemrushProjectId(); - if (isClosed) { - const { id, rootId, created } = await ensureClosedValue( + if (isServerOwned) { + const { id, rootId, created } = await ensureServerOwnedValue( transport, semrushWorkspaceId, projectId, @@ -389,7 +399,7 @@ export async function handleCreateTag( name, log, ); - log?.info?.('handleCreateTag: resolved closed-dimension value', { + log?.info?.('handleCreateTag: resolved server-owned-dimension value', { brandId, geoTargetId, languageCode, type, name, created, }); // A create leaves the project in `live_with_unpublished_updates`; publish so @@ -464,7 +474,7 @@ export async function handleCreateTagSubworkspace( log, ) { const { - type, name, geoTargetId, languageCode, parentId, isClosed, + type, name, geoTargetId, languageCode, parentId, isServerOwned, } = parseCreateTagBody(body); const project = await resolveProject(transport, workspaceId, geoTargetId, languageCode, log); if (!project) { @@ -472,8 +482,8 @@ export async function handleCreateTagSubworkspace( } const projectId = String(project.id); - if (isClosed) { - const { id, rootId, created } = await ensureClosedValue( + if (isServerOwned) { + const { id, rootId, created } = await ensureServerOwnedValue( transport, workspaceId, projectId, @@ -481,7 +491,7 @@ export async function handleCreateTagSubworkspace( name, log, ); - log?.info?.('handleCreateTagSubworkspace: resolved closed-dimension value', { + log?.info?.('handleCreateTagSubworkspace: resolved server-owned-dimension value', { geoTargetId, languageCode, type, name, created, }); // Publish the seeded value so it is live (best-effort). See handleCreateTag. diff --git a/src/support/serenity/prompt-tags.js b/src/support/serenity/prompt-tags.js index 77324187ae..d020447a03 100644 --- a/src/support/serenity/prompt-tags.js +++ b/src/support/serenity/prompt-tags.js @@ -40,28 +40,51 @@ */ /** - * The four dimension roots. Each is a bare-named ROOT tag on every project. + * The five dimension roots. Each is a bare-named ROOT tag on every project. + * + * `source` (source-dimension.md) is the producing-system dimension — the system + * that produced a prompt (`config`, `gsc`, `drs`, …), read from `prompts.source` + * and canonicalized ({@link canonicalizeSource}). It reuses the name the + * authorship root vacates in the `source` → `origin` rename (origin-dimension.md): + * while that rename's contract phase (WP-O6) is still in flight the identifier is + * temporarily overloaded, so this dimension MUST NOT be provisioned on a project + * whose `source` root still carries `ai` / `human` — see + * {@link LEGACY_AUTHORSHIP_ROOT_NAME} and the distinctness guard in `tag-tree.js`. */ export const DIMENSION = Object.freeze({ CATEGORY: 'category', INTENT: 'intent', ORIGIN: 'origin', TYPE: 'type', + SOURCE: 'source', }); /** * The pre-rename name of the authorship root. Live projects provisioned before * the rename still carry a root named `source` (with `ai` / `human` beneath it); * the tolerant resolver accepts it in place of `origin` until WP-O6 drops this. + * + * NOTE it is the SAME string as {@link DIMENSION.SOURCE}: the producing-system + * `source` root (this dimension) and the legacy authorship root are told apart by + * their CHILDREN, never by name — see `tag-tree.js` `childrenAreAuthorship` and the + * distinctness guard in `ensureDimensionRoots`. */ export const LEGACY_AUTHORSHIP_ROOT_NAME = 'source'; +/** + * The upstream tag-name length limit (Semrush `aio/tags`). Shared single source + * of truth: the create-tag handler holds a create body to it, and + * {@link canonicalizeSource} refuses a derived value longer than it. + */ +export const MAX_TAG_NAME_LEN = 100; + /** Root names, in the order they are provisioned on a project. */ export const DIMENSION_ROOT_NAMES = Object.freeze([ DIMENSION.CATEGORY, DIMENSION.INTENT, DIMENSION.ORIGIN, DIMENSION.TYPE, + DIMENSION.SOURCE, ]); /** `origin` values — who authored the prompt. */ @@ -122,15 +145,116 @@ export const CLOSED_DIMENSIONS = Object.freeze([ ]); /** - * The OPEN dimensions — a caller may create arbitrary descendants at any depth. - * `category` is the only one: a customer category is a child of the `category` - * root, and a sub-category is a child of a category. + * The OPEN dimensions — a value's vocabulary is NOT a fixed enum. + * + * `category` is customer-authored: a customer category is a child of the + * `category` root, and a sub-category is a child of a category, at any depth. + * `source` is server-owned but equally open — its vocabulary is the set of + * producing systems, which grows with the platform and is never a frozen enum + * (source-dimension.md §1 item 3). Open-vs-closed answers ONLY "does this have a + * fixed vocabulary"; it does NOT answer "may a client write it" — that is + * {@link SERVER_OWNED_DIMENSIONS}. */ -export const OPEN_DIMENSIONS = Object.freeze([DIMENSION.CATEGORY]); +export const OPEN_DIMENSIONS = Object.freeze([DIMENSION.CATEGORY, DIMENSION.SOURCE]); -/** Every dimension a caller may address on the create-tag endpoint. */ +/** + * The SERVER-OWNED dimensions — everything except `category`. No client may mint + * a value beneath these or assert one on a write; the server resolves-or-creates + * them. This is a SEPARATE axis from open/closed ({@link isClosedDimension}): a + * dimension is described by two independent properties — its vocabulary (open or + * closed) and who writes it (the customer or the server) — and `source` is the + * cell that is open AND server-owned (source-dimension.md §1 item 4). + * + * Two decisions route through this list, not through `isClosedDimension`: + * - the create-tag WRITE GUARD (a client may address it but never author a value + * outside the server's control — the closed dimensions additionally enum-check, + * `source` does not), and + * - CREATE SEMANTICS: a server-owned value is resolve-or-create, because no human + * is in a dialog to resolve it first. + * `isClosedDimension` keeps its one honest job: vocabulary validation. + */ +export const SERVER_OWNED_DIMENSIONS = Object.freeze([ + DIMENSION.INTENT, + DIMENSION.ORIGIN, + DIMENSION.TYPE, + DIMENSION.SOURCE, +]); + +/** + * Every dimension a caller may address on the create-tag endpoint. This is a + * MEMBERSHIP set (used only for `.includes` validation), so its order is + * irrelevant and INTENTIONALLY differs from {@link DIMENSION_ROOT_NAMES} — that + * list is provisioning ORDER (`category, intent, origin, type, source`), whereas + * this is grouped open-then-closed (`category, source, intent, origin, type`). + * Do not assume the two share an order. + */ export const ALL_DIMENSIONS = Object.freeze([...OPEN_DIMENSIONS, ...CLOSED_DIMENSIONS]); +/** + * The canonical producing-system vocabulary known TODAY (source-dimension.md + * §2.2, folded to the canonical form by §3.1). `source` is an OPEN dimension, so + * this is NOT an allow-list and canonicalization never consults it — a producer + * that ships a new value tomorrow is tagged tomorrow. It is a HYGIENE REFERENCE + * SET with one job: anchoring the exhaustiveness of {@link SOURCE_LABEL}, so a + * new canonical value cannot be added without also giving it a label (§7). Mirror + * of the migration CLI's `KNOWN_PROMPT_SOURCES` (mysticat-data-service + * `scripts/serenity_migration/tags.py`); keep the two in sync. + */ +export const SOURCE_VALUES = Object.freeze([ + 'config', + 'base-url', + 'gsc', + 'drs', + 'semrush', + 'flow', + 'synthetic-personas', + 'citation-attempt', + 'llm-generated', + 'sheet', + 'api', + 'personalized', + 'agentic-traffic', + 'brand-concierge', +]); + +/** + * The `source` value stamped on a prompt created through the Serenity PROXY create + * path (the human create dialog). A constant at the write site, never a caller + * input and never read from a column — it matches what Postgres assigns on the v2 + * path (`prompts.source` default `config`), so the same user action produces the + * same tag whichever store is behind it (source-dimension.md §1 items 2 & 5). + */ +export const PROXY_CREATE_SOURCE_VALUE = 'config'; + +/** + * The `source` value stamped on every AI-generated prompt by the market-onboarding + * generator. That path builds prompts from Semrush's own `getBrandTopics`, and + * `semrush` is the persisted key for prompts from SR AI Visibility (source-dimension.md + * §1 item 2). A constant at that write site — NOT `config`. + */ +export const GENERATED_PROMPT_SOURCE_VALUE = 'semrush'; + +/** + * Canonical producing-system slug → customer-facing label. FROZEN and EXHAUSTIVE: + * one entry per {@link SOURCE_VALUES} value, enforced by a unit test that FAILS + * the moment a canonical value is added without a label. There is deliberately NO + * pass-through slug default — a `SOURCE_LABEL[x] ?? x` fallback is exactly the + * mechanism by which an internal slug reaches a customer silently (source-dimension.md + * §7), and §3.2's product sign-off protects only the values that exist today. + * + * The label question is OPEN (source-dimension.md §3.2): until product signs off, + * the labels are the canonical machine-name slugs, verbatim. This map is the SINGLE + * place a display label would land, so whichever way that call goes it changes here + * and nowhere else. (elmo ships its own display labels behind `SOURCE_BADGE_CONFIG`; + * WP-S3.) + */ +export const SOURCE_LABEL = Object.freeze( + SOURCE_VALUES.reduce((acc, slug) => { + acc[slug] = slug; + return acc; + }, /** @type {Record} */ ({})), +); + /** * The closed-dimension values applied to EVERY AI-generated prompt: the `origin` * value `ai` (AI-authored) plus the default `Informational` intent (the most @@ -166,7 +290,9 @@ export function isDimensionRootName(name) { } /** - * True when `dimension` has a fixed child vocabulary. + * True when `dimension` has a fixed child vocabulary. This answers ONLY the + * vocabulary question — for the write-guard / create-semantics question use + * {@link isServerOwnedDimension}. * * @param {string} dimension * @returns {boolean} @@ -175,6 +301,69 @@ export function isClosedDimension(dimension) { return (/** @type {readonly string[]} */ (CLOSED_DIMENSIONS)).includes(dimension); } +/** + * True when `dimension` is server-owned — no client may author a value under it, + * and the server resolves-or-creates it. Everything except `category`. Distinct + * from {@link isClosedDimension}: `source` is server-owned yet open. + * + * @param {string} dimension + * @returns {boolean} + */ +export function isServerOwnedDimension(dimension) { + return (/** @type {readonly string[]} */ (SERVER_OWNED_DIMENSIONS)).includes(dimension); +} + +/** + * The bare canonical FOLD of a `source` value — trim, lowercase, `_` → `-` + * (source-dimension.md §3.1) — with none of {@link canonicalizeSource}'s safety + * guards. This is the SINGLE definition of the transform in this repo: both + * `canonicalizeSource` (read/tag-write boundary) and the v2 list `source` filter + * fold (prompts-storage.js) route through it so the spellings can never drift. + * + * Coerces with `String()` so a non-string filter value (e.g. a query param parsed + * as a number/array) folds instead of throwing; `canonicalizeSource` already + * guards the type before calling, so the coercion is a no-op on that path. + * + * @param {unknown} value - a `source` value. + * @returns {string} the folded value. + */ +export function foldSourceValue(value) { + return String(value).trim().toLowerCase().replace(/_/g, '-'); +} + +/** + * Canonicalizes a raw `prompts.source` value to its `source`-dimension tag name, + * OR returns `null` when the value must not be tagged (source-dimension.md §3.1). + * + * The rule is mechanical: trim, lowercase, and `_` → `-`. Nothing else — no + * mapping table, no inference, no default. It is total as a transform, but total + * is not the same as safe (`prompts.source` is free text with no `CHECK`), so a + * derived value is refused a tag — `null` — when it is empty after trimming, + * contains a `:` (forbidden in any tag name), exceeds {@link MAX_TAG_NAME_LEN}, + * or shadows a dimension-root name ({@link isDimensionRootName}). + * + * `null` means "do not tag this prompt", NEVER "substitute a default": a caller + * writes the prompt regardless and logs the offending value. This is the single + * place the rule lives in this repo; it is applied at both derivation boundaries + * (the tag write and the v2 read surface — `mapRowToPrompt`). + * + * @param {unknown} value - a raw `prompts.source` value. + * @returns {string | null} the canonical slug, or `null` when it must not be tagged. + */ +export function canonicalizeSource(value) { + if (typeof value !== 'string') { + return null; + } + const canonical = foldSourceValue(value); + if (canonical === '' + || canonical.includes(':') + || canonical.length > MAX_TAG_NAME_LEN + || isDimensionRootName(canonical)) { + return null; + } + return canonical; +} + /** * The fixed child vocabulary of a closed dimension, or an empty tuple for an * open one. diff --git a/src/support/serenity/tag-tree.js b/src/support/serenity/tag-tree.js index 5dba4f5e77..8df5a4fd52 100644 --- a/src/support/serenity/tag-tree.js +++ b/src/support/serenity/tag-tree.js @@ -238,7 +238,7 @@ async function childrenAreAuthorship(transport, semrushWorkspaceId, projectId, r } /** - * Resolves the four dimension roots, creating any that a project is missing. + * Resolves the five dimension roots, creating any that a project is missing. * Older projects predate this taxonomy entirely, so this is the seam that brings * them forward on first touch. * @@ -252,40 +252,62 @@ async function childrenAreAuthorship(transport, semrushWorkspaceId, projectId, r * map's `origin` key maps to whichever physical root was resolved, so callers key on * `DIMENSION.ORIGIN` regardless. Removed with the fallback by WP-O6. * + * The producing-system `source` root (source-dimension.md) shares the name a + * mid-rename project's authorship root still carries, so the two are kept DISTINCT: + * a physical `source` root is the producing-system root ONLY when it is not the one + * adopted as authorship. While a project's `source` root still means authorship the + * producing-system dimension has no root there — the name is taken — so the returned + * map's `source` key is `undefined` on such a project. It resolves on a fresh project + * (where `source` is created outright) and on a post-rename project (where authorship + * has moved to `origin`). This is why this dimension MUST NOT be provisioned before + * WP-O6, and why the guard here mirrors {@link childrenAreAuthorship}'s in reverse. + * * @param {object} transport - Serenity transport (Semrush proxy client). * @param {string} semrushWorkspaceId * @param {string} projectId * @param {object} [log] - logger. - * @returns {Promise>} root name → tag id, in root order, with the - * `origin` key carrying the resolved authorship root's id. + * @returns {Promise>} root name → tag id, in root + * order, with the `origin` key carrying the resolved authorship root's id and the + * `source` key `undefined` on a project whose `source` root is still authorship. */ export async function ensureDimensionRoots(transport, semrushWorkspaceId, projectId, log) { const existing = await indexLevelByName(transport, semrushWorkspaceId, projectId, '', log); // Tolerant authorship resolution: prefer `origin`; else adopt a legacy `source` root // in place (guarded so the companion producing-system `source` dimension is never - // mistaken for authorship). + // mistaken for authorship). The SAME physical `source` root can only be one of the + // two, so classifying it once here also tells the producing-system resolve below + // whether the name is free. + const physicalSourceId = existing.get(LEGACY_AUTHORSHIP_ROOT_NAME); let authorshipId = existing.get(DIMENSION.ORIGIN); - if (!authorshipId) { - const legacyId = existing.get(LEGACY_AUTHORSHIP_ROOT_NAME); - if (legacyId - && await childrenAreAuthorship(transport, semrushWorkspaceId, projectId, legacyId, log)) { - log?.info?.('ensureDimensionRoots: adopting the legacy `source` authorship root in place', { - semrushWorkspaceId, projectId, rootId: legacyId, - }); - authorshipId = legacyId; - } + let legacySourceIsAuthorship = false; + const physicalSourceIsAuthorship = !authorshipId && physicalSourceId + && await childrenAreAuthorship(transport, semrushWorkspaceId, projectId, physicalSourceId, log); + if (physicalSourceIsAuthorship) { + log?.info?.('ensureDimensionRoots: adopting the legacy `source` authorship root in place', { + semrushWorkspaceId, projectId, rootId: physicalSourceId, + }); + authorshipId = physicalSourceId; + legacySourceIsAuthorship = true; } - // Resolve-or-create every root except `origin`, and `origin` too UNLESS an authorship - // root was already found — creating it only then keeps the fresh-project path a single - // create call while never minting a second authorship root on a mid-rename project. - const wanted = DIMENSION_ROOT_NAMES.filter( - (name) => name !== DIMENSION.ORIGIN || !authorshipId, - ); + // Resolve-or-create every root except: + // - `origin`, when an authorship root was already found (never mint a second — §8); + // - `source`, when a physical `source` root already exists (whether it turned out to + // be authorship or producing-system) — a blind create would either mint a second + // root or convert an authorship root into a producing one. // Reuse the root-level read above — the tolerant resolve costs no extra read on the // common path (only `childrenAreAuthorship` adds one, and only when a legacy `source` - // root is present). + // root is present with no `origin`). + const wanted = DIMENSION_ROOT_NAMES.filter((name) => { + if (name === DIMENSION.ORIGIN) { + return !authorshipId; + } + if (name === DIMENSION.SOURCE) { + return !physicalSourceId; + } + return true; + }); const { byName } = await ensureChildren( transport, semrushWorkspaceId, @@ -296,13 +318,29 @@ export async function ensureDimensionRoots(transport, semrushWorkspaceId, projec existing, ); - // Return the roots in canonical order, with `origin` carrying the resolved id. + // The producing-system `source` root: the existing physical root when it is NOT the + // authorship root, the freshly-created one on a project that had no `source` root at + // all, and `undefined` while the `source` root still means authorship (WP-O6-gated). + let producingSourceId; + if (legacySourceIsAuthorship) { + producingSourceId = undefined; + } else if (physicalSourceId) { + producingSourceId = physicalSourceId; + } else { + producingSourceId = byName.get(DIMENSION.SOURCE); + } + + // Return the roots in canonical order, with `origin` carrying the resolved authorship + // id and `source` the producing-system id (never the authorship root's). const roots = new Map(); for (const name of DIMENSION_ROOT_NAMES) { - roots.set( - name, - name === DIMENSION.ORIGIN ? (authorshipId ?? byName.get(name)) : byName.get(name), - ); + if (name === DIMENSION.ORIGIN) { + roots.set(name, authorshipId ?? byName.get(name)); + } else if (name === DIMENSION.SOURCE) { + roots.set(name, producingSourceId); + } else { + roots.set(name, byName.get(name)); + } } return roots; } @@ -311,17 +349,47 @@ export async function ensureDimensionRoots(transport, semrushWorkspaceId, projec * The id of one dimension root out of an {@link ensureDimensionRoots} result. * * `ensureChildren` fails closed, so a map it returned carries every name that was - * asked for — all four roots. The assertion records that invariant for the type - * checker instead of re-testing it at runtime. + * asked for. Used only for roots that are always resolved (the closed dimensions + * and `category`); the `source` root can be `undefined` on a mid-rename project, + * so callers that need it read `roots.get(DIMENSION.SOURCE)` directly instead. * - * @param {Map} roots - the resolved root name → id map. - * @param {string} dimension - one of the four dimension root names. + * @param {Map} roots - the resolved root name → id map. + * @param {string} dimension - one of the always-resolved dimension root names. * @returns {string} */ function rootIdOf(roots, dimension) { return /** @type {string} */ (roots.get(dimension)); } +/** + * The id of a SERVER-OWNED dimension root, failing LOUD when it is unresolved. + * + * The only dimension whose root can come back `undefined` is `source` on a + * mid-rename project — {@link ensureDimensionRoots} deliberately leaves the + * `source` key unset while that project's `source` root still means authorship + * (WP-O6-gated). Without this guard an `undefined` root id flows into + * {@link ensureChildren}, whose `createProjectTags(missing, parentId ? {parentId} + * : {})` degrades to a ROOT-LEVEL create, silently minting a stranded value as a + * bogus new dimension root. The external deploy gate should keep this state out of + * production, but a gate bypass must fail visibly, not corrupt the tree — so refuse + * rather than proceed. A no-op for the always-provisioned dimensions. + * + * @param {Map} roots - the resolved root name → id map. + * @param {string} dimension - a server-owned dimension root name. + * @returns {string} + */ +function requireServerOwnedRootId(roots, dimension) { + const rootId = roots.get(dimension); + if (!rootId) { + throw new ErrorWithStatusCode( + `${dimension} dimension root not provisioned (source-dimension.md is WP-O6-gated); ` + + 'refusing to create a stranded root-level tag', + 502, + ); + } + return rootId; +} + /** * Locates every id in `tagIds` in the project's draft tag tree, in ONE walk, and * reports where each sits: the DIMENSION it belongs to (the name of its root @@ -499,19 +567,21 @@ export async function assertParentWithinDimension( } /** - * Resolves (provisioning as needed) the full fixed taxonomy: the four roots plus - * every closed dimension's child vocabulary. The open `category` root is created - * but left empty — its children are customer content. + * Resolves (provisioning as needed) the fixed taxonomy: the roots plus every + * closed dimension's child vocabulary. The open `category` and `source` roots are + * created but left empty — a `category`'s children are customer content, and a + * `source`'s children are minted on first use (it has no enum to pre-provision). * * @param {object} transport - Serenity transport (Semrush proxy client). * @param {string} semrushWorkspaceId * @param {string} projectId * @param {object} [log] - logger. * @returns {Promise<{ - * roots: Map, + * roots: Map, * values: Map>, - * }>} `roots` maps a root name to its id; `values` maps a closed dimension name - * to that dimension's bare value → id map. + * }>} `roots` maps a root name to its id (the `source` key is `undefined` on a + * mid-rename project — see {@link ensureDimensionRoots}); `values` maps a closed + * dimension name to that dimension's bare value → id map. */ export async function provisionDimensionTree(transport, semrushWorkspaceId, projectId, log) { const roots = await ensureDimensionRoots(transport, semrushWorkspaceId, projectId, log); @@ -532,21 +602,33 @@ export async function provisionDimensionTree(transport, semrushWorkspaceId, proj } /** - * Resolves one closed-dimension value to its upstream id, creating it (and its - * root) only if absent. Idempotent: many independent callers legitimately need - * the id of a small, project-wide-shared value. + * Resolves one SERVER-OWNED value to its upstream id, creating it (and its root) + * only if absent. Idempotent: many independent callers legitimately need the id + * of a small, project-wide-shared value. + * + * This is the resolve-or-create primitive (source-dimension.md §1 item 4). It is + * vocabulary-agnostic on purpose — the caller enforces a CLOSED dimension's enum + * BEFORE calling (the create-tag handler's `parseCreateTagBody`), and an OPEN + * server-owned dimension (`source`) has no enum to check. Lifting the enum check + * to the caller is exactly what generalizes this from the former `ensureClosedValue` + * to every server-owned dimension without changing its resolve-or-create body: + * `ensureChildren` reads the level, creates the missing name, and on an upstream + * failure re-reads and adopts the id a concurrent writer minted, so a duplicate + * `(parent, name)` — reported upstream as an indistinguishable 500 — is absorbed + * rather than surfaced. * * @param {object} transport - Serenity transport (Semrush proxy client). * @param {string} semrushWorkspaceId * @param {string} projectId - * @param {string} dimension - a closed dimension (`intent` / `origin` / `type`). - * @param {string} value - a bare value from that dimension's fixed vocabulary. + * @param {string} dimension - a server-owned dimension (`intent` / `origin` / + * `type` / `source`). + * @param {string} value - the bare value to resolve under that dimension's root. * @param {object} [log] - logger. * @returns {Promise<{ id: string, rootId: string, created: boolean }>} `created` * is true only when THIS call minted the value. Both ids are always resolved — * {@link ensureChildren} throws rather than leave a hole. */ -export async function ensureClosedValue( +export async function ensureServerOwnedValue( transport, semrushWorkspaceId, projectId, @@ -555,7 +637,7 @@ export async function ensureClosedValue( log, ) { const roots = await ensureDimensionRoots(transport, semrushWorkspaceId, projectId, log); - const rootId = rootIdOf(roots, dimension); + const rootId = requireServerOwnedRootId(roots, dimension); const { byName, createdNames } = await ensureChildren( transport, semrushWorkspaceId, @@ -605,7 +687,7 @@ export async function resolveClosedValueInjection( log, ) { const roots = await ensureDimensionRoots(transport, semrushWorkspaceId, projectId, log); - const rootId = rootIdOf(roots, dimension); + const rootId = requireServerOwnedRootId(roots, dimension); const { byName } = await ensureChildren( transport, semrushWorkspaceId, diff --git a/test/controllers/brands.test.js b/test/controllers/brands.test.js index 415e04a77e..b5c9422e2e 100644 --- a/test/controllers/brands.test.js +++ b/test/controllers/brands.test.js @@ -4835,9 +4835,10 @@ describe('Brands Controller', () => { expect(response.status).to.equal(200); }); - it('forwards source param through to the prompts query as an exact filter', async () => { + it('forwards source param through to the prompts query as a source_canonical filter', async () => { // Recording client so the assertion fails if source stops being forwarded - // from the controller through to listPrompts' `.eq('source', ...)`. + // from the controller through to listPrompts' source filter (matched on the + // `source_canonical` generated column). const eqCalls = []; mockDataAccess.services.postgrestClient = { from: sandbox.stub().callsFake((table) => { @@ -4847,6 +4848,10 @@ describe('Brands Controller', () => { eqCalls.push({ column, value }); return chain; }), + in: sandbox.stub().callsFake((column, values) => { + eqCalls.push({ column, values }); + return chain; + }), neq: sandbox.stub().returnsThis(), order: sandbox.stub().returnsThis(), or: sandbox.stub().returnsThis(), @@ -4869,7 +4874,7 @@ describe('Brands Controller', () => { dataAccess: mockDataAccess, }); expect(response.status).to.equal(200); - expect(eqCalls).to.deep.include({ column: 'source', value: 'gsc' }); + expect(eqCalls).to.deep.include({ column: 'source_canonical', value: 'gsc' }); }); it('returns prompt items from normalized tables', async () => { diff --git a/test/support/prompts-storage.test.js b/test/support/prompts-storage.test.js index da90bc1331..9b9b8d1599 100644 --- a/test/support/prompts-storage.test.js +++ b/test/support/prompts-storage.test.js @@ -438,7 +438,10 @@ describe('prompts-storage', () => { or: () => chain, contains: () => chain, overlaps: () => chain, - in: () => chain, + in: (column, values) => { + eqCalls.push({ column, values }); + return chain; + }, range: () => thenable(result), maybeSingle: () => thenable(result), single: () => thenable(result), @@ -453,7 +456,7 @@ describe('prompts-storage', () => { }; } - it('applies the source filter as an exact match when source is provided', async () => { + it('filters source on the source_canonical generated column', async () => { const eqCalls = []; await listPrompts({ organizationId: ORG_ID, @@ -461,7 +464,24 @@ describe('prompts-storage', () => { source: 'gsc', postgrestClient: makeEqRecordingClient(eqCalls), }); - expect(eqCalls).to.deep.include({ column: 'source', value: 'gsc' }); + // Single equality on the DB-canonicalized column — no app-side variant expansion. + expect(eqCalls).to.deep.include({ column: 'source_canonical', value: 'gsc' }); + }); + + it('folds the incoming filter value to canonical before matching source_canonical', async () => { + const eqCalls = []; + await listPrompts({ + organizationId: ORG_ID, + brandId: BRAND_UUID, + source: 'CITATION_ATTEMPT', + postgrestClient: makeEqRecordingClient(eqCalls), + }); + // The query-param value is folded (trim→lower→`_`→`-`) so it aligns with the + // generated column, which already stores the canonical form — one value, and + // a request for `citation_attempt`/`CITATION_ATTEMPT` finds `citation-attempt`. + expect(eqCalls).to.deep.include({ + column: 'source_canonical', value: 'citation-attempt', + }); }); it('does not apply a source filter when source is omitted', async () => { @@ -471,7 +491,7 @@ describe('prompts-storage', () => { brandId: BRAND_UUID, postgrestClient: makeEqRecordingClient(eqCalls), }); - expect(eqCalls.some((c) => c.column === 'source')).to.equal(false); + expect(eqCalls.some((c) => c.column.includes('source'))).to.equal(false); }); it('uses explicit limit and page values', async () => { @@ -3326,6 +3346,71 @@ describe('prompts-storage', () => { }); expect(result.items[0].source).to.equal('sheet'); }); + + it('canonicalizes source on read (2nd derivation boundary — agentic_traffic → agentic-traffic)', async () => { + const rowWithSource = { ...sampleRow, source: 'agentic_traffic' }; + const client = { + from: (table) => (table === 'brands' + ? makeChain({ data: { id: BRAND_UUID }, error: null }) + : makeChain({ data: [rowWithSource], error: null, count: 1 })), + }; + const result = await listPrompts({ + organizationId: ORG_ID, brandId: BRAND_UUID, postgrestClient: client, + }); + expect(result.items[0].source).to.equal('agentic-traffic'); + }); + + it('returns the RAW stored value when it fails the canonical guard (grid still shows it)', async () => { + const rowWithSource = { ...sampleRow, source: 'has:colon' }; + const client = { + from: (table) => (table === 'brands' + ? makeChain({ data: { id: BRAND_UUID }, error: null }) + : makeChain({ data: [rowWithSource], error: null, count: 1 })), + }; + const result = await listPrompts({ + organizationId: ORG_ID, brandId: BRAND_UUID, postgrestClient: client, + }); + expect(result.items[0].source).to.equal('has:colon'); + }); + + it('sorts source on the source_canonical generated column', async () => { + const orderCalls = []; + const recordingChain = (result) => { + const chain = { + select: () => chain, + eq: () => chain, + neq: () => chain, + or: () => chain, + contains: () => chain, + overlaps: () => chain, + in: () => chain, + order: (column, opts) => { + orderCalls.push({ column, opts }); + return chain; + }, + range: () => thenable(result), + maybeSingle: () => thenable(result), + single: () => thenable(result), + then: (resolve) => resolve(result), + }; + return chain; + }; + const client = { + from: (table) => (table === 'brands' + ? recordingChain({ data: { id: BRAND_UUID }, error: null }) + : recordingChain({ data: [], error: null, count: 0 })), + }; + await listPrompts({ + organizationId: ORG_ID, + brandId: BRAND_UUID, + sort: 'source', + order: 'asc', + postgrestClient: client, + }); + expect(orderCalls[0]).to.deep.equal({ + column: 'source_canonical', opts: { ascending: true }, + }); + }); }); describe('upsertPrompts - source field', () => { diff --git a/test/support/serenity/fixtures/tag-tree.js b/test/support/serenity/fixtures/tag-tree.js index 7d15eb44d6..641d3f1d97 100644 --- a/test/support/serenity/fixtures/tag-tree.js +++ b/test/support/serenity/fixtures/tag-tree.js @@ -38,6 +38,7 @@ export const TAG_IDS = Object.freeze({ intentRoot: 'root-intent', originRoot: 'root-origin', typeRoot: 'root-type', + sourceRoot: 'root-source', intentInformational: 'intent-informational', intentTask: 'intent-task', @@ -53,6 +54,14 @@ export const TAG_IDS = Object.freeze({ categoryRunningShoes: 'category-running-shoes', subCategoryHuman: 'subcategory-human', + + // The producing-system `source` root carries a couple of already-minted values + // (`config`, the commonest producer, and `semrush`, the market generator's). + // `source` is OPEN so this is not a fixed enum — these are just values that + // happen to exist on this project, the way the server resolves-or-creates one on + // first use (source-dimension.md §1 item 4). + sourceConfig: 'source-config', + sourceSemrush: 'source-semrush', }); const ROOT_IDS = Object.freeze({ @@ -60,6 +69,7 @@ const ROOT_IDS = Object.freeze({ intent: TAG_IDS.intentRoot, origin: TAG_IDS.originRoot, type: TAG_IDS.typeRoot, + source: TAG_IDS.sourceRoot, }); const CLOSED_VALUE_IDS = Object.freeze({ @@ -100,7 +110,12 @@ export function dimensionTreeLevels(extraLevels = {}) { const roots = DIMENSION_ROOT_NAMES.map((name) => upstreamTag({ id: ROOT_IDS[name], name, - childrenCount: name === 'category' ? 1 : CLOSED_DIMENSION_VALUES[name].length, + // `category` carries one seeded sub-tree; the closed dimensions carry their + // enum. The open `source` root reports `children_count: 0` so tree WALKS + // (findTagsInTree) do not descend it, yet its level below still serves one + // already-minted `config` value for by-parent resolution (indexLevelByName), + // matching how a real project accrues `source` values on first use. + childrenCount: name === 'category' ? 1 : (CLOSED_DIMENSION_VALUES[name]?.length ?? 0), })); const closedLevels = {}; @@ -132,6 +147,21 @@ export function dimensionTreeLevels(extraLevels = {}) { parentId: TAG_IDS.categoryRunningShoes, path: [...CATEGORY_CRUMB, { id: TAG_IDS.categoryRunningShoes, name: 'Running Shoes' }], })], + // Already-minted producing-system values under the `source` root. + [TAG_IDS.sourceRoot]: [ + upstreamTag({ + id: TAG_IDS.sourceConfig, + name: 'config', + parentId: TAG_IDS.sourceRoot, + path: [{ id: TAG_IDS.sourceRoot, name: 'source' }], + }), + upstreamTag({ + id: TAG_IDS.sourceSemrush, + name: 'semrush', + parentId: TAG_IDS.sourceRoot, + path: [{ id: TAG_IDS.sourceRoot, name: 'source' }], + }), + ], ...extraLevels, }; } diff --git a/test/support/serenity/handlers/markets-subworkspace.test.js b/test/support/serenity/handlers/markets-subworkspace.test.js index a6bf85ba35..ad3f8b425c 100644 --- a/test/support/serenity/handlers/markets-subworkspace.test.js +++ b/test/support/serenity/handlers/markets-subworkspace.test.js @@ -33,9 +33,12 @@ import { TAG_IDS, dimensionTreeLevels, makeListProjectTagsStub } from '../fixtur use(chaiAsPromised); use(sinonChai); -// Every generated prompt carries the two standard values (source=ai, -// intent=Informational); the third tag is the per-prompt computed `type`. +// Every generated prompt carries the two standard values (origin=ai, +// intent=Informational) plus the producing `source/semrush` value; the last tag +// is the per-prompt computed `type`. Order matches the write site: +// [...standardIds, sourceId, typeId]. const STANDARD_IDS = [TAG_IDS.originAi, TAG_IDS.intentInformational]; +const GENERATED_IDS = [...STANDARD_IDS, TAG_IDS.sourceSemrush]; const BRAND = 'brand-1'; const WS = 'subworkspace-ws-1'; @@ -608,8 +611,8 @@ describe('markets-subworkspace handlers', () => { // grouped by computed type, one upstream call per group, because // createPromptsByIds carries ONE shared tag_ids array per call. expect(transport.createPromptsByIds).to.have.been.calledTwice; - expect(transport.createPromptsByIds).to.have.been.calledWithExactly(WS, 'new-proj', ['best running shoes'], [...STANDARD_IDS, TAG_IDS.typeNonBranded]); - expect(transport.createPromptsByIds).to.have.been.calledWithExactly(WS, 'new-proj', ['top trail shoes'], [...STANDARD_IDS, TAG_IDS.typeBranded]); + expect(transport.createPromptsByIds).to.have.been.calledWithExactly(WS, 'new-proj', ['best running shoes'], [...GENERATED_IDS, TAG_IDS.typeNonBranded]); + expect(transport.createPromptsByIds).to.have.been.calledWithExactly(WS, 'new-proj', ['top trail shoes'], [...GENERATED_IDS, TAG_IDS.typeBranded]); expect(res.body).to.include({ topicCount: 1, promptCount: 2, published: true }); // Models are STAGED (no inner publish) — only the single final publish runs, // so a quota 405 can never escape mid-flow from the model-set commit. @@ -722,13 +725,13 @@ describe('markets-subworkspace handlers', () => { WS, 'new-proj', ['Best ACME running shoes', 'top trail sneakers from zoom'], - [...STANDARD_IDS, TAG_IDS.typeBranded], + [...GENERATED_IDS, TAG_IDS.typeBranded], ); expect(transport.createPromptsByIds).to.have.been.calledWithExactly( WS, 'new-proj', ['most comfortable sandals'], - [...STANDARD_IDS, TAG_IDS.typeNonBranded], + [...GENERATED_IDS, TAG_IDS.typeNonBranded], ); }); @@ -1445,7 +1448,7 @@ describe('markets-subworkspace — defensive branch coverage', () => { ); expect(res.status).to.equal(201); // 'adobe shoes' contains 'adobe' → branded (null alias was dropped, not used). - expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WS, 'new-proj', ['adobe shoes'], [...STANDARD_IDS, TAG_IDS.typeBranded]); + expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WS, 'new-proj', ['adobe shoes'], [...GENERATED_IDS, TAG_IDS.typeBranded]); }); // Line 115 truthy branch: body.name is provided and valid — String(body.name) is used @@ -1544,8 +1547,8 @@ describe('markets-subworkspace — defensive branch coverage', () => { expect(res.status).to.equal(201); // Needles = ['b','real'] (the '' element was coerced + filtered out). // 'real deal' contains 'real' → branded; 'plain text' → non-branded. - expect(transport.createPromptsByIds).to.have.been.calledWithExactly(WS, 'new-proj', ['real deal'], [...STANDARD_IDS, TAG_IDS.typeBranded]); - expect(transport.createPromptsByIds).to.have.been.calledWithExactly(WS, 'new-proj', ['plain text'], [...STANDARD_IDS, TAG_IDS.typeNonBranded]); + expect(transport.createPromptsByIds).to.have.been.calledWithExactly(WS, 'new-proj', ['real deal'], [...GENERATED_IDS, TAG_IDS.typeBranded]); + expect(transport.createPromptsByIds).to.have.been.calledWithExactly(WS, 'new-proj', ['plain text'], [...GENERATED_IDS, TAG_IDS.typeNonBranded]); }); // The two guards below both fire when the tag tree, freshly provisioned, still diff --git a/test/support/serenity/handlers/prompts-subworkspace.test.js b/test/support/serenity/handlers/prompts-subworkspace.test.js index 0f570c5456..64cb4d4029 100644 --- a/test/support/serenity/handlers/prompts-subworkspace.test.js +++ b/test/support/serenity/handlers/prompts-subworkspace.test.js @@ -154,9 +154,10 @@ describe('prompts-subworkspace handlers', () => { }, log); expect(result.created).to.have.length(1); expect(result.created[0]).to.include({ semrushPromptId: 'new-prompt', geoTargetId: 2840 }); - // A create is a user-authenticated write: the derived `origin` (`human`) is - // stamped alongside the caller's tags (origin-dimension.md §3). - expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WS, 'p-us-en', ['p'], ['tag-1', TAG_IDS.originHuman]); + // A create is a user-authenticated write: the derived `origin` (`human`) and + // producing `source` (`config`) are stamped alongside the caller's tags + // (origin-dimension.md §3, source-dimension.md §1). + expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WS, 'p-us-en', ['p'], ['tag-1', TAG_IDS.originHuman, TAG_IDS.sourceConfig]); expect(transport.publishProject).to.have.been.calledOnceWith(WS, 'p-us-en'); }); @@ -212,13 +213,21 @@ describe('prompts-subworkspace handlers', () => { }], }, log, classifyByBrandMention); expect(result.created[0].tagIds).to.deep.equal([ - TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, TAG_IDS.originHuman, + TAG_IDS.categoryRunningShoes, + TAG_IDS.typeBranded, + TAG_IDS.originHuman, + TAG_IDS.sourceConfig, ]); expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly( WS, 'p-us-en', ['is Acme good?'], - [TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, TAG_IDS.originHuman], + [ + TAG_IDS.categoryRunningShoes, + TAG_IDS.typeBranded, + TAG_IDS.originHuman, + TAG_IDS.sourceConfig, + ], ); }); diff --git a/test/support/serenity/handlers/prompts.test.js b/test/support/serenity/handlers/prompts.test.js index 3803c89add..efb5a2c9b6 100644 --- a/test/support/serenity/handlers/prompts.test.js +++ b/test/support/serenity/handlers/prompts.test.js @@ -576,9 +576,9 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { geoTargetId: 2840, languageCode: 'en', text: 'hello', - tagIds: ['tag-cat-1', 'tag-child-1', TAG_IDS.originHuman], + tagIds: ['tag-cat-1', 'tag-child-1', TAG_IDS.originHuman, TAG_IDS.sourceConfig], }); - expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', ['hello'], ['tag-cat-1', 'tag-child-1', TAG_IDS.originHuman]); + expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', ['hello'], ['tag-cat-1', 'tag-child-1', TAG_IDS.originHuman, TAG_IDS.sourceConfig]); expect(transport.publishProject).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en'); }); @@ -662,8 +662,8 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }, fakeLog()); expect(result.created[0].semrushPromptId).to.equal(''); - expect(result.created[0].tagIds).to.deep.equal(['keep', TAG_IDS.originHuman]); - expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', ['hello'], ['keep', TAG_IDS.originHuman]); + expect(result.created[0].tagIds).to.deep.equal(['keep', TAG_IDS.originHuman, TAG_IDS.sourceConfig]); + expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', ['hello'], ['keep', TAG_IDS.originHuman, TAG_IDS.sourceConfig]); }); it('returns empty semrushPromptId (not the string "undefined") when createPromptsByIds returns an item with no id', async () => { @@ -711,8 +711,8 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }], }, fakeLog()); - expect(result.created[0].tagIds).to.deep.equal(['keep', TAG_IDS.originHuman]); - expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', ['hello'], ['keep', TAG_IDS.originHuman]); + expect(result.created[0].tagIds).to.deep.equal(['keep', TAG_IDS.originHuman, TAG_IDS.sourceConfig]); + expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', ['hello'], ['keep', TAG_IDS.originHuman, TAG_IDS.sourceConfig]); }); it('caps a bulk-create tagIds array at MAX_TAG_IDS (50), mirroring the list-read query cap', async () => { @@ -736,10 +736,12 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }, fakeLog()); // The MAX_TAG_IDS cap bounds the CALLER's tags (50); the server-derived - // `origin` is injected on top, so the stored set is the 50 capped tags plus - // the derived origin id. - expect(result.created[0].tagIds).to.have.lengthOf(51); - expect(result.created[0].tagIds).to.deep.equal([...tooMany.slice(0, 50), TAG_IDS.originHuman]); + // `origin` and `source` are injected on top, so the stored set is the 50 capped + // tags plus the derived origin id plus the derived source id. + expect(result.created[0].tagIds).to.have.lengthOf(52); + expect(result.created[0].tagIds).to.deep.equal( + [...tooMany.slice(0, 50), TAG_IDS.originHuman, TAG_IDS.sourceConfig], + ); }); it('skips a create row when tagIds sanitizes to empty (every entry malformed)', async () => { @@ -1848,13 +1850,21 @@ describe('handlers/prompts.js — unified type classification (serenity-docs#31) }, fakeLog(), classifyByBrandMention); expect(result.created[0].tagIds).to.deep.equal([ - TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, TAG_IDS.originHuman, + TAG_IDS.categoryRunningShoes, + TAG_IDS.typeBranded, + TAG_IDS.originHuman, + TAG_IDS.sourceConfig, ]); expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly( WORKSPACE, 'proj-us-en', ['is Acme good?'], - [TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, TAG_IDS.originHuman], + [ + TAG_IDS.categoryRunningShoes, + TAG_IDS.typeBranded, + TAG_IDS.originHuman, + TAG_IDS.sourceConfig, + ], ); // The whole taxonomy already exists, so nothing is provisioned. expect(transport.createProjectTags).to.not.have.been.called; @@ -1880,7 +1890,10 @@ describe('handlers/prompts.js — unified type classification (serenity-docs#31) }, fakeLog(), classifyByBrandMention); expect(result.created[0].tagIds).to.deep.equal([ - TAG_IDS.categoryRunningShoes, TAG_IDS.typeNonBranded, TAG_IDS.originHuman, + TAG_IDS.categoryRunningShoes, + TAG_IDS.typeNonBranded, + TAG_IDS.originHuman, + TAG_IDS.sourceConfig, ]); }); @@ -1919,7 +1932,7 @@ describe('handlers/prompts.js — unified type classification (serenity-docs#31) }, fakeLog(), classifyByBrandMention); expect(result.created[0].tagIds).to.deep.equal([ - decoyCategoryId, TAG_IDS.typeBranded, TAG_IDS.originHuman, + decoyCategoryId, TAG_IDS.typeBranded, TAG_IDS.originHuman, TAG_IDS.sourceConfig, ]); }); @@ -1943,19 +1956,23 @@ describe('handlers/prompts.js — unified type classification (serenity-docs#31) }], }, fakeLog(), classifyByBrandMention); - // The four roots are created at the root level, then `branded` beneath the - // freshly-minted `type` root, then `human` beneath the `origin` root — the - // create path stamps the derived origin as well as the computed type. + // The five roots are created at the root level, then `branded` beneath the + // freshly-minted `type` root, then `human` beneath the `origin` root, then + // `config` beneath the `source` root — the create path stamps the derived + // origin AND source as well as the computed type. expect(createProjectTags.firstCall.args[2]).to.deep.equal([ - 'category', 'intent', 'origin', 'type', + 'category', 'intent', 'origin', 'type', 'source', ]); expect(createProjectTags.firstCall.args[3]).to.deep.equal({}); expect(createProjectTags.secondCall.args[2]).to.deep.equal(['branded']); expect(createProjectTags.secondCall.args[3]).to.deep.equal({ parentId: 'created::type' }); expect(createProjectTags.thirdCall.args[2]).to.deep.equal(['human']); expect(createProjectTags.thirdCall.args[3]).to.deep.equal({ parentId: 'created::origin' }); + expect(createProjectTags.getCall(3).args[2]).to.deep.equal(['config']); + expect(createProjectTags.getCall(3).args[3]).to.deep.equal({ parentId: 'created::source' }); expect(result.created[0].tagIds).to.deep.equal([ 'tag-cat-1', 'created:created::type:branded', 'created:created::origin:human', + 'created:created::source:config', ]); }); }); @@ -2089,7 +2106,10 @@ describe('handlers/prompts.js — origin derivation (origin-dimension.md §3)', }, fakeLog(), classifyByBrandMention); expect(result.created[0].tagIds).to.deep.equal([ - TAG_IDS.categoryRunningShoes, TAG_IDS.typeNonBranded, TAG_IDS.originHuman, + TAG_IDS.categoryRunningShoes, + TAG_IDS.typeNonBranded, + TAG_IDS.originHuman, + TAG_IDS.sourceConfig, ]); expect(result.created[0].tagIds).to.not.include(TAG_IDS.originAi); }); @@ -2129,12 +2149,55 @@ describe('handlers/prompts.js — origin derivation (origin-dimension.md §3)', }, fakeLog(), classifyByBrandMention); // The `ai` CATEGORY survives; only the origin-root id is stripped, and the - // derived origin is injected. + // derived origin and source are injected. expect(result.created[0].tagIds).to.deep.equal([ - decoyAiCategoryId, TAG_IDS.typeBranded, TAG_IDS.originHuman, + decoyAiCategoryId, TAG_IDS.typeBranded, TAG_IDS.originHuman, TAG_IDS.sourceConfig, ]); }); + // Defense-in-depth: on a MID-RENAME project the `source` root still means + // authorship, so ensureDimensionRoots leaves the producing `source` key + // undefined. The create-path source injection must FAIL LOUD rather than let an + // undefined root id degrade into a stranded root-level `config` tag. (This state + // is externally gated out of prod by the WP-O6 deploy gate; the guard is what + // makes a gate bypass fail visibly instead of corrupting the tree.) + it('create fails loud (no stranded root-level tag) when the source root is unprovisioned (mid-rename)', async () => { + const dataAccess = makeDataAccess([project()]); + const createProjectTags = sinon.stub(); + const transport = { + // Legacy `source` root (ai/human), no `origin` root → source key undefined. + listProjectTags: makeListProjectTagsStub({ + '': [ + { id: 'root-category', name: 'category', children_count: 0 }, + { id: 'root-intent', name: 'intent', children_count: 5 }, + { id: 'root-source', name: 'source', children_count: 2 }, + { id: 'root-type', name: 'type', children_count: 2 }, + ], + 'root-source': [ + { id: 'legacy-ai', name: 'ai', parent_id: 'root-source' }, + { id: 'legacy-human', name: 'human', parent_id: 'root-source' }, + ], + }), + createProjectTags, + createPromptsByIds: sinon.stub().resolves({ page: 1, total: 1, items: [{ id: 's', name: 'x' }] }), + publishProject: sinon.stub().resolves(), + }; + + const result = await handleCreatePrompts(transport, dataAccess, BRAND, WORKSPACE, { + prompts: [{ + text: 'best shoes', geoTargetId: 2840, languageCode: 'en', tagIds: ['tag-cat-1'], + }], + }, fakeLog()); + + // The input fails cleanly with the clear guard error; nothing is written. + expect(result.created).to.have.lengthOf(0); + expect(result.failed).to.have.lengthOf(1); + expect(result.failed[0].status).to.equal(502); + expect(transport.createPromptsByIds).to.not.have.been.called; + // Crucially: no root-level create ever minted a stranded `config` root. + expect(createProjectTags).to.not.have.been.called; + }); + // Gate 7: editing a prompt must not relabel it. The stored origin the caller // echoes back (`ai`) rides through the replace-mode tag write untouched — it is // never re-derived to `human`, and `origin/human` is never injected on update. diff --git a/test/support/serenity/handlers/tags.test.js b/test/support/serenity/handlers/tags.test.js index e19894c9ed..7207f3cd36 100644 --- a/test/support/serenity/handlers/tags.test.js +++ b/test/support/serenity/handlers/tags.test.js @@ -141,13 +141,14 @@ describe('serenity tags handler (POST /serenity/tags)', () => { )).to.be.rejectedWith('boom'); }); - it('provisions the four dimension roots on a project that predates the taxonomy', async () => { + it('provisions the five dimension roots on a project that predates the taxonomy', async () => { const createProjectTags = sinon.stub(); createProjectTags.onFirstCall().resolves([ { id: 'r-category', name: 'category' }, { id: 'r-intent', name: 'intent' }, { id: 'r-origin', name: 'origin' }, { id: 'r-type', name: 'type' }, + { id: 'r-source', name: 'source' }, ]); createProjectTags.onSecondCall().resolves([ { id: 'new-cat', name: 'Footwear', parent_id: 'r-category' }, @@ -165,7 +166,7 @@ describe('serenity tags handler (POST /serenity/tags)', () => { ); expect(res.status).to.equal(201); - expect(createProjectTags.firstCall.args[2]).to.deep.equal(['category', 'intent', 'origin', 'type']); + expect(createProjectTags.firstCall.args[2]).to.deep.equal(['category', 'intent', 'origin', 'type', 'source']); expect(createProjectTags.secondCall.args[2]).to.deep.equal(['Footwear']); expect(createProjectTags.secondCall.args[3]).to.deep.equal({ parentId: 'r-category' }); expect(res.body).to.include({ id: 'new-cat', parentId: 'r-category' }); @@ -175,7 +176,7 @@ describe('serenity tags handler (POST /serenity/tags)', () => { // serves the LIVE view, so a root create can answer 201, echo nothing, and // leave the re-read of the root level exactly as empty as it was. The open // dimension then has no root to hang the new category under. Hanging it at - // the root level instead would mint a fifth root that looks like a customer + // the root level instead would mint a spurious root that looks like a customer // category, so this fails the request rather than guessing a parent. it('502s when the open dimension root cannot be resolved after provisioning', async () => { const transport = makeEmptyTreeTransport({ @@ -193,7 +194,7 @@ describe('serenity tags handler (POST /serenity/tags)', () => { // The roots were attempted; the category itself never was. expect(transport.createProjectTags).to.have.been.calledOnce; expect(transport.createProjectTags.firstCall.args[2]) - .to.deep.equal(['category', 'intent', 'origin', 'type']); + .to.deep.equal(['category', 'intent', 'origin', 'type', 'source']); }); it('502s when the upstream create response carries no usable id', async () => { @@ -333,7 +334,7 @@ describe('serenity tags handler (POST /serenity/tags)', () => { }); }); - describe('handleCreateTag — closed dimensions (source/intent/type)', () => { + describe('handleCreateTag — closed dimensions (intent/origin/type)', () => { let handler; beforeEach(async () => { handler = await import('../../../../src/support/serenity/handlers/tags.js'); @@ -444,6 +445,81 @@ describe('serenity tags handler (POST /serenity/tags)', () => { }); }); + describe('handleCreateTag — source dimension (open + server-owned, resolve-or-create)', () => { + let handler; + beforeEach(async () => { + handler = await import('../../../../src/support/serenity/handlers/tags.js'); + }); + + it('resolves an EXISTING source value without creating a duplicate (200, created:false)', async () => { + // The fixture carries `config` under the `source` root already. + const transport = makeTransport(); + const dataAccess = makeDataAccess({ getSemrushProjectId: () => 'proj-1' }); + const res = await handler.handleCreateTag( + transport, + dataAccess, + BRAND, + WORKSPACE, + { + type: 'source', name: 'config', geoTargetId: 2840, languageCode: 'en', + }, + fakeLog(), + ); + expect(res.status).to.equal(200); + expect(res.body).to.include({ + type: 'source', + name: 'config', + id: TAG_IDS.sourceConfig, + parentId: TAG_IDS.sourceRoot, + created: false, + }); + expect(transport.createProjectTags).to.not.have.been.called; + }); + + it('mints a NEW source value on demand — open, so any bare name resolves-or-creates (created:true)', async () => { + // `source` has no enum, so an unseen value like `drs` is created, not rejected. + const transport = makeTransport({ + createProjectTags: sinon.stub().resolves([ + { id: 'tag-source-drs', name: 'drs', parent_id: TAG_IDS.sourceRoot }, + ]), + }); + const dataAccess = makeDataAccess({ getSemrushProjectId: () => 'proj-1' }); + const res = await handler.handleCreateTag( + transport, + dataAccess, + BRAND, + WORKSPACE, + { + type: 'source', name: 'drs', geoTargetId: 2840, languageCode: 'en', + }, + fakeLog(), + ); + expect(res.status).to.equal(200); + expect(res.body).to.include({ + type: 'source', name: 'drs', id: 'tag-source-drs', parentId: TAG_IDS.sourceRoot, created: true, + }); + // A source value is a direct child of the `source` root. + expect(transport.createProjectTags) + .to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-1', ['drs'], { parentId: TAG_IDS.sourceRoot }); + }); + + it('400s a source create that carries a parentId (server-owned: values hang off the root)', async () => { + const transport = makeTransport(); + const dataAccess = makeDataAccess({ getSemrushProjectId: () => 'proj-1' }); + await expect(handler.handleCreateTag( + transport, + dataAccess, + BRAND, + WORKSPACE, + { + type: 'source', name: 'gsc', geoTargetId: 2840, languageCode: 'en', parentId: 'root-1', + }, + fakeLog(), + )).to.be.rejected.then((err) => expect(err.status).to.equal(400)); + expect(transport.createProjectTags).to.not.have.been.called; + }); + }); + describe('handleCreateTagSubworkspace', () => { it('resolves the project from the live listing and registers the tag', async () => { const resolveProjectStub = sinon.stub().resolves({ id: 'proj-sub-1' }); diff --git a/test/support/serenity/prompt-tags.test.js b/test/support/serenity/prompt-tags.test.js index 75665ffeed..c718571680 100644 --- a/test/support/serenity/prompt-tags.test.js +++ b/test/support/serenity/prompt-tags.test.js @@ -21,29 +21,49 @@ import { CLOSED_DIMENSION_VALUES, CLOSED_DIMENSIONS, OPEN_DIMENSIONS, + SERVER_OWNED_DIMENSIONS, ALL_DIMENSIONS, + SOURCE_VALUES, + SOURCE_LABEL, + MAX_TAG_NAME_LEN, STANDARD_PROMPT_TAG_VALUES, isDimensionRootName, isClosedDimension, + isServerOwnedDimension, + canonicalizeSource, closedValuesOf, } from '../../../src/support/serenity/prompt-tags.js'; describe('serenity prompt-tags taxonomy', () => { describe('dimension roots', () => { - it('has exactly four roots, all bare-named', () => { - expect([...DIMENSION_ROOT_NAMES]).to.deep.equal(['category', 'intent', 'origin', 'type']); + it('includes the five roots, all bare-named (membership, never a count)', () => { + // Membership, not set-equality — a further open root is contemplated + // (source-dimension.md header), so nothing may key on the root count. + expect([...DIMENSION_ROOT_NAMES]).to.include.members([ + 'category', 'intent', 'origin', 'type', 'source', + ]); DIMENSION_ROOT_NAMES.forEach((n) => expect(n).to.not.include(':')); }); - it('splits the roots into one open and three closed dimensions', () => { - expect([...OPEN_DIMENSIONS]).to.deep.equal([DIMENSION.CATEGORY]); + it('splits the roots into open (category, source) and closed (intent, origin, type)', () => { + expect([...OPEN_DIMENSIONS]).to.deep.equal([DIMENSION.CATEGORY, DIMENSION.SOURCE]); expect([...CLOSED_DIMENSIONS]).to.deep.equal(['intent', 'origin', 'type']); expect([...ALL_DIMENSIONS].sort()).to.deep.equal([...DIMENSION_ROOT_NAMES].sort()); }); - it('recognises a reserved root name', () => { + it('is server-owned for everything except category (write-guard / create-semantics axis)', () => { + expect([...SERVER_OWNED_DIMENSIONS]).to.deep.equal(['intent', 'origin', 'type', 'source']); + expect(isServerOwnedDimension(DIMENSION.CATEGORY)).to.equal(false); + expect(isServerOwnedDimension(DIMENSION.SOURCE)).to.equal(true); + expect(isServerOwnedDimension(DIMENSION.INTENT)).to.equal(true); + // `source` is server-owned yet OPEN — a separate axis from vocabulary. + expect(isClosedDimension(DIMENSION.SOURCE)).to.equal(false); + }); + + it('recognises a reserved root name, including source', () => { expect(isDimensionRootName('category')).to.equal(true); expect(isDimensionRootName('type')).to.equal(true); + expect(isDimensionRootName('source')).to.equal(true); expect(isDimensionRootName('Running Shoes')).to.equal(false); }); @@ -103,4 +123,60 @@ describe('serenity prompt-tags taxonomy', () => { expect(Object.isFrozen(STANDARD_PROMPT_TAG_VALUES)).to.equal(true); }); }); + + describe('canonicalizeSource', () => { + it('trims, lowercases and folds `_` to `-`', () => { + expect(canonicalizeSource(' GSC ')).to.equal('gsc'); + expect(canonicalizeSource('agentic_traffic')).to.equal('agentic-traffic'); + expect(canonicalizeSource('CITATION_ATTEMPT')).to.equal('citation-attempt'); + expect(canonicalizeSource('config')).to.equal('config'); + }); + + it('folds the twinned spellings onto one canonical value', () => { + expect(canonicalizeSource('synthetic_personas')) + .to.equal(canonicalizeSource('synthetic-personas')); + }); + + it('returns null (do-not-tag) for a value that fails the guard — never a default', () => { + expect(canonicalizeSource('')).to.equal(null); + expect(canonicalizeSource(' ')).to.equal(null); + expect(canonicalizeSource('has:colon')).to.equal(null); + expect(canonicalizeSource('x'.repeat(MAX_TAG_NAME_LEN + 1))).to.equal(null); + // shadows a dimension-root name (including the reserved legacy `source`) + expect(canonicalizeSource('category')).to.equal(null); + expect(canonicalizeSource('source')).to.equal(null); + expect(canonicalizeSource('ORIGIN')).to.equal(null); + // non-string + expect(canonicalizeSource(null)).to.equal(null); + expect(canonicalizeSource(undefined)).to.equal(null); + }); + + it('accepts a value exactly at the length limit', () => { + const atLimit = 'a'.repeat(MAX_TAG_NAME_LEN); + expect(canonicalizeSource(atLimit)).to.equal(atLimit); + }); + }); + + describe('SOURCE_LABEL', () => { + it('is frozen and has exactly one entry per canonical value (exhaustive, CI gate)', () => { + expect(Object.isFrozen(SOURCE_LABEL)).to.equal(true); + // This assertion FAILS the moment a canonical value is added to SOURCE_VALUES + // without a label — the exhaustiveness gate (source-dimension.md §7). No + // pass-through slug default is permitted. + expect(Object.keys(SOURCE_LABEL).sort()).to.deep.equal([...SOURCE_VALUES].sort()); + SOURCE_VALUES.forEach((slug) => { + expect(SOURCE_LABEL[slug], `missing SOURCE_LABEL for ${slug}`).to.be.a('string').and.not.equal(''); + }); + }); + + it('every canonical value canonicalizes to itself (already folded)', () => { + SOURCE_VALUES.forEach((slug) => { + expect(canonicalizeSource(slug)).to.equal(slug); + }); + }); + + it('is frozen for SOURCE_VALUES too', () => { + expect(Object.isFrozen(SOURCE_VALUES)).to.equal(true); + }); + }); }); diff --git a/test/support/serenity/tag-tree.test.js b/test/support/serenity/tag-tree.test.js index 3ca43b2961..ffce24c675 100644 --- a/test/support/serenity/tag-tree.test.js +++ b/test/support/serenity/tag-tree.test.js @@ -20,7 +20,7 @@ import { ensureChildren, ensureDimensionRoots, provisionDimensionTree, - ensureClosedValue, + ensureServerOwnedValue, resolveTypeValueInjection, resolveClosedValueInjection, findTagsInTree, @@ -256,13 +256,17 @@ describe('serenity tag-tree', () => { }); describe('ensureDimensionRoots', () => { - it('resolves all four roots without creating them when they exist', async () => { + it('resolves all five roots without creating them when they exist', async () => { const transport = { listProjectTags: makeListProjectTagsStub(), createProjectTags: sinon.stub(), }; const roots = await ensureDimensionRoots(transport, WS, PROJECT, fakeLog()); - expect([...roots.keys()]).to.deep.equal(['category', 'intent', 'origin', 'type']); + // Membership, not a count — a further open root is contemplated (source-dimension.md). + expect([...roots.keys()]).to.include.members(['category', 'intent', 'origin', 'type', 'source']); + // The producing-system `source` root resolves (the fixture's `source` root is + // not authorship), distinct from the `origin` root. + expect(roots.get('source')).to.equal(TAG_IDS.sourceRoot); expect(transport.createProjectTags).to.not.have.been.called; }); @@ -272,8 +276,10 @@ describe('serenity tag-tree', () => { const roots = await ensureDimensionRoots(transport, WS, PROJECT, fakeLog()); expect(createProjectTags).to.have.been.calledOnce; expect(createProjectTags.firstCall.args[2]) - .to.deep.equal(['category', 'intent', 'origin', 'type']); + .to.deep.equal(['category', 'intent', 'origin', 'type', 'source']); expect(roots.get('type')).to.equal('created::type'); + // A fresh project mints the producing-system `source` root outright. + expect(roots.get('source')).to.equal('created::source'); }); it('adopts a legacy `source` authorship root in place, minting no second `origin`', async () => { @@ -439,7 +445,7 @@ describe('serenity tag-tree', () => { const listProjectTags = makeListProjectTagsStub(); const transport = { listProjectTags, createProjectTags: sinon.stub() }; const { roots, values } = await provisionDimensionTree(transport, WS, PROJECT, fakeLog()); - expect([...roots.keys()]).to.have.members(['category', 'intent', 'origin', 'type']); + expect([...roots.keys()]).to.include.members(['category', 'intent', 'origin', 'type', 'source']); expect(values.get('origin')?.get('ai')).to.equal(TAG_IDS.originAi); expect(values.get('type')?.get('branded')).to.equal(TAG_IDS.typeBranded); // The open `category` root is provisioned but its children are customer content. @@ -448,13 +454,13 @@ describe('serenity tag-tree', () => { }); }); - describe('ensureClosedValue', () => { + describe('ensureServerOwnedValue', () => { it('resolves an existing value and reports created:false', async () => { const transport = { listProjectTags: makeListProjectTagsStub(), createProjectTags: sinon.stub(), }; - const res = await ensureClosedValue(transport, WS, PROJECT, 'origin', 'ai', fakeLog()); + const res = await ensureServerOwnedValue(transport, WS, PROJECT, 'origin', 'ai', fakeLog()); expect(res).to.deep.equal({ id: TAG_IDS.originAi, rootId: TAG_IDS.originRoot, created: false, }); @@ -470,7 +476,7 @@ describe('serenity tag-tree', () => { { id: 'made-ai', name: 'ai', parent_id: TAG_IDS.originRoot }, ]), }; - const res = await ensureClosedValue(transport, WS, PROJECT, 'origin', 'ai', fakeLog()); + const res = await ensureServerOwnedValue(transport, WS, PROJECT, 'origin', 'ai', fakeLog()); expect(res).to.deep.equal({ id: 'made-ai', rootId: TAG_IDS.originRoot, created: true, }); @@ -482,7 +488,7 @@ describe('serenity tag-tree', () => { // The create echoes nothing, so no root id is ever learned. createProjectTags: sinon.stub().resolves([]), }; - const err = await ensureClosedValue(transport, WS, PROJECT, 'origin', 'ai', fakeLog()) + const err = await ensureServerOwnedValue(transport, WS, PROJECT, 'origin', 'ai', fakeLog()) .then(() => null, (e) => e); expect(err).to.be.an('error'); expect(err.status).to.equal(502); @@ -525,6 +531,64 @@ describe('serenity tag-tree', () => { const res = await resolveClosedValueInjection(transport, WS, PROJECT, DIMENSION.ORIGIN, 'ai', fakeLog()); expect(res.computedId).to.equal(TAG_IDS.originAi); expect(res.valueTagIds).to.have.members([TAG_IDS.originAi, TAG_IDS.originHuman]); + expect(transport.createProjectTags).to.not.have.been.called; + }); + }); + + // A MID-RENAME project (legacy `source` root carrying ai/human, no `origin`): + // ensureDimensionRoots adopts `source` as authorship and leaves the producing + // `source` key undefined. The server-owned resolve paths must fail LOUD rather + // than let an undefined root id degrade into a stranded root-level create. + describe('source root distinctness guard (mid-rename, WP-O6-gated)', () => { + const midRenameLevels = () => ({ + '': [ + { id: 'root-category', name: 'category', children_count: 0 }, + { id: 'root-intent', name: 'intent', children_count: 5 }, + { id: 'root-source', name: 'source', children_count: 2 }, + { id: 'root-type', name: 'type', children_count: 2 }, + ], + 'root-source': [ + { id: 'legacy-ai', name: 'ai', parent_id: 'root-source' }, + { id: 'legacy-human', name: 'human', parent_id: 'root-source' }, + ], + }); + + it('ensureDimensionRoots leaves the producing `source` key undefined', async () => { + const transport = { + listProjectTags: makeListProjectTagsStub(midRenameLevels()), + createProjectTags: sinon.stub(), + }; + const roots = await ensureDimensionRoots(transport, WS, PROJECT, fakeLog()); + expect(roots.get('origin')).to.equal('root-source'); + expect(roots.get('source')).to.equal(undefined); + }); + + it('ensureServerOwnedValue(source) throws a clear 502 and issues NO root-level create', async () => { + const createProjectTags = sinon.stub(); + const transport = { + listProjectTags: makeListProjectTagsStub(midRenameLevels()), + createProjectTags, + }; + const err = await ensureServerOwnedValue(transport, WS, PROJECT, 'source', 'config', fakeLog()) + .then(() => null, (e) => e); + expect(err).to.be.an('error'); + expect(err.status).to.equal(502); + expect(err.message).to.match(/source dimension root not provisioned/); + expect(createProjectTags).to.not.have.been.called; + }); + + it('resolveClosedValueInjection(source) — the injector path — throws and creates nothing', async () => { + const createProjectTags = sinon.stub(); + const transport = { + listProjectTags: makeListProjectTagsStub(midRenameLevels()), + createProjectTags, + }; + const err = await resolveClosedValueInjection(transport, WS, PROJECT, 'source', 'config', fakeLog()) + .then(() => null, (e) => e); + expect(err).to.be.an('error'); + expect(err.status).to.equal(502); + expect(err.message).to.match(/source dimension root not provisioned/); + expect(createProjectTags).to.not.have.been.called; }); });