From 7bbc71d7f575b6926a4b8adceed8c9bb0120d054 Mon Sep 17 00:00:00 2001 From: Alicia Adriani Date: Mon, 20 Jul 2026 18:28:20 +0200 Subject: [PATCH 1/9] =?UTF-8?q?feat(serenity):=20tolerant=20origin/source?= =?UTF-8?q?=20authorship-root=20resolver;=20rename=20source=E2=86=92origin?= =?UTF-8?q?=20(LLMO-6274)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WP-O2a of the origin rename (serenity-docs#46). Renames the authorship dimension root `source` → `origin` in api-service, and makes the tag-tree resolver TOLERANT of both names for the duration of the in-place rename so it never mints a second authorship root (origin-dimension.md §8). - prompt-tags.js: DIMENSION.SOURCE→ORIGIN; DIMENSION_ROOT_NAMES; SOURCE_VALUE→ ORIGIN_VALUE; CLOSED_DIMENSION_VALUES/CLOSED_DIMENSIONS; STANDARD_PROMPT_TAG_VALUES. Adds LEGACY_AUTHORSHIP_ROOT_NAME ('source') for the resolver's fallback. - tag-tree.js: ensureDimensionRoots resolves the authorship root tolerantly — prefers `origin`, else adopts a legacy `source` root IN PLACE (guarded by childrenAreAuthorship: children ⊆ {ai,human}, so the companion producing-system `source` dimension is never mistaken for authorship), and creates `origin` only when neither exists. The returned map's `origin` key carries whichever physical root was resolved, so provisionDimensionTree / ensureClosedValue inherit the tolerance unchanged. ensureChildren gains an optional pre-read arg so the resolve costs no extra root-level read on the common path. Removed with the fallback by WP-O6. - New projects are provisioned with `origin` (via provisionDimensionTree). - Tests: fixture + suites renamed source→origin; added two tolerant-resolver tests (adopt-legacy-source-in-place; guard against a non-authorship `source` root). type-check + lint + full unit suite (15264 passing) green. NOTE: the it/ integration (test/it/, excluded from `npm test`) pulls the PE-client mock image and needs WP-O1 (spacecat-shared#1831) released to exercise the origin root + legacy-source seed. Co-Authored-By: Claude Opus 4.8 --- .../serenity/handlers/markets-subworkspace.js | 8 +- src/support/serenity/handlers/tags.js | 4 +- src/support/serenity/prompt-tags.js | 40 ++++++--- src/support/serenity/tag-tree.js | 82 ++++++++++++++++- test/support/serenity/fixtures/tag-tree.js | 16 ++-- .../handlers/markets-subworkspace.test.js | 2 +- .../support/serenity/handlers/prompts.test.js | 12 +-- test/support/serenity/handlers/tags.test.js | 38 ++++---- test/support/serenity/prompt-tags.test.js | 12 +-- test/support/serenity/tag-tree.test.js | 89 +++++++++++++++---- 10 files changed, 220 insertions(+), 83 deletions(-) diff --git a/src/support/serenity/handlers/markets-subworkspace.js b/src/support/serenity/handlers/markets-subworkspace.js index 1ecd8cdd73..c848ad28c1 100644 --- a/src/support/serenity/handlers/markets-subworkspace.js +++ b/src/support/serenity/handlers/markets-subworkspace.js @@ -448,7 +448,7 @@ export async function handleCreateMarketSubworkspace( } // Provision the dimension-root taxonomy on the project (independent of prompts), - // so classification can later apply intent/source/type values per prompt and the + // so classification can later apply intent/origin/type values per prompt and the // Categories surface has a `category` root to hang customer categories under. // Idempotent (resolve-before-create), and unconditional: every project carries // exactly the four dimension roots, whether or not it has prompts yet. @@ -798,7 +798,7 @@ export async function handleListTagsSubworkspace(transport, workspaceId, query, }), ]); // Merge by ID, not by name. Names are unique only per (project, parent), so a - // sub-category `human` and the `source` value `human` are two distinct tags — + // sub-category `human` and the `origin` value `human` are two distinct tags — // keying by name silently drops one of them. const byId = new Map(); // Both sources back-fill a missing upstream id with the tag's own name (a @@ -809,8 +809,8 @@ export async function handleListTagsSubworkspace(transport, workspaceId, query, // Placeholders are keyed by name, not by a synthetic composite: an id-less // entry carries ONLY its bare name (`id === name`), so two id-less tags sharing // a name are indistinguishable here — there is no id to tell a `category` value - // `human` from a `source` value `human` once both arrive without one. Keying by - // `(name, source)` would just emit two identical `{ id: name, name }` rows, a + // `human` from an `origin` value `human` once both arrive without one. Keying by + // `(name, dimension)` would just emit two identical `{ id: name, name }` rows, a // duplicate that is worse than the collapse. So they intentionally collapse to // one; the by-id merge above is what actually preserves two same-named tags, // and it fires whenever either carries a real upstream id (the common case). diff --git a/src/support/serenity/handlers/tags.js b/src/support/serenity/handlers/tags.js index 265233eff2..9c6d4d8859 100644 --- a/src/support/serenity/handlers/tags.js +++ b/src/support/serenity/handlers/tags.js @@ -37,13 +37,13 @@ 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`, `source`, `type`) on a market's project — the + * (`category`, `intent`, `origin`, `type`) 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` / `source` / `type`) have a fixed value + * 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 diff --git a/src/support/serenity/prompt-tags.js b/src/support/serenity/prompt-tags.js index 5ce2138d2b..55f7db44a1 100644 --- a/src/support/serenity/prompt-tags.js +++ b/src/support/serenity/prompt-tags.js @@ -19,7 +19,7 @@ * serenity flow. * * A tag's DIMENSION is its root ancestor, not a prefix on its name. Every - * project's tag tree has exactly four roots — `category`, `intent`, `source`, + * project's tag tree has exactly four roots — `category`, `intent`, `origin`, * `type` — and every tag value is a bare-named descendant of one of them. No * tag name contains a `:`. A tag's dimension is therefore `path[0]` of the * upstream breadcrumb (verified against the live Semrush API: `path[]` is a @@ -30,8 +30,13 @@ * The upstream API caps neither, so nothing here does either. * * Names are NOT unique on their own — upstream uniqueness is scoped per - * `(project, parent)`. A sub-category named `human` and the `source` value + * `(project, parent)`. A sub-category named `human` and the `origin` value * `human` are two distinct tags. Never key a tag by name alone; key by id. + * + * The authorship root is being renamed `source` → `origin` in place across the + * live projects (origin-dimension.md). Until that migration's contract phase + * (WP-O6) lands, the tag-tree resolver tolerates BOTH names — see + * {@link LEGACY_AUTHORSHIP_ROOT_NAME} and `tag-tree.js`. */ /** @@ -40,20 +45,27 @@ export const DIMENSION = Object.freeze({ CATEGORY: 'category', INTENT: 'intent', - SOURCE: 'source', + ORIGIN: 'origin', TYPE: 'type', }); +/** + * 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. + */ +export const LEGACY_AUTHORSHIP_ROOT_NAME = 'source'; + /** Root names, in the order they are provisioned on a project. */ export const DIMENSION_ROOT_NAMES = Object.freeze([ DIMENSION.CATEGORY, DIMENSION.INTENT, - DIMENSION.SOURCE, + DIMENSION.ORIGIN, DIMENSION.TYPE, ]); -/** `source` values — who authored the prompt. */ -export const SOURCE_VALUE = Object.freeze({ +/** `origin` values — who authored the prompt. */ +export const ORIGIN_VALUE = Object.freeze({ AI: 'ai', HUMAN: 'human', }); @@ -98,14 +110,14 @@ export const TYPE_VALUE = Object.freeze({ */ export const CLOSED_DIMENSION_VALUES = Object.freeze({ [DIMENSION.INTENT]: Object.freeze(Object.values(INTENT_VALUE)), - [DIMENSION.SOURCE]: Object.freeze(Object.values(SOURCE_VALUE)), + [DIMENSION.ORIGIN]: Object.freeze(Object.values(ORIGIN_VALUE)), [DIMENSION.TYPE]: Object.freeze(Object.values(TYPE_VALUE)), }); /** The closed dimensions — fixed vocabularies, never customer-authored. */ export const CLOSED_DIMENSIONS = Object.freeze([ DIMENSION.INTENT, - DIMENSION.SOURCE, + DIMENSION.ORIGIN, DIMENSION.TYPE, ]); @@ -120,17 +132,17 @@ export const OPEN_DIMENSIONS = Object.freeze([DIMENSION.CATEGORY]); export const ALL_DIMENSIONS = Object.freeze([...OPEN_DIMENSIONS, ...CLOSED_DIMENSIONS]); /** - * The closed-dimension values applied to EVERY AI-generated prompt: `source:ai` - * (AI-authored) plus the default `Informational` intent (the most common intent - * for brand-topic prompts; re-classification can refine it later). The `type` - * value is classified per prompt at generation time (branded vs non-branded — - * see the handler), so it is NOT seeded here. + * The closed-dimension values applied to EVERY AI-generated prompt: the `origin` + * value `ai` (AI-authored) plus the default `Informational` intent (the most + * common intent for brand-topic prompts; re-classification can refine it later). + * The `type` value is classified per prompt at generation time (branded vs + * non-branded — see the handler), so it is NOT seeded here. * * Each entry names a dimension and the bare value beneath it; the caller resolves * the pair to an upstream tag id against the project's tree. */ export const STANDARD_PROMPT_TAG_VALUES = Object.freeze([ - Object.freeze({ dimension: DIMENSION.SOURCE, name: SOURCE_VALUE.AI }), + Object.freeze({ dimension: DIMENSION.ORIGIN, name: ORIGIN_VALUE.AI }), Object.freeze({ dimension: DIMENSION.INTENT, name: INTENT_VALUE.INFORMATIONAL }), ]); diff --git a/src/support/serenity/tag-tree.js b/src/support/serenity/tag-tree.js index 825d41db88..05570902e5 100644 --- a/src/support/serenity/tag-tree.js +++ b/src/support/serenity/tag-tree.js @@ -51,6 +51,8 @@ import { DIMENSION_ROOT_NAMES, CLOSED_DIMENSION_VALUES, CLOSED_DIMENSIONS, + ORIGIN_VALUE, + LEGACY_AUTHORSHIP_ROOT_NAME, } from './prompt-tags.js'; /** @@ -128,6 +130,9 @@ export async function indexLevelByName(transport, semrushWorkspaceId, projectId, * @param {string} parentId - '' to create at the root level. * @param {readonly string[]} wanted - bare names that must exist under `parentId`. * @param {object} [log] - logger. + * @param {Map} [preRead] - an already-read `indexLevelByName` of this + * parent's level, reused instead of reading it again. Lets a caller that has already + * inspected the level (e.g. the tolerant root resolver) avoid a redundant read. * @returns {Promise<{ byName: Map, createdNames: string[] }>} * `byName` maps every wanted name to its tag id. */ @@ -138,8 +143,10 @@ export async function ensureChildren( parentId, wanted, log, + preRead, ) { - const existing = await indexLevelByName(transport, semrushWorkspaceId, projectId, parentId, log); + const existing = preRead + ?? await indexLevelByName(transport, semrushWorkspaceId, projectId, parentId, log); const missing = wanted.filter((name) => !existing.has(name)); if (missing.length === 0) { return { byName: existing, createdNames: [] }; @@ -210,27 +217,94 @@ export async function ensureChildren( return { byName, createdNames: missing.filter((name) => existing.has(name)) }; } +/** + * True when a root tag's children are a subset of the authorship vocabulary + * `{ai, human}`. A childless root passes vacuously (a not-yet-populated authorship + * root); a `source` root carrying producing-system values (`config`, `gsc`, …) does + * NOT — that is the companion `source` dimension (source-dimension.md §9), not + * authorship. This guard is what lets the two names coexist safely during the rename. + * + * @param {object} transport + * @param {string} semrushWorkspaceId + * @param {string} projectId + * @param {string} rootId + * @param {object} [log] + * @returns {Promise} + */ +async function childrenAreAuthorship(transport, semrushWorkspaceId, projectId, rootId, log) { + const children = await indexLevelByName(transport, semrushWorkspaceId, projectId, rootId, log); + const authorship = new Set(/** @type {readonly string[]} */ (Object.values(ORIGIN_VALUE))); + return [...children.keys()].every((name) => authorship.has(name)); +} + /** * Resolves the four 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. * + * The authorship root is resolved TOLERANTLY while the `source` → `origin` rename is + * in flight (origin-dimension.md): an existing `origin` root, OR a legacy `source` + * root whose children are a subset of `{ai, human}` ({@link childrenAreAuthorship} — + * the guard that keeps it from adopting the companion producing-system `source` + * dimension), satisfies the authorship dimension in place. `origin` is created ONLY + * when neither exists — a blind create would mint an empty SECOND authorship root the + * moment code and data disagree (origin-dimension.md §8). Either way the returned + * 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. + * * @param {object} transport - Serenity transport (Semrush proxy client). * @param {string} semrushWorkspaceId * @param {string} projectId * @param {object} [log] - logger. - * @returns {Promise>} root name → tag id, for all four roots. + * @returns {Promise>} root name → tag id, in root order, with the + * `origin` key carrying the resolved authorship root's id. */ 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). + 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; + } + } + + // 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, + ); + // 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). const { byName } = await ensureChildren( transport, semrushWorkspaceId, projectId, '', - DIMENSION_ROOT_NAMES, + wanted, log, + existing, ); - return byName; + + // Return the roots in canonical order, with `origin` carrying the resolved id. + const roots = new Map(); + for (const name of DIMENSION_ROOT_NAMES) { + roots.set( + name, + name === DIMENSION.ORIGIN ? (authorshipId ?? byName.get(name)) : byName.get(name), + ); + } + return roots; } /** diff --git a/test/support/serenity/fixtures/tag-tree.js b/test/support/serenity/fixtures/tag-tree.js index 5adcfe7ef2..7d15eb44d6 100644 --- a/test/support/serenity/fixtures/tag-tree.js +++ b/test/support/serenity/fixtures/tag-tree.js @@ -28,7 +28,7 @@ import { * does not exist yields an empty page, exactly as upstream does. * * The fixture deliberately carries a cross-dimension name collision: the - * sub-category `human` under `Running Shoes` and the `source` value `human` are + * sub-category `human` under `Running Shoes` and the `origin` value `human` are * different tags with the same bare name. Any handler that keys tags by name * rather than id collapses them, and the tests that use this tree catch it. */ @@ -36,7 +36,7 @@ import { export const TAG_IDS = Object.freeze({ categoryRoot: 'root-category', intentRoot: 'root-intent', - sourceRoot: 'root-source', + originRoot: 'root-origin', typeRoot: 'root-type', intentInformational: 'intent-informational', @@ -45,8 +45,8 @@ export const TAG_IDS = Object.freeze({ intentTransactional: 'intent-transactional', intentNavigational: 'intent-navigational', - sourceAi: 'source-ai', - sourceHuman: 'source-human', + originAi: 'origin-ai', + originHuman: 'origin-human', typeBranded: 'type-branded', typeNonBranded: 'type-non-branded', @@ -58,7 +58,7 @@ export const TAG_IDS = Object.freeze({ const ROOT_IDS = Object.freeze({ category: TAG_IDS.categoryRoot, intent: TAG_IDS.intentRoot, - source: TAG_IDS.sourceRoot, + origin: TAG_IDS.originRoot, type: TAG_IDS.typeRoot, }); @@ -70,7 +70,7 @@ const CLOSED_VALUE_IDS = Object.freeze({ Transactional: TAG_IDS.intentTransactional, Navigational: TAG_IDS.intentNavigational, }, - source: { ai: TAG_IDS.sourceAi, human: TAG_IDS.sourceHuman }, + origin: { ai: TAG_IDS.originAi, human: TAG_IDS.originHuman }, type: { branded: TAG_IDS.typeBranded, 'non-branded': TAG_IDS.typeNonBranded }, }); @@ -104,7 +104,7 @@ export function dimensionTreeLevels(extraLevels = {}) { })); const closedLevels = {}; - for (const dimension of ['intent', 'source', 'type']) { + for (const dimension of ['intent', 'origin', 'type']) { closedLevels[ROOT_IDS[dimension]] = CLOSED_DIMENSION_VALUES[dimension].map((value) => ( upstreamTag({ id: CLOSED_VALUE_IDS[dimension][value], @@ -125,7 +125,7 @@ export function dimensionTreeLevels(extraLevels = {}) { childrenCount: 1, path: CATEGORY_CRUMB, })], - // Depth 3. Shares its bare name with the `source` value `human`. + // Depth 3. Shares its bare name with the `origin` value `human`. [TAG_IDS.categoryRunningShoes]: [upstreamTag({ id: TAG_IDS.subCategoryHuman, name: 'human', diff --git a/test/support/serenity/handlers/markets-subworkspace.test.js b/test/support/serenity/handlers/markets-subworkspace.test.js index 8348b7e3dd..d057b13f6b 100644 --- a/test/support/serenity/handlers/markets-subworkspace.test.js +++ b/test/support/serenity/handlers/markets-subworkspace.test.js @@ -34,7 +34,7 @@ use(sinonChai); // Every generated prompt carries the two standard values (source=ai, // intent=Informational); the third tag is the per-prompt computed `type`. -const STANDARD_IDS = [TAG_IDS.sourceAi, TAG_IDS.intentInformational]; +const STANDARD_IDS = [TAG_IDS.originAi, TAG_IDS.intentInformational]; const BRAND = 'brand-1'; const WS = 'subworkspace-ws-1'; diff --git a/test/support/serenity/handlers/prompts.test.js b/test/support/serenity/handlers/prompts.test.js index 5035fb99c5..8a21d6003a 100644 --- a/test/support/serenity/handlers/prompts.test.js +++ b/test/support/serenity/handlers/prompts.test.js @@ -278,11 +278,11 @@ describe('handlers/prompts.js — handleListPrompts', () => { ], }, { - id: TAG_IDS.sourceHuman, + id: TAG_IDS.originHuman, name: 'human', children_count: 0, - parent_id: TAG_IDS.sourceRoot, - path: [{ id: TAG_IDS.sourceRoot, name: 'source' }], + parent_id: TAG_IDS.originRoot, + path: [{ id: TAG_IDS.originRoot, name: 'origin' }], }, ], }], @@ -296,9 +296,9 @@ describe('handlers/prompts.js — handleListPrompts', () => { const { tags } = result.items[0]; expect(tags).to.have.lengthOf(2); - expect(tags.map((t) => t.path[0].name)).to.deep.equal(['category', 'source']); + expect(tags.map((t) => t.path[0].name)).to.deep.equal(['category', 'origin']); expect(tags[0].parentId).to.equal(TAG_IDS.categoryRunningShoes); - expect(tags[1].parentId).to.equal(TAG_IDS.sourceRoot); + expect(tags[1].parentId).to.equal(TAG_IDS.originRoot); // The deprecated name-keyed view collapses them; only one id survives. expect(Object.keys(result.items[0].tagMap)).to.deep.equal(['human']); // Listing prompts costs exactly ONE upstream call — no tag-tree walk. @@ -1926,7 +1926,7 @@ describe('handlers/prompts.js — unified type classification (serenity-docs#31) // The four roots are created at the root level, then `branded` beneath // the freshly-minted `type` root. expect(createProjectTags.firstCall.args[2]).to.deep.equal([ - 'category', 'intent', 'source', 'type', + 'category', 'intent', 'origin', 'type', ]); expect(createProjectTags.firstCall.args[3]).to.deep.equal({}); expect(createProjectTags.secondCall.args[2]).to.deep.equal(['branded']); diff --git a/test/support/serenity/handlers/tags.test.js b/test/support/serenity/handlers/tags.test.js index 0e5a2e68d5..4ad7525bc9 100644 --- a/test/support/serenity/handlers/tags.test.js +++ b/test/support/serenity/handlers/tags.test.js @@ -146,7 +146,7 @@ describe('serenity tags handler (POST /serenity/tags)', () => { createProjectTags.onFirstCall().resolves([ { id: 'r-category', name: 'category' }, { id: 'r-intent', name: 'intent' }, - { id: 'r-source', name: 'source' }, + { id: 'r-source', name: 'origin' }, { id: 'r-type', name: 'type' }, ]); createProjectTags.onSecondCall().resolves([ @@ -165,7 +165,7 @@ describe('serenity tags handler (POST /serenity/tags)', () => { ); expect(res.status).to.equal(201); - expect(createProjectTags.firstCall.args[2]).to.deep.equal(['category', 'intent', 'source', 'type']); + expect(createProjectTags.firstCall.args[2]).to.deep.equal(['category', 'intent', 'origin', 'type']); 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' }); @@ -193,7 +193,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', 'source', 'type']); + .to.deep.equal(['category', 'intent', 'origin', 'type']); }); it('502s when the upstream create response carries no usable id', async () => { @@ -231,7 +231,7 @@ describe('serenity tags handler (POST /serenity/tags)', () => { it('400s a name that shadows a reserved dimension root', async () => { const transport = makeTransport(); const dataAccess = makeDataAccess({ getSemrushProjectId: () => 'proj-1' }); - for (const name of ['category', 'intent', 'source', 'type']) { + for (const name of ['category', 'intent', 'origin', 'type']) { // eslint-disable-next-line no-await-in-loop await expect(handler.handleCreateTag( transport, @@ -321,11 +321,11 @@ describe('serenity tags handler (POST /serenity/tags)', () => { it('creates a closed-dimension value under its root when absent (200, created:true)', async () => { // The `source` root exists but is empty, so `ai` must be minted beneath it. const levels = dimensionTreeLevels(); - levels[TAG_IDS.sourceRoot] = []; + levels[TAG_IDS.originRoot] = []; const transport = makeTransport({ listProjectTags: makeListProjectTagsStub(levels), createProjectTags: sinon.stub().resolves([ - { id: 'tag-source-ai', name: 'ai', parent_id: TAG_IDS.sourceRoot }, + { id: 'tag-source-ai', name: 'ai', parent_id: TAG_IDS.originRoot }, ]), }); const dataAccess = makeDataAccess({ getSemrushProjectId: () => 'proj-1' }); @@ -335,17 +335,17 @@ describe('serenity tags handler (POST /serenity/tags)', () => { BRAND, WORKSPACE, { - type: 'source', name: 'ai', geoTargetId: 2840, languageCode: 'en', + type: 'origin', name: 'ai', geoTargetId: 2840, languageCode: 'en', }, fakeLog(), ); expect(res.status).to.equal(200); expect(res.body).to.include({ - type: 'source', name: 'ai', id: 'tag-source-ai', parentId: TAG_IDS.sourceRoot, created: true, + type: 'origin', name: 'ai', id: 'tag-source-ai', parentId: TAG_IDS.originRoot, created: true, }); // A closed value is a CHILD of its dimension root, never a root itself. expect(transport.createProjectTags) - .to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-1', ['ai'], { parentId: TAG_IDS.sourceRoot }); + .to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-1', ['ai'], { parentId: TAG_IDS.originRoot }); }); it('resolves an EXISTING closed-dimension value without creating a duplicate (200, created:false)', async () => { @@ -415,7 +415,7 @@ describe('serenity tags handler (POST /serenity/tags)', () => { BRAND, WORKSPACE, { - type: 'source', name: 'ai', geoTargetId: 2840, languageCode: 'en', parentId: 'root-1', + type: 'origin', name: 'ai', geoTargetId: 2840, languageCode: 'en', parentId: 'root-1', }, fakeLog(), )).to.be.rejected.then((err) => expect(err.status).to.equal(400)); @@ -560,24 +560,24 @@ describe('serenity tags handler (POST /serenity/tags)', () => { // The `source` root exists but is empty, so `ai` must be minted beneath it, // and a newly minted value must be published so it is live rather than draft. const levels = dimensionTreeLevels(); - levels[TAG_IDS.sourceRoot] = []; + levels[TAG_IDS.originRoot] = []; const transport = makeTransport({ listProjectTags: makeListProjectTagsStub(levels), createProjectTags: sinon.stub().resolves([ - { id: 'tag-source-ai', name: 'ai', parent_id: TAG_IDS.sourceRoot }, + { id: 'tag-source-ai', name: 'ai', parent_id: TAG_IDS.originRoot }, ]), }); const res = await handler.handleCreateTagSubworkspace( transport, WORKSPACE, { - type: 'source', name: 'ai', geoTargetId: 2840, languageCode: 'en', + type: 'origin', name: 'ai', geoTargetId: 2840, languageCode: 'en', }, fakeLog(), ); expect(res.status).to.equal(200); expect(res.body).to.include({ - type: 'source', name: 'ai', id: 'tag-source-ai', parentId: TAG_IDS.sourceRoot, created: true, + type: 'origin', name: 'ai', id: 'tag-source-ai', parentId: TAG_IDS.originRoot, created: true, }); expect(transport.publishProject).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-sub-1'); }); @@ -728,7 +728,7 @@ describe('serenity tags handler (POST /serenity/tags)', () => { dataAccess, BRAND, WORKSPACE, - { ...validBody, parentId: TAG_IDS.sourceHuman }, + { ...validBody, parentId: TAG_IDS.originHuman }, fakeLog(), ).then(() => null, (e) => e); @@ -879,7 +879,7 @@ describe('serenity tags handler (POST /serenity/tags)', () => { it('400s on a rename to a reserved dimension root name', async () => { const transport = makeTransport(); const dataAccess = makeDataAccess({ getSemrushProjectId: () => 'proj-1' }); - for (const name of ['category', 'intent', 'source', 'type']) { + for (const name of ['category', 'intent', 'origin', 'type']) { // eslint-disable-next-line no-await-in-loop await expect(handler.handleUpdateTag( transport, @@ -1133,13 +1133,13 @@ describe('serenity tags handler (POST /serenity/tags)', () => { dataAccess, BRAND, WORKSPACE, - TAG_IDS.sourceHuman, + TAG_IDS.originHuman, { name: 'manual', geoTargetId: 2840, languageCode: 'en' }, fakeLog(), ).then(() => null, (e) => e); expect(err.status).to.equal(400); - expect(err.message).to.match(/closed "source" dimension cannot be renamed or re-parented/); + expect(err.message).to.match(/closed "origin" dimension cannot be renamed or re-parented/); expect(transport.updateProjectTag).to.not.have.been.called; }); @@ -1154,7 +1154,7 @@ describe('serenity tags handler (POST /serenity/tags)', () => { TAG_IDS.categoryRunningShoes, { name: 'Running Shoes', - parentId: TAG_IDS.sourceRoot, + parentId: TAG_IDS.originRoot, geoTargetId: 2840, languageCode: 'en', }, diff --git a/test/support/serenity/prompt-tags.test.js b/test/support/serenity/prompt-tags.test.js index ed0b188a36..75665ffeed 100644 --- a/test/support/serenity/prompt-tags.test.js +++ b/test/support/serenity/prompt-tags.test.js @@ -15,7 +15,7 @@ import { expect } from 'chai'; import { DIMENSION, DIMENSION_ROOT_NAMES, - SOURCE_VALUE, + ORIGIN_VALUE, INTENT_VALUE, TYPE_VALUE, CLOSED_DIMENSION_VALUES, @@ -31,13 +31,13 @@ import { 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', 'source', 'type']); + expect([...DIMENSION_ROOT_NAMES]).to.deep.equal(['category', 'intent', 'origin', 'type']); 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]); - expect([...CLOSED_DIMENSIONS]).to.deep.equal(['intent', 'source', 'type']); + expect([...CLOSED_DIMENSIONS]).to.deep.equal(['intent', 'origin', 'type']); expect([...ALL_DIMENSIONS].sort()).to.deep.equal([...DIMENSION_ROOT_NAMES].sort()); }); @@ -63,9 +63,9 @@ describe('serenity prompt-tags taxonomy', () => { }); it('carries the source and type vocabularies', () => { - expect([...closedValuesOf(DIMENSION.SOURCE)]).to.deep.equal(['ai', 'human']); + expect([...closedValuesOf(DIMENSION.ORIGIN)]).to.deep.equal(['ai', 'human']); expect([...closedValuesOf(DIMENSION.TYPE)]).to.deep.equal(['branded', 'non-branded']); - expect(SOURCE_VALUE.AI).to.equal('ai'); + expect(ORIGIN_VALUE.AI).to.equal('ai'); expect(TYPE_VALUE.NON_BRANDED).to.equal('non-branded'); }); @@ -88,7 +88,7 @@ describe('serenity prompt-tags taxonomy', () => { describe('STANDARD_PROMPT_TAG_VALUES', () => { it('seeds source=ai + intent=Informational only (type is classified per prompt)', () => { expect(STANDARD_PROMPT_TAG_VALUES.map((t) => [t.dimension, t.name])).to.deep.equal([ - ['source', 'ai'], + ['origin', 'ai'], ['intent', 'Informational'], ]); }); diff --git a/test/support/serenity/tag-tree.test.js b/test/support/serenity/tag-tree.test.js index 6d69f48555..0e04d1cf64 100644 --- a/test/support/serenity/tag-tree.test.js +++ b/test/support/serenity/tag-tree.test.js @@ -260,7 +260,7 @@ describe('serenity tag-tree', () => { createProjectTags: sinon.stub(), }; const roots = await ensureDimensionRoots(transport, WS, PROJECT, fakeLog()); - expect([...roots.keys()]).to.deep.equal(['category', 'intent', 'source', 'type']); + expect([...roots.keys()]).to.deep.equal(['category', 'intent', 'origin', 'type']); expect(transport.createProjectTags).to.not.have.been.called; }); @@ -270,9 +270,60 @@ 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', 'source', 'type']); + .to.deep.equal(['category', 'intent', 'origin', 'type']); expect(roots.get('type')).to.equal('created::type'); }); + + it('adopts a legacy `source` authorship root in place, minting no second `origin`', async () => { + // A project the reshape has not yet renamed: its authorship root is still `source`, + // with `ai`/`human` beneath it (origin-dimension.md §7 gate 3). + const legacyLevels = { + '': [ + { 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' }, + ], + }; + const transport = { + listProjectTags: makeListProjectTagsStub(legacyLevels), + createProjectTags: sinon.stub(), + }; + const roots = await ensureDimensionRoots(transport, WS, PROJECT, fakeLog()); + // The `origin` key maps to the physical `source` root — adopted in place … + expect(roots.get('origin')).to.equal('root-source'); + // … and NOTHING was created: no empty second authorship root (spec §8). + expect(transport.createProjectTags).to.not.have.been.called; + }); + + it('does NOT adopt a `source` root carrying non-authorship values; creates `origin`', async () => { + // The companion producing-system `source` dimension (source-dimension.md §9): its + // children are not {ai, human}, so it must not be mistaken for authorship. + const created = []; + const levels = { + '': [ + { id: 'root-category', name: 'category', children_count: 0 }, + { id: 'root-intent', name: 'intent', children_count: 5 }, + { id: 'root-source', name: 'source', children_count: 1 }, + { id: 'root-type', name: 'type', children_count: 2 }, + ], + 'root-source': [{ id: 'src-config', name: 'config', parent_id: 'root-source' }], + }; + const transport = { + listProjectTags: makeListProjectTagsStub(levels), + createProjectTags: sinon.stub().callsFake((ws, pid, names) => { + created.push(...names); + return Promise.resolve(names.map((n) => ({ id: `made-${n}`, name: n }))); + }), + }; + const roots = await ensureDimensionRoots(transport, WS, PROJECT, fakeLog()); + expect(created).to.deep.equal(['origin']); + expect(roots.get('origin')).to.equal('made-origin'); + }); }); describe('provisionDimensionTree', () => { @@ -284,7 +335,7 @@ describe('serenity tag-tree', () => { const { roots, values } = await provisionDimensionTree(transport, WS, PROJECT, fakeLog()); expect(roots.get('category')).to.equal(TAG_IDS.categoryRoot); - expect([...values.keys()]).to.deep.equal(['intent', 'source', 'type']); + expect([...values.keys()]).to.deep.equal(['intent', 'origin', 'type']); // The open dimension's children are customer content, never provisioned. expect(values.has('category')).to.equal(false); expect([...values.get('intent').keys()]).to.deep.equal([ @@ -300,7 +351,7 @@ describe('serenity tag-tree', () => { const { values } = await provisionDimensionTree(transport, WS, PROJECT, fakeLog()); // Roots first, then one call per closed dimension. expect(createProjectTags).to.have.callCount(4); - expect(values.get('source').get('ai')).to.equal('created:created::source:ai'); + expect(values.get('origin').get('ai')).to.equal('created:created::origin:ai'); expect(values.get('intent').get('Navigational')) .to.equal('created:created::intent:Navigational'); }); @@ -337,8 +388,8 @@ 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', 'source', 'type']); - expect(values.get('source')?.get('ai')).to.equal(TAG_IDS.sourceAi); + expect([...roots.keys()]).to.have.members(['category', 'intent', 'origin', 'type']); + 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. expect(values.has('category')).to.equal(false); @@ -352,25 +403,25 @@ describe('serenity tag-tree', () => { listProjectTags: makeListProjectTagsStub(), createProjectTags: sinon.stub(), }; - const res = await ensureClosedValue(transport, WS, PROJECT, 'source', 'ai', fakeLog()); + const res = await ensureClosedValue(transport, WS, PROJECT, 'origin', 'ai', fakeLog()); expect(res).to.deep.equal({ - id: TAG_IDS.sourceAi, rootId: TAG_IDS.sourceRoot, created: false, + id: TAG_IDS.originAi, rootId: TAG_IDS.originRoot, created: false, }); expect(transport.createProjectTags).to.not.have.been.called; }); it('creates a missing value under its root and reports created:true', async () => { const levels = dimensionTreeLevels(); - levels[TAG_IDS.sourceRoot] = []; + levels[TAG_IDS.originRoot] = []; const transport = { listProjectTags: makeListProjectTagsStub(levels), createProjectTags: sinon.stub().resolves([ - { id: 'made-ai', name: 'ai', parent_id: TAG_IDS.sourceRoot }, + { id: 'made-ai', name: 'ai', parent_id: TAG_IDS.originRoot }, ]), }; - const res = await ensureClosedValue(transport, WS, PROJECT, 'source', 'ai', fakeLog()); + const res = await ensureClosedValue(transport, WS, PROJECT, 'origin', 'ai', fakeLog()); expect(res).to.deep.equal({ - id: 'made-ai', rootId: TAG_IDS.sourceRoot, created: true, + id: 'made-ai', rootId: TAG_IDS.originRoot, created: true, }); }); @@ -380,7 +431,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, 'source', 'ai', fakeLog()) + const err = await ensureClosedValue(transport, WS, PROJECT, 'origin', 'ai', fakeLog()) .then(() => null, (e) => e); expect(err).to.be.an('error'); expect(err.status).to.equal(502); @@ -467,10 +518,10 @@ describe('serenity tag-tree', () => { transport, WS, PROJECT, - [TAG_IDS.sourceHuman, 'no-such-tag'], + [TAG_IDS.originHuman, 'no-such-tag'], fakeLog(), ); - expect(found.get(TAG_IDS.sourceHuman).rootName).to.equal('source'); + expect(found.get(TAG_IDS.originHuman).rootName).to.equal('origin'); expect(found.get('no-such-tag')).to.deep.equal({ kind: 'unknown', parentId: null, rootName: null, ancestorIds: [], }); @@ -505,9 +556,9 @@ describe('serenity tag-tree', () => { it('reports the same bare name under a different root as that other dimension', async () => { const transport = { listProjectTags: makeListProjectTagsStub() }; - const placed = await findTagsInTree(transport, WS, PROJECT, [TAG_IDS.sourceHuman], fakeLog()); - const found = placed.get(TAG_IDS.sourceHuman); - expect(found.rootName).to.equal('source'); + const placed = await findTagsInTree(transport, WS, PROJECT, [TAG_IDS.originHuman], fakeLog()); + const found = placed.get(TAG_IDS.originHuman); + expect(found.rootName).to.equal('origin'); }); it('reports an id absent from the tree as unknown', async () => { @@ -582,7 +633,7 @@ describe('serenity tag-tree', () => { // The cheap check — comparing against the three closed root ids — passes here. // Ancestry is what catches it. const transport = { listProjectTags: makeListProjectTagsStub() }; - const err = await assertParentWithinDimension(transport, WS, PROJECT, 'category', TAG_IDS.sourceHuman, fakeLog()).then(() => null, (e) => e); + const err = await assertParentWithinDimension(transport, WS, PROJECT, 'category', TAG_IDS.originHuman, fakeLog()).then(() => null, (e) => e); expect(err.status).to.equal(400); }); From 9e0f3a950ca28f36c03e98e4bf5b2d8c5a062bdf Mon Sep 17 00:00:00 2001 From: Alicia Adriani Date: Mon, 20 Jul 2026 19:12:32 +0200 Subject: [PATCH 2/9] =?UTF-8?q?fix(serenity):=20address=20MysticatBot=20re?= =?UTF-8?q?view=20=E2=80=94=20reserve=20legacy=20`source`,=20docs=20+=20co?= =?UTF-8?q?verage=20(LLMO-6274)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-blocking follow-ups from MysticatBot's approval of #2862: - Reserve the legacy authorship name `source` in `isDimensionRootName` so a customer cannot mint a `source` tag during the rename window (removed at WP-O6). - Fix two stale `source` references in tag-tree.js JSDoc/header comments. - Rename confusing fixture id `r-source` → `r-origin` for the `origin` root. - Add tests: dual-root prefer-`origin`, childless-`source` vacuous adopt, and the migration-window reservation of the `source` name. Co-Authored-By: Claude Opus 4.8 --- src/support/serenity/prompt-tags.js | 11 ++++- src/support/serenity/tag-tree.js | 4 +- test/support/serenity/handlers/tags.test.js | 19 ++++++++- test/support/serenity/tag-tree.test.js | 46 +++++++++++++++++++++ 4 files changed, 75 insertions(+), 5 deletions(-) diff --git a/src/support/serenity/prompt-tags.js b/src/support/serenity/prompt-tags.js index 55f7db44a1..77324187ae 100644 --- a/src/support/serenity/prompt-tags.js +++ b/src/support/serenity/prompt-tags.js @@ -147,15 +147,22 @@ export const STANDARD_PROMPT_TAG_VALUES = Object.freeze([ ]); /** - * True when `name` is one of the four dimension roots. Root names are reserved: + * True when `name` is a reserved dimension-root name. Root names are reserved: * a customer category may not be called `category`, and a closed value may not * be minted at the root level. * + * While the `source` → `origin` rename is in flight, the legacy authorship name + * ({@link LEGACY_AUTHORSHIP_ROOT_NAME}) is ALSO reserved — a customer must not be + * able to mint a tag named `source` during the migration window, or it could be + * mistaken for (or collide with) the legacy authorship root the tolerant resolver + * still adopts. Dropped with the rest of the fallback at WP-O6. + * * @param {string} name - a bare tag name. * @returns {boolean} */ export function isDimensionRootName(name) { - return (/** @type {readonly string[]} */ (DIMENSION_ROOT_NAMES)).includes(name); + return name === LEGACY_AUTHORSHIP_ROOT_NAME + || (/** @type {readonly string[]} */ (DIMENSION_ROOT_NAMES)).includes(name); } /** diff --git a/src/support/serenity/tag-tree.js b/src/support/serenity/tag-tree.js index 05570902e5..9712cb8a47 100644 --- a/src/support/serenity/tag-tree.js +++ b/src/support/serenity/tag-tree.js @@ -26,7 +26,7 @@ * Every create in this module is therefore resolve-before-create. Names are * unique per `(project, parent)`, not per project, so the resolve must be * scoped to the parent — a bare-name lookup across the whole tree would - * conflate a sub-category `human` with the `source` value `human`. + * conflate a sub-category `human` with the `origin` value `human`. * - Tag writes land in the project's DRAFT layer, and a default read serves the * LIVE view. Reads here go through {@link listProjectTagTree}, which passes * `draft: true`, so a tag this module just created is visible to the tag @@ -539,7 +539,7 @@ export async function provisionDimensionTree(transport, semrushWorkspaceId, proj * @param {object} transport - Serenity transport (Semrush proxy client). * @param {string} semrushWorkspaceId * @param {string} projectId - * @param {string} dimension - a closed dimension (`intent` / `source` / `type`). + * @param {string} dimension - a closed dimension (`intent` / `origin` / `type`). * @param {string} value - a bare value from that dimension's fixed vocabulary. * @param {object} [log] - logger. * @returns {Promise<{ id: string, rootId: string, created: boolean }>} `created` diff --git a/test/support/serenity/handlers/tags.test.js b/test/support/serenity/handlers/tags.test.js index 4ad7525bc9..2eefe20aa8 100644 --- a/test/support/serenity/handlers/tags.test.js +++ b/test/support/serenity/handlers/tags.test.js @@ -146,7 +146,7 @@ describe('serenity tags handler (POST /serenity/tags)', () => { createProjectTags.onFirstCall().resolves([ { id: 'r-category', name: 'category' }, { id: 'r-intent', name: 'intent' }, - { id: 'r-source', name: 'origin' }, + { id: 'r-origin', name: 'origin' }, { id: 'r-type', name: 'type' }, ]); createProjectTags.onSecondCall().resolves([ @@ -245,6 +245,23 @@ describe('serenity tags handler (POST /serenity/tags)', () => { expect(transport.createProjectTags).to.not.have.been.called; }); + it('400s the legacy `source` name too while the origin rename is in flight', async () => { + // Migration-window guard: until WP-O6, `source` is reserved alongside the four + // roots so a customer cannot mint a tag that collides with the legacy authorship + // root the tolerant resolver still adopts. + const transport = makeTransport(); + const dataAccess = makeDataAccess({ getSemrushProjectId: () => 'proj-1' }); + await expect(handler.handleCreateTag( + transport, + dataAccess, + BRAND, + WORKSPACE, + { ...validBody, name: 'source' }, + fakeLog(), + )).to.be.rejected.then((err) => expect(err.status).to.equal(400)); + expect(transport.createProjectTags).to.not.have.been.called; + }); + it('404s (marketNotFound) when no project backs the slice', async () => { const transport = makeTransport(); const dataAccess = makeDataAccess(null); diff --git a/test/support/serenity/tag-tree.test.js b/test/support/serenity/tag-tree.test.js index 0e04d1cf64..18282c974a 100644 --- a/test/support/serenity/tag-tree.test.js +++ b/test/support/serenity/tag-tree.test.js @@ -324,6 +324,52 @@ describe('serenity tag-tree', () => { expect(created).to.deep.equal(['origin']); expect(roots.get('origin')).to.equal('made-origin'); }); + + it('prefers `origin` over a legacy `source` when a project carries BOTH roots', async () => { + // A partially-migrated project: the new `origin` root already exists alongside + // the not-yet-cleaned-up legacy `source` root. `origin` must win, and the + // orphan `source` is left untouched (removed at WP-O6) — never a second mint. + const bothLevels = { + '': [ + { id: 'root-category', name: 'category', children_count: 0 }, + { id: 'root-intent', name: 'intent', children_count: 5 }, + { id: 'root-origin', name: 'origin', children_count: 2 }, + { id: 'root-source', name: 'source', children_count: 2 }, + { id: 'root-type', name: 'type', children_count: 2 }, + ], + }; + const transport = { + listProjectTags: makeListProjectTagsStub(bothLevels), + createProjectTags: sinon.stub(), + }; + const roots = await ensureDimensionRoots(transport, WS, PROJECT, fakeLog()); + // The `origin` key resolves to the real `origin` root, not the legacy `source`. + expect(roots.get('origin')).to.equal('root-origin'); + // Nothing was created and the orphan `source` was not touched. + expect(transport.createProjectTags).to.not.have.been.called; + }); + + it('adopts a CHILDLESS legacy `source` root in place (vacuous authorship guard)', async () => { + // The intentional vacuous-true branch of `childrenAreAuthorship`: a `source` + // root not yet populated is still an authorship root, so it is adopted rather + // than shadowed by a second `origin`. Documents the behavior until WP-O6. + const childlessLevels = { + '': [ + { id: 'root-category', name: 'category', children_count: 0 }, + { id: 'root-intent', name: 'intent', children_count: 5 }, + { id: 'root-source', name: 'source', children_count: 0 }, + { id: 'root-type', name: 'type', children_count: 2 }, + ], + 'root-source': [], + }; + const transport = { + listProjectTags: makeListProjectTagsStub(childlessLevels), + createProjectTags: sinon.stub(), + }; + const roots = await ensureDimensionRoots(transport, WS, PROJECT, fakeLog()); + expect(roots.get('origin')).to.equal('root-source'); + expect(transport.createProjectTags).to.not.have.been.called; + }); }); describe('provisionDimensionTree', () => { From 81ca3728f1f99907af407db6786041e0cade55a4 Mon Sep 17 00:00:00 2001 From: Alicia Adriani Date: Mon, 20 Jul 2026 19:36:33 +0200 Subject: [PATCH 3/9] test(serenity): harden origin-rename tests per MysticatBot round 2 (LLMO-6274) Non-blocking follow-ups from MysticatBot's second approval of #2862: - Add a rename-to-`source` rejection test mirroring the create-path guard. - Pin the dual-root performance contract with `listProjectTags.callCount === 1` (finding `origin` short-circuits before any orphan-`source` child read). - Assert the legacy-`source` 400 carries the reserved-name message, distinguishing the domain guard from a generic schema rejection. Co-Authored-By: Claude Opus 4.8 --- test/support/serenity/handlers/tags.test.js | 26 ++++++++++++++++++++- test/support/serenity/tag-tree.test.js | 3 +++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/test/support/serenity/handlers/tags.test.js b/test/support/serenity/handlers/tags.test.js index 2eefe20aa8..e19894c9ed 100644 --- a/test/support/serenity/handlers/tags.test.js +++ b/test/support/serenity/handlers/tags.test.js @@ -258,7 +258,11 @@ describe('serenity tags handler (POST /serenity/tags)', () => { WORKSPACE, { ...validBody, name: 'source' }, fakeLog(), - )).to.be.rejected.then((err) => expect(err.status).to.equal(400)); + )).to.be.rejected.then((err) => { + expect(err.status).to.equal(400); + // The domain guard, not a generic schema rejection. + expect(err.message).to.match(/reserved dimension root name/); + }); expect(transport.createProjectTags).to.not.have.been.called; }); @@ -911,6 +915,26 @@ describe('serenity tags handler (POST /serenity/tags)', () => { expect(transport.updateProjectTag).to.not.have.been.called; }); + it('400s on a rename to the legacy `source` name during the migration window', async () => { + // Mirrors the create-path guard: `source` is reserved until WP-O6, so a tag + // cannot be renamed onto it any more than a fresh one can be minted with it. + const transport = makeTransport(); + const dataAccess = makeDataAccess({ getSemrushProjectId: () => 'proj-1' }); + await expect(handler.handleUpdateTag( + transport, + dataAccess, + BRAND, + WORKSPACE, + TARGET, + { ...updateBody, name: 'source' }, + fakeLog(), + )).to.be.rejected.then((err) => { + expect(err.status).to.equal(400); + expect(err.message).to.match(/reserved dimension root name/); + }); + expect(transport.updateProjectTag).to.not.have.been.called; + }); + it('400s on a name that is too long or carries control characters', async () => { const transport = makeTransport(); const dataAccess = makeDataAccess({ getSemrushProjectId: () => 'proj-1' }); diff --git a/test/support/serenity/tag-tree.test.js b/test/support/serenity/tag-tree.test.js index 18282c974a..b39ed26e04 100644 --- a/test/support/serenity/tag-tree.test.js +++ b/test/support/serenity/tag-tree.test.js @@ -347,6 +347,9 @@ describe('serenity tag-tree', () => { expect(roots.get('origin')).to.equal('root-origin'); // Nothing was created and the orphan `source` was not touched. expect(transport.createProjectTags).to.not.have.been.called; + // Performance contract: finding `origin` short-circuits before any legacy + // adoption, so no child-level read of the orphan `source` — exactly ONE read. + expect(transport.listProjectTags).to.have.callCount(1); }); it('adopts a CHILDLESS legacy `source` root in place (vacuous authorship guard)', async () => { From 97466e7153de07d3ebca2f064ab5138dec42b148 Mon Sep 17 00:00:00 2001 From: Alicia Adriani Date: Mon, 20 Jul 2026 20:01:21 +0200 Subject: [PATCH 4/9] =?UTF-8?q?test(serenity):=20rename=20authorship=20sou?= =?UTF-8?q?rce=E2=86=92origin=20in=20the=20it-postgres=20suite=20(LLMO-627?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tolerant-resolver PR renamed the authorship dimension root source→origin everywhere except test/it/shared/tests/serenity.js, which turned it-postgres red: - root-list assertion still expected `source` (code now returns `origin`); - two closed-dimension cases POSTed `type: 'source'`, which the handler now 400s as an unknown dimension. Rename the authorship references to `origin` to match. The producing-system `source` prompt filter in categories-prompts.js (serenity-docs#47) is a different dimension and is intentionally left untouched. Co-Authored-By: Claude Opus 4.8 --- test/it/shared/tests/serenity.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/it/shared/tests/serenity.js b/test/it/shared/tests/serenity.js index ea892efc21..3c7712ab5e 100644 --- a/test/it/shared/tests/serenity.js +++ b/test/it/shared/tests/serenity.js @@ -320,7 +320,7 @@ export default function serenityTests( ); expect(roots.status).to.equal(200); expect(roots.body.items.map((t) => t.name)) - .to.have.members(['category', 'intent', 'source', 'type']); + .to.have.members(['category', 'intent', 'origin', 'type']); const categoryRoot = roots.body.items.find((t) => t.name === 'category'); expect(res.body.parentId).to.equal(categoryRoot.id); }); @@ -475,20 +475,20 @@ export default function serenityTests( expect(res.status).to.equal(400); }); - it('POST /serenity/tags resolves a closed-dimension tag idempotently (source/intent/type)', async () => { + it('POST /serenity/tags resolves a closed-dimension tag idempotently (origin/intent/type)', async () => { await createUsMarket(); const first = await getHttpClient().admin.post(`${base}/tags`, { - type: 'source', name: 'ai', geoTargetId: US_GEO, languageCode: 'en', + type: 'origin', name: 'ai', geoTargetId: US_GEO, languageCode: 'en', }); expect(first.status).to.equal(200); - expect(first.body).to.include({ type: 'source', name: 'ai' }); + expect(first.body).to.include({ type: 'origin', name: 'ai' }); expect(first.body.id).to.be.a('string').that.is.not.empty; - // The value hangs under the `source` root, never at the root level. + // The value hangs under the `origin` root, never at the root level. expect(first.body.parentId).to.be.a('string').that.is.not.empty; // Same closed-dimension value again — resolved, not re-created (no upstream collision). const second = await getHttpClient().admin.post(`${base}/tags`, { - type: 'source', name: 'ai', geoTargetId: US_GEO, languageCode: 'en', + type: 'origin', name: 'ai', geoTargetId: US_GEO, languageCode: 'en', }); expect(second.status).to.equal(200); expect(second.body).to.include({ name: 'ai', id: first.body.id, created: false }); @@ -500,7 +500,7 @@ export default function serenityTests( it('PATCH /serenity/tags/:tagId 400s a rename of a closed-dimension value', async () => { await createUsMarket(); const created = await getHttpClient().admin.post(`${base}/tags`, { - type: 'source', name: 'ai', geoTargetId: US_GEO, languageCode: 'en', + type: 'origin', name: 'ai', geoTargetId: US_GEO, languageCode: 'en', }); expect(created.status).to.equal(200); const res = await getHttpClient().admin.patch(`${base}/tags/${created.body.id}`, { From 108f87516c55a21ebab32388939e5f282ae95ee9 Mon Sep 17 00:00:00 2001 From: Alicia Adriani Date: Mon, 20 Jul 2026 20:06:47 +0200 Subject: [PATCH 5/9] =?UTF-8?q?feat(serenity):=20WP-O2b=20origin=20derivat?= =?UTF-8?q?ion=20=E2=80=94=20salvaged=20from=20#2825=20(LLMO-6275)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recovers the O2b half of byteclimber's #2825, which was clobbered by a main-merge on that branch (its current head has no net O2b diff; the work survives in commit 74bbc00). Rebased onto WP-O2a (#2862). From 74bbc00 (O2b files only; O2a stays #2862's): - prompts.js / prompts-subworkspace.js: makeTypeInjector strips+injects origin (CREATE derives to ai, UPDATE re-injects the stored origin, never re-derives) - prompts-storage.js: resolveOriginForWrite + isServicePrincipal; mapRowToPrompt drops the '|| human' fallback - brands.js: createPromptsByBrand derives origin from the principal - docs/openapi: origin service-principal-only on V2PromptInput; read filter/sort kept Conflict resolved in prompts-storage upsert row: keep byteclimber's origin derivation, keep main's source handling (#2810; source is WP-S2's domain). Co-Authored-By: Claude Opus 4.8 --- docs/openapi/prompts-v2-api.yaml | 4 + docs/openapi/schemas.yaml | 5 + src/controllers/brands.js | 6 + src/support/prompts-storage.js | 43 +++++- .../serenity/handlers/prompts-subworkspace.js | 2 +- src/support/serenity/handlers/prompts.js | 130 +++++++++++++----- test/support/prompts-storage.test.js | 79 ++++++++++- 7 files changed, 225 insertions(+), 44 deletions(-) diff --git a/docs/openapi/prompts-v2-api.yaml b/docs/openapi/prompts-v2-api.yaml index 3b6de631f4..b4b547c493 100644 --- a/docs/openapi/prompts-v2-api.yaml +++ b/docs/openapi/prompts-v2-api.yaml @@ -254,6 +254,10 @@ v2-prompts-by-brand-and-id: tags: - customer-config summary: Update a single prompt + description: >- + `origin` in the body is not honored on PATCH — it is set once at create + time (see `V2PromptInput.origin`) and can never be changed afterward, so + any `origin` sent here is ignored. operationId: updatePromptByBrandAndId security: - ims_key: [ ] diff --git a/docs/openapi/schemas.yaml b/docs/openapi/schemas.yaml index 781597e454..ddf79f3a29 100644 --- a/docs/openapi/schemas.yaml +++ b/docs/openapi/schemas.yaml @@ -6643,6 +6643,11 @@ V2PromptInput: type: string enum: [ai, human] default: human + description: >- + Who authored the prompt. Service-principal only: honored (and + validated against the enum) only when the caller authenticates as an + S2S service; for a user-authenticated request this field is ignored + and always overridden to `human`, regardless of what is sent. source: type: string description: The source system or process that created the prompt diff --git a/src/controllers/brands.js b/src/controllers/brands.js index bc349baba6..3e5bf27f92 100644 --- a/src/controllers/brands.js +++ b/src/controllers/brands.js @@ -554,6 +554,11 @@ function BrandsController(ctx, log, env) { const { postgrestClient } = context.dataAccess.services; const updatedBy = context.attributes?.authInfo?.profile?.email || 'system'; + // Service principals (e.g. llmo-data-retrieval-service) authenticate over + // s2sAuthWrapper, which sets `context.s2sConsumer`; a user principal never + // carries it. Gates whether a per-prompt `origin` in the body is honored — + // see `resolveOriginForWrite`. + const isServicePrincipal = Boolean(context.s2sConsumer); const brandUuid = await resolveBrandUuid(spaceCatId, brandId, postgrestClient); if (!brandUuid) { @@ -567,6 +572,7 @@ function BrandsController(ctx, log, env) { postgrestClient, updatedBy, classifyIntent: classifyIntent ?? undefined, + isServicePrincipal, }); return createResponse({ created, updated, prompts: outPrompts }, 201); diff --git a/src/support/prompts-storage.js b/src/support/prompts-storage.js index 1f8ae67c44..1e25e3bfab 100644 --- a/src/support/prompts-storage.js +++ b/src/support/prompts-storage.js @@ -18,6 +18,33 @@ 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 { ORIGIN_VALUE } from './serenity/prompt-tags.js'; + +const ORIGIN_VALUES = new Set(Object.values(ORIGIN_VALUE)); + +/** + * Resolves the `origin` a prompt write should carry, from the caller's + * authenticated principal rather than trusting the request body outright. + * + * A SERVICE principal (e.g. llmo-data-retrieval-service, which POSTs with a + * hardcoded `origin: "ai"`) may assert either enum value; an unrecognized + * value is treated as absent rather than rejected — a write must never fail + * over this field. A USER principal's asserted value is always ignored: a + * human hitting this endpoint is definitionally authoring the prompt + * themselves, so the body's `origin` (if any) is silently overridden, never + * used to reject the request. + * + * @param {*} candidate - the request body's asserted `origin`, if any. + * @param {boolean} isServicePrincipal - true when the caller authenticated as + * an S2S service (see `context.s2sConsumer` at the controller boundary). + * @returns {'ai' | 'human'} + */ +export function resolveOriginForWrite(candidate, isServicePrincipal) { + if (isServicePrincipal && ORIGIN_VALUES.has(candidate)) { + return candidate; + } + return ORIGIN_VALUE.HUMAN; +} // Re-exported for backward compatibility — `normalizeIntent`/`INTENT_VALUES` now // live in `./intent.js` so the LLM intent classifier can reuse them without an @@ -406,7 +433,9 @@ function mapRowToPrompt(row) { name: row.name, regions: row.regions || [], status: row.status || 'active', - origin: row.origin || 'human', + // No fallback: the column has zero NULLs in production, so `|| 'human'` + // was dead code that would otherwise silently mislabel a null as human. + origin: row.origin, source: row.source || 'config', intent: row.intent ?? null, createdAt: row.created_at, @@ -723,6 +752,9 @@ function buildPromptKey({ text, regions, source }) { * text without an explicit intent. Non-fatal: a null result leaves intent unset. * @param {number} [params.classifyIntentBatchTimeoutMs] - Cap on the classifier * batch (ms); the upsert proceeds without intent once it elapses. + * @param {boolean} [params.isServicePrincipal] - true when the caller + * authenticated as an S2S service; gates whether a per-prompt `origin` in + * the request body is honored (see {@link resolveOriginForWrite}). * @returns {Promise<{created: number, updated: number, prompts: object[]}>} */ export async function upsertPrompts({ @@ -733,6 +765,7 @@ export async function upsertPrompts({ updatedBy = 'system', classifyIntent, classifyIntentBatchTimeoutMs = 8000, + isServicePrincipal = false, }) { if (!postgrestClient?.from) { throw new Error('PostgREST client is required for prompts'); @@ -825,7 +858,7 @@ export async function upsertPrompts({ category_id: categoryUuid, topic_id: topicUuid, status: p.status || 'active', - origin: p.origin || 'human', + origin: resolveOriginForWrite(p.origin, isServicePrincipal), source, intent: normalizeIntent(p.intent), updated_by: updatedBy, @@ -1050,9 +1083,9 @@ export async function updatePromptById({ if (updates.status !== undefined) { patch.status = updates.status; } - if (updates.origin !== undefined) { - patch.origin = updates.origin; - } + // `origin` is never patchable via the PATCH body — mirrors `source`, which is + // already absent here. It is set once at create time (from the authenticated + // principal — see `upsertPrompts`) and never re-derived or overridden later. if (updates.intent !== undefined) { // The shared fallback strips intent when the column is known-absent. patch.intent = normalizeIntent(updates.intent); diff --git a/src/support/serenity/handlers/prompts-subworkspace.js b/src/support/serenity/handlers/prompts-subworkspace.js index 23d14e1e68..a08a46f9d6 100644 --- a/src/support/serenity/handlers/prompts-subworkspace.js +++ b/src/support/serenity/handlers/prompts-subworkspace.js @@ -294,7 +294,7 @@ export async function handleUpdatePromptSubworkspace( const injectComputedType = makeTypeInjector(transport, workspaceId, classifyPromptType, log); const typed = await injectComputedType(projectId, { text: nextText, geoTargetId, tagIds: nextTagIds, - }); + }, { mode: 'update' }); try { await transport.renamePrompt(workspaceId, projectId, semrushPromptId, nextText); diff --git a/src/support/serenity/handlers/prompts.js b/src/support/serenity/handlers/prompts.js index 75e71f540f..55b6f23994 100644 --- a/src/support/serenity/handlers/prompts.js +++ b/src/support/serenity/handlers/prompts.js @@ -19,7 +19,8 @@ import { redactUpstreamMessage } from '../rest-transport.js'; import { ERROR_CODES, isUpstreamGone } from '../errors.js'; import { normalizeGeoTargetId, normalizeLanguageCode, isValidTagIdFormat } from '../validation.js'; import { invalidateTagCacheForProject } from './markets.js'; -import { resolveTypeValueInjection } from '../tag-tree.js'; +import { resolveTypeValueInjection, resolveOriginValueInjection } from '../tag-tree.js'; +import { ORIGIN_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 @@ -366,60 +367,115 @@ export async function createOnePrompt(transport, semrushWorkspaceId, projectId, } /** - * Builds the per-request `type` injector — the UNIFIED classification layer - * (serenity-docs#31). Given a pure `classifyPromptType(text, geoTargetId)` + * Builds the per-request `type` + `origin` injector — the UNIFIED + * classification layer (serenity-docs#31, extended by the-origin-dimension + * spec for `origin`). Given a pure `classifyPromptType(text, geoTargetId)` * closure (built by the controller from the brand name + region-clamped * aliases) that yields a BARE `type` value (`branded` / `non-branded`), it - * returns `injectComputedType(projectId, input)` which: - * - STRIPS every caller-supplied tag id that lives under the `type` root (the - * client may never set the value), and - * - APPENDS the pre-resolved upstream id of the server-computed value. The - * atomic `createPromptsByIds` 500s on an unresolved id, so it is resolved + * returns `injectComputedType(projectId, input, options)` which, for BOTH + * dimensions: + * - STRIPS every caller-supplied tag id that lives under the dimension's root + * (the client may never set either value directly), and + * - APPENDS a resolved upstream tag id in its place. The atomic + * `createPromptsByIds` 500s on an unresolved id, so ids are resolved * BEFORE the write. * The returned input carries the rewritten `tagIds`, so the caller's response - * echo reflects the computed type without a refetch (decision 5). + * echo reflects the computed values without a refetch (decision 5). * - * The strip set is every id under the `type` root, not a name prefix: a tag's - * dimension is its root, and a sub-category could legitimately be named - * `branded` without being a `type` value. + * The strip set for each dimension is every id under that dimension's root, + * not a name prefix: a tag's dimension is its root, and a sub-category could + * legitimately be named `branded` or `ai` without being a `type`/`origin` value. * - * Resolution ({@link resolveTypeValueInjection}, two tag-tree reads per distinct - * `type` value per project — the root level plus the `type` root's children) is - * memoized for the request, so a bulk create fans out over the distinct computed - * values rather than over the items. A non-function `classifyPromptType` - * (defensive) is a pass-through. + * `type` is always freshly classified from the write's own text. `origin` is + * asymmetric between create and update (`options.mode`, default `'create'`): + * - `'create'`: derives to {@link ORIGIN_VALUE.AI} — every prompt this layer + * creates is AI-generated. + * - `'update'`: re-injects whichever `origin` tag id is ALREADY in + * `input.tagIds` (the client round-trips the tags it read off the prior + * list call), rather than deriving a new one from anything in the update + * request. This is deliberate: `origin` records who authored the prompt + * ORIGINALLY, which an edit does not change. If none is found (a prompt + * tagged before this dimension existed), it falls back to `AI` rather than + * leave the prompt untagged — a strip-without-inject would make it invisible + * to origin-based filtering. * - * `resolveTypeValueInjection` resolves or throws, so the computed tag is always - * attached. It must never be dropped: `type` is the one dimension a client may - * not set, so a prompt written without it stays unclassified forever, and the - * caller sees a 2xx. Failing the write instead is free — the upstream bulk create - * is atomic and has not run yet. + * Resolution (two tag-tree reads per distinct value per project — the root + * level plus that dimension root's children) is memoized for the request per + * dimension, so a bulk create fans out over the distinct computed values + * rather than over the items. A non-function `classifyPromptType` (defensive) + * skips the `type` step; `origin` is always injected. + * + * Both resolvers resolve or throw, so the computed tags are always attached. + * They must never be dropped: neither dimension is client-settable, so a + * prompt written without one stays unclassified forever, and the caller sees a + * 2xx. Failing the write instead is free — the upstream bulk create is atomic + * and has not run yet. * * @param {object} transport - Serenity transport (Semrush proxy client). * @param {string} semrushWorkspaceId * @param {((text: string, geoTargetId: number) => string) | undefined} classifyPromptType * @param {object} [log] * @returns {(projectId: string, input: { text: string, geoTargetId: number, - * tagIds: string[] }) => + * tagIds: string[] }, options?: { mode?: 'create' | 'update' }) => * Promise<{ text: string, geoTargetId: number, tagIds: string[] }>} */ export function makeTypeInjector(transport, semrushWorkspaceId, classifyPromptType, log) { /** @type {Map>} */ - const cache = new Map(); - return async function injectComputedType(projectId, input) { - if (typeof classifyPromptType !== 'function') { - return input; + const typeCache = new Map(); + /** @type {Map>} */ + const originCache = new Map(); + + return async function injectComputedType(projectId, input, { mode = 'create' } = {}) { + let out = input; + + if (typeof classifyPromptType === 'function') { + const typeValue = classifyPromptType(input.text, input.geoTargetId); + const typeKey = `${projectId} ${typeValue}`; + let typePending = typeCache.get(typeKey); + if (!typePending) { + typePending = resolveTypeValueInjection( + transport, + semrushWorkspaceId, + projectId, + typeValue, + log, + ); + typeCache.set(typeKey, typePending); + } + const { computedId, typeTagIds } = await typePending; + const stripped = out.tagIds.filter((id) => !typeTagIds.includes(id)); + out = { ...out, tagIds: [...stripped, computedId] }; } - const typeValue = classifyPromptType(input.text, input.geoTargetId); - const key = `${projectId} ${typeValue}`; - let pending = cache.get(key); - if (!pending) { - pending = resolveTypeValueInjection(transport, semrushWorkspaceId, projectId, typeValue, log); - cache.set(key, pending); + + // `origin` resolution is the same tree lookup on both create and update — + // every id under the root, plus the resolved `AI` id — only what CREATE + // does with it differs from what UPDATE does. + const originKey = `${projectId} ${ORIGIN_VALUE.AI}`; + let originPending = originCache.get(originKey); + if (!originPending) { + originPending = resolveOriginValueInjection( + transport, + semrushWorkspaceId, + projectId, + ORIGIN_VALUE.AI, + log, + ); + originCache.set(originKey, originPending); } - const { computedId, typeTagIds } = await pending; - const stripped = input.tagIds.filter((id) => !typeTagIds.includes(id)); - return { ...input, tagIds: [...stripped, computedId] }; + const { computedId: aiId, originTagIds } = await originPending; + const stripped = out.tagIds.filter((id) => !originTagIds.includes(id)); + + // UPDATE re-injects whichever origin tag the caller already carried — it + // never derives a new one. CREATE always derives to AI. Falling back to AI + // when update finds none avoids a strip-without-inject (which would make + // the prompt invisible to origin-based filtering) for a prompt tagged + // before this dimension existed. + const nextOriginId = mode === 'update' + ? (out.tagIds.find((id) => originTagIds.includes(id)) ?? aiId) + : aiId; + out = { ...out, tagIds: [...stripped, nextOriginId] }; + + return out; }; } @@ -722,7 +778,7 @@ export async function handleUpdatePrompt( ); const typed = await injectComputedType(projectId, { text: nextText, geoTargetId, tagIds: nextTagIds, - }); + }, { mode: 'update' }); try { await transport.renamePrompt(semrushWorkspaceId, projectId, semrushPromptId, nextText); diff --git a/test/support/prompts-storage.test.js b/test/support/prompts-storage.test.js index 8a051c7a4f..376ce36ecd 100644 --- a/test/support/prompts-storage.test.js +++ b/test/support/prompts-storage.test.js @@ -30,6 +30,7 @@ import { isMissingIntentColumnError, findPromptsBlockingRegionRemoval, getIntentsByPromptIds, + resolveOriginForWrite, } from '../../src/support/prompts-storage.js'; use(chaiAsPromised); @@ -899,7 +900,10 @@ describe('prompts-storage', () => { expect(result).to.not.be.null; expect(result.regions).to.deep.equal([]); expect(result.status).to.equal('active'); - expect(result.origin).to.equal('human'); + // No fallback: the column has zero NULLs in production, so a null/ + // undefined `origin` is returned as-is rather than silently coerced to + // 'human' (which would have mislabeled it). + expect(result.origin).to.equal(undefined); expect(result.source).to.equal('config'); expect(result.category).to.be.null; expect(result.topic).to.be.null; @@ -3190,6 +3194,79 @@ describe('prompts-storage', () => { }); }); + describe('resolveOriginForWrite (WP-O2b: origin derived from the authenticated principal)', () => { + it('honors a valid asserted value from a service principal', () => { + expect(resolveOriginForWrite('ai', true)).to.equal('ai'); + expect(resolveOriginForWrite('human', true)).to.equal('human'); + }); + + it('falls back to human for an unrecognized value from a service principal (never rejects)', () => { + expect(resolveOriginForWrite('bogus', true)).to.equal('human'); + expect(resolveOriginForWrite(undefined, true)).to.equal('human'); + }); + + it('always overrides to human for a user principal, regardless of the asserted value', () => { + expect(resolveOriginForWrite('ai', false)).to.equal('human'); + expect(resolveOriginForWrite('human', false)).to.equal('human'); + expect(resolveOriginForWrite(undefined, false)).to.equal('human'); + }); + }); + + describe('upsertPrompts - origin derived from the authenticated principal', () => { + function makeUpsertClient() { + return { + from: (table) => { + if (table === 'prompts') { + return { + select: () => ({ + eq: () => ({ + eq: () => thenable({ data: [], error: null }), + }), + }), + insert: () => ({ + select: () => thenable({ data: [{ prompt_id: 'new-1' }], error: null }), + }), + update: () => ({ eq: () => thenable({ error: null }) }), + }; + } + return makeChain({}); + }, + }; + } + + it('honors a service principal\'s asserted origin', async () => { + const result = await upsertPrompts({ + organizationId: ORG_ID, + brandUuid: BRAND_UUID, + prompts: [{ prompt: 'New prompt', regions: ['us'], origin: 'ai' }], + postgrestClient: makeUpsertClient(), + isServicePrincipal: true, + }); + expect(result.prompts[0].origin).to.equal('ai'); + }); + + it('overrides a user principal\'s asserted origin to human', async () => { + const result = await upsertPrompts({ + organizationId: ORG_ID, + brandUuid: BRAND_UUID, + prompts: [{ prompt: 'New prompt', regions: ['us'], origin: 'ai' }], + postgrestClient: makeUpsertClient(), + isServicePrincipal: false, + }); + expect(result.prompts[0].origin).to.equal('human'); + }); + + it('defaults to human when isServicePrincipal is omitted', async () => { + const result = await upsertPrompts({ + organizationId: ORG_ID, + brandUuid: BRAND_UUID, + prompts: [{ prompt: 'New prompt', regions: ['us'], origin: 'ai' }], + postgrestClient: makeUpsertClient(), + }); + expect(result.prompts[0].origin).to.equal('human'); + }); + }); + describe('bulkDeletePrompts', () => { it('throws when postgrestClient has no from', async () => { await expect( From 7133321471491c18a73b6116ba086c0328f40d37 Mon Sep 17 00:00:00 2001 From: Alicia Adriani Date: Mon, 20 Jul 2026 20:57:51 +0200 Subject: [PATCH 6/9] fix(serenity): WP-O2b integration with the in-place edit path (LLMO-6275) - tag-tree.js: add resolveOriginValueInjection (was in byteclimber's O2a tag-tree.js, not carried when O2a came from #2862). Mirrors the inline resolveTypeValueInjection and resolves the origin root via the tolerant ensureDimensionRoots, so injection works during the source->origin rename. - Update prompts.js / prompts-subworkspace.js tests to expect the injected origin tag on create (ai) and in-place update (re-inject existing, else ai); basic create/update tests gain a listProjectTags stub since origin injection now always reads the tag tree. 509 affected serenity tests green; eslint clean. Co-Authored-By: Claude Opus 4.8 --- src/support/serenity/tag-tree.js | 43 +++++++ .../handlers/prompts-subworkspace.test.js | 21 ++-- .../support/serenity/handlers/prompts.test.js | 109 ++++++++++++------ 3 files changed, 128 insertions(+), 45 deletions(-) diff --git a/src/support/serenity/tag-tree.js b/src/support/serenity/tag-tree.js index 9712cb8a47..49ddef1a30 100644 --- a/src/support/serenity/tag-tree.js +++ b/src/support/serenity/tag-tree.js @@ -608,3 +608,46 @@ export async function resolveTypeValueInjection( typeTagIds: [...byName.values()], }; } + +/** + * Resolves the id-based injection of a server-computed `origin` value into a + * prompt write. Returns the wanted value's id plus EVERY id under the resolved + * authorship root, so the caller can strip any caller-supplied `origin` tag id + * (a client must never set the value itself on create; on update the caller + * re-injects the prompt's already-stored id instead of calling this with a + * freshly derived one — see `makeTypeInjector` in `handlers/prompts.js`). + * + * The root is resolved through {@link ensureDimensionRoots}, so during the + * `source` → `origin` rename this returns the tolerantly-resolved authorship + * root (whichever physical name it currently carries) — never a second one. + * + * @param {object} transport - Serenity transport (Semrush proxy client). + * @param {string} semrushWorkspaceId + * @param {string} projectId + * @param {string} wantValue - the bare `origin` value (`ai` / `human`). + * @param {object} [log] - logger. + * @returns {Promise<{ computedId: string, originTagIds: string[] }>} `computedId` + * is always resolved — {@link ensureChildren} throws rather than leave a hole. + */ +export async function resolveOriginValueInjection( + transport, + semrushWorkspaceId, + projectId, + wantValue, + log, +) { + const roots = await ensureDimensionRoots(transport, semrushWorkspaceId, projectId, log); + const originRootId = rootIdOf(roots, DIMENSION.ORIGIN); + const { byName } = await ensureChildren( + transport, + semrushWorkspaceId, + projectId, + originRootId, + [wantValue], + log, + ); + return { + computedId: /** @type {string} */ (byName.get(wantValue)), + originTagIds: [...byName.values()], + }; +} diff --git a/test/support/serenity/handlers/prompts-subworkspace.test.js b/test/support/serenity/handlers/prompts-subworkspace.test.js index 01c4d591a7..5404cb2961 100644 --- a/test/support/serenity/handlers/prompts-subworkspace.test.js +++ b/test/support/serenity/handlers/prompts-subworkspace.test.js @@ -154,7 +154,9 @@ describe('prompts-subworkspace handlers', () => { }, log); expect(result.created).to.have.length(1); expect(result.created[0]).to.include({ semrushPromptId: 'new-prompt', geoTargetId: 2840 }); - expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WS, 'p-us-en', ['p'], ['tag-1']); + // No classifier supplied, so the `type` step is skipped; the `origin` + // step always runs and appends the AI-authored origin id on create. + expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WS, 'p-us-en', ['p'], ['tag-1', TAG_IDS.originAi]); expect(transport.publishProject).to.have.been.calledOnceWith(WS, 'p-us-en'); }); @@ -210,13 +212,13 @@ describe('prompts-subworkspace handlers', () => { }], }, log, classifyByBrandMention); expect(result.created[0].tagIds).to.deep.equal([ - TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, + TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, TAG_IDS.originAi, ]); expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly( WS, 'p-us-en', ['is Acme good?'], - [TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded], + [TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, TAG_IDS.originAi], ); }); @@ -311,10 +313,11 @@ describe('prompts-subworkspace handlers', () => { // The id is preserved — the edit is in place, never a re-create. expect(result.body.semrushPromptId).to.equal('old-id'); expect(transport.renamePrompt).to.have.been.calledOnceWithExactly(WS, 'p-us-en', 'old-id', 'new'); + // No caller-supplied origin id, so update falls back to the AI origin id. expect(transport.updatePromptTagsByIds).to.have.been.calledOnceWithExactly( WS, 'p-us-en', - [{ id: 'old-id', references: ['tag-1'], replace: true }], + [{ id: 'old-id', references: ['tag-1', TAG_IDS.originAi], replace: true }], ); expect(transport.deletePromptsByIds).to.not.have.been.called; expect(transport.createPromptsByIds).to.not.have.been.called; @@ -374,12 +377,12 @@ describe('prompts-subworkspace handlers', () => { }, log); expect(result.status).to.equal(200); expect(result.body.semrushPromptId).to.equal('old-id'); - expect(result.body.tagIds).to.deep.equal(['tag-cat-1']); + expect(result.body.tagIds).to.deep.equal(['tag-cat-1', TAG_IDS.originAi]); expect(transport.renamePrompt).to.have.been.calledOnceWithExactly(WS, 'p-us-en', 'old-id', 'new'); expect(transport.updatePromptTagsByIds).to.have.been.calledOnceWithExactly( WS, 'p-us-en', - [{ id: 'old-id', references: ['tag-cat-1'], replace: true }], + [{ id: 'old-id', references: ['tag-cat-1', TAG_IDS.originAi], replace: true }], ); }); @@ -410,14 +413,14 @@ describe('prompts-subworkspace handlers', () => { }, log, classifyByBrandMention); expect(result.status).to.equal(200); expect(result.body.tagIds).to.deep.equal([ - TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, + TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, TAG_IDS.originAi, ]); expect(transport.updatePromptTagsByIds).to.have.been.calledOnceWithExactly( WS, 'p-us-en', [{ id: 'old-id', - references: [TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded], + references: [TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, TAG_IDS.originAi], replace: true, }], ); @@ -740,6 +743,6 @@ describe('prompts-subworkspace — defensive branch coverage', () => { languageCode: 'en', }, log); expect(result.status).to.equal(200); - expect(result.body.tagIds).to.deep.equal(['keep']); + expect(result.body.tagIds).to.deep.equal(['keep', TAG_IDS.originAi]); }); }); diff --git a/test/support/serenity/handlers/prompts.test.js b/test/support/serenity/handlers/prompts.test.js index 8a21d6003a..6bbe6120f8 100644 --- a/test/support/serenity/handlers/prompts.test.js +++ b/test/support/serenity/handlers/prompts.test.js @@ -553,6 +553,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }); const dataAccess = makeDataAccess([project]); const transport = { + listProjectTags: makeListProjectTagsStub(), createPromptsByIds: sinon.stub().resolves({ page: 1, total: 1, items: [{ id: 'new-sem-id', name: 'hello' }], existing_count: 0, }), @@ -566,14 +567,16 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }, fakeLog()); expect(result.created).to.have.lengthOf(1); + // No classifier is supplied, so `type` is untouched; `origin` is always + // injected (AI on create) and appended to the echoed tagIds. expect(result.created[0]).to.deep.equal({ semrushPromptId: 'new-sem-id', geoTargetId: 2840, languageCode: 'en', text: 'hello', - tagIds: ['tag-cat-1', 'tag-child-1'], + tagIds: ['tag-cat-1', 'tag-child-1', TAG_IDS.originAi], }); - expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', ['hello'], ['tag-cat-1', 'tag-child-1']); + expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', ['hello'], ['tag-cat-1', 'tag-child-1', TAG_IDS.originAi]); expect(transport.publishProject).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en'); }); @@ -643,6 +646,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }); const dataAccess = makeDataAccess([project]); const transport = { + listProjectTags: makeListProjectTagsStub(), createPromptsByIds: sinon.stub().resolves({ page: 1, total: 0, items: [], existing_count: 1, }), @@ -656,8 +660,8 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }, fakeLog()); expect(result.created[0].semrushPromptId).to.equal(''); - expect(result.created[0].tagIds).to.deep.equal(['keep']); - expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', ['hello'], ['keep']); + expect(result.created[0].tagIds).to.deep.equal(['keep', TAG_IDS.originAi]); + expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', ['hello'], ['keep', TAG_IDS.originAi]); }); it('returns empty semrushPromptId (not the string "undefined") when createPromptsByIds returns an item with no id', async () => { @@ -666,6 +670,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }); const dataAccess = makeDataAccess([project]); const transport = { + listProjectTags: makeListProjectTagsStub(), createPromptsByIds: sinon.stub().resolves({ page: 1, total: 1, items: [{ name: 'hello' }], }), @@ -692,6 +697,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }), publishProject: sinon.stub().resolves(), }; + transport.listProjectTags = makeListProjectTagsStub(); const tooLong = 'x'.repeat(201); const result = await handleCreatePrompts(transport, dataAccess, BRAND, WORKSPACE, { @@ -703,8 +709,8 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }], }, fakeLog()); - expect(result.created[0].tagIds).to.deep.equal(['keep']); - expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', ['hello'], ['keep']); + expect(result.created[0].tagIds).to.deep.equal(['keep', TAG_IDS.originAi]); + expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', ['hello'], ['keep', TAG_IDS.originAi]); }); it('caps a bulk-create tagIds array at MAX_TAG_IDS (50), mirroring the list-read query cap', async () => { @@ -718,6 +724,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }), publishProject: sinon.stub().resolves(), }; + transport.listProjectTags = makeListProjectTagsStub(); const tooMany = Array.from({ length: 55 }, (_, i) => `tag-${i}`); const result = await handleCreatePrompts(transport, dataAccess, BRAND, WORKSPACE, { @@ -726,8 +733,10 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }], }, fakeLog()); - expect(result.created[0].tagIds).to.have.lengthOf(50); - expect(result.created[0].tagIds).to.deep.equal(tooMany.slice(0, 50)); + // The MAX_TAG_IDS cap applies to CALLER-supplied ids (50); the server-injected + // origin id is additive, so the echoed set is 50 + 1. + expect(result.created[0].tagIds).to.have.lengthOf(51); + expect(result.created[0].tagIds).to.deep.equal([...tooMany.slice(0, 50), TAG_IDS.originAi]); }); it('skips a create row when tagIds sanitizes to empty (every entry malformed)', async () => { @@ -753,6 +762,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }); const dataAccess = makeDataAccess([usEn]); const transport = { + listProjectTags: makeListProjectTagsStub(), createPromptsByIds: sinon.stub().resolves({ page: 1, total: 1, items: [{ id: 'new-sem-id', name: 'good' }], }), @@ -790,6 +800,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { const dataAccess = makeDataAccess([project]); const err = Object.assign(new Error('rate limited'), { status: 429 }); const transport = { + listProjectTags: makeListProjectTagsStub(), createPromptsByIds: sinon.stub().rejects(err), publishProject: sinon.stub().resolves(), }; @@ -822,6 +833,7 @@ describe('handlers/prompts.js — handleCreatePrompts', () => { }); const dataAccess = makeDataAccess([project]); const transport = { + listProjectTags: makeListProjectTagsStub(), createPromptsByIds: sinon.stub().resolves({ page: 1, total: 1, items: [{ id: 'new-sem-id', name: 'ok' }], }), @@ -999,6 +1011,7 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { dataAccess.BrandSemrushProject.findBySlice.resolves(project); const transport = { + listProjectTags: makeListProjectTagsStub(), renamePrompt: sinon.stub().resolves({ id: 'sem-1', name: 'next', is_updated: true }), updatePromptTagsByIds: sinon.stub().resolves(null), deletePromptsByIds: sinon.stub(), @@ -1025,13 +1038,13 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { geoTargetId: 2840, languageCode: 'en', text: 'next', - tagIds: ['tag-cat-1'], + tagIds: ['tag-cat-1', TAG_IDS.originAi], }); expect(transport.renamePrompt).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', 'sem-1', 'next'); expect(transport.updatePromptTagsByIds).to.have.been.calledOnceWithExactly( WORKSPACE, 'proj-us-en', - [{ id: 'sem-1', references: ['tag-cat-1'], replace: true }], + [{ id: 'sem-1', references: ['tag-cat-1', TAG_IDS.originAi], replace: true }], ); // Nothing is deleted or created anywhere on the edit path. expect(transport.deletePromptsByIds).to.have.callCount(0); @@ -1047,6 +1060,7 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { dataAccess.BrandSemrushProject.findBySlice.resolves(project); const transport = { + listProjectTags: makeListProjectTagsStub(), renamePrompt: sinon.stub().resolves({ id: 'sem-1', name: 'next', is_updated: true }), updatePromptTagsByIds: sinon.stub().resolves(null), publishProject: sinon.stub().resolves(), @@ -1064,11 +1078,11 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { fakeLog(), ); - expect(result.body.tagIds).to.deep.equal(['keep']); + expect(result.body.tagIds).to.deep.equal(['keep', TAG_IDS.originAi]); expect(transport.updatePromptTagsByIds).to.have.been.calledOnceWithExactly( WORKSPACE, 'proj-us-en', - [{ id: 'sem-1', references: ['keep'], replace: true }], + [{ id: 'sem-1', references: ['keep', TAG_IDS.originAi], replace: true }], ); }); @@ -1080,6 +1094,7 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { dataAccess.BrandSemrushProject.findBySlice.resolves(project); const transport = { + listProjectTags: makeListProjectTagsStub(), renamePrompt: sinon.stub().resolves({ id: 'sem-1', name: 'next', is_updated: true }), updatePromptTagsByIds: sinon.stub().resolves(null), publishProject: sinon.stub().resolves(), @@ -1101,11 +1116,11 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { fakeLog(), ); - expect(result.body.tagIds).to.deep.equal(['keep']); + expect(result.body.tagIds).to.deep.equal(['keep', TAG_IDS.originAi]); expect(transport.updatePromptTagsByIds).to.have.been.calledOnceWithExactly( WORKSPACE, 'proj-us-en', - [{ id: 'sem-1', references: ['keep'], replace: true }], + [{ id: 'sem-1', references: ['keep', TAG_IDS.originAi], replace: true }], ); }); @@ -1179,6 +1194,7 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { // stub lets us assert callCount(0) so a regression that brings the // walk back fails this test. listPromptsByTags: sinon.stub(), + listProjectTags: makeListProjectTagsStub(), renamePrompt: sinon.stub().resolves({ id: 'sem-1', name: 'new text', is_updated: true }), updatePromptTagsByIds: sinon.stub().resolves(null), publishProject: sinon.stub().resolves(), @@ -1202,7 +1218,7 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { geoTargetId: 2840, languageCode: 'en', text: 'new text', - tagIds: ['tag-fresh'], + tagIds: ['tag-fresh', TAG_IDS.originAi], }); expect(transport.listPromptsByTags).to.have.callCount(0); expect(transport.renamePrompt).to.have.been.calledOnceWithExactly(WORKSPACE, 'proj-us-en', 'sem-1', 'new text'); @@ -1220,6 +1236,7 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { // must NOT trip the promptNotFound path. const err = new SerenityTransportError(404, 'not found'); const transport = { + listProjectTags: makeListProjectTagsStub(), renamePrompt: sinon.stub().rejects(err), updatePromptTagsByIds: sinon.stub(), }; @@ -1254,6 +1271,7 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { const err = new SerenityTransportError(409, 'conflict'); const transport = { + listProjectTags: makeListProjectTagsStub(), renamePrompt: sinon.stub().rejects(err), updatePromptTagsByIds: sinon.stub(), publishProject: sinon.stub(), @@ -1283,6 +1301,7 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { const err = Object.assign(new Error('upstream 503'), { status: 503 }); const transport = { + listProjectTags: makeListProjectTagsStub(), renamePrompt: sinon.stub().rejects(err), updatePromptTagsByIds: sinon.stub(), }; @@ -1313,6 +1332,7 @@ describe('handlers/prompts.js — handleUpdatePrompt', () => { const tagErr = Object.assign(new Error('tag write boom'), { status: 500 }); const transport = { + listProjectTags: makeListProjectTagsStub(), renamePrompt: sinon.stub().resolves({ id: 'sem-1', name: 'x', is_updated: true }), updatePromptTagsByIds: sinon.stub().rejects(tagErr), publishProject: sinon.stub().resolves(), @@ -1677,7 +1697,9 @@ describe('handlers/prompts.js — tag cache invalidation (Important #6)', () => }); // Step 1: populate cache via handleListTags with set A. - const transport = { listPromptsByTags }; + // listProjectTags feeds the origin-tag-tree resolution the create/update + // handlers now perform (harmless to the bulk-delete path, which never reads it). + const transport = { listPromptsByTags, listProjectTags: makeListProjectTagsStub() }; await handleListTags(transport, dataAccess, BRAND, WORKSPACE, { geoTargetId: 2840, languageCode: 'en', }, fakeLog()); @@ -1830,13 +1852,13 @@ 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.categoryRunningShoes, TAG_IDS.typeBranded, TAG_IDS.originAi, ]); expect(transport.createPromptsByIds).to.have.been.calledOnceWithExactly( WORKSPACE, 'proj-us-en', ['is Acme good?'], - [TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded], + [TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, TAG_IDS.originAi], ); // The whole taxonomy already exists, so nothing is provisioned. expect(transport.createProjectTags).to.not.have.been.called; @@ -1862,7 +1884,7 @@ 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.categoryRunningShoes, TAG_IDS.typeNonBranded, TAG_IDS.originAi, ]); }); @@ -1900,7 +1922,9 @@ describe('handlers/prompts.js — unified type classification (serenity-docs#31) }], }, fakeLog(), classifyByBrandMention); - expect(result.created[0].tagIds).to.deep.equal([decoyCategoryId, TAG_IDS.typeBranded]); + expect(result.created[0].tagIds).to.deep.equal([ + decoyCategoryId, TAG_IDS.typeBranded, TAG_IDS.originAi, + ]); }); // Projects that predate the taxonomy carry none of it; the first write @@ -1931,7 +1955,12 @@ describe('handlers/prompts.js — unified type classification (serenity-docs#31) 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(result.created[0].tagIds).to.deep.equal(['tag-cat-1', 'created:created::type:branded']); + // The `origin` value `ai` is then minted under the freshly-created origin root. + expect(createProjectTags.thirdCall.args[2]).to.deep.equal(['ai']); + expect(createProjectTags.thirdCall.args[3]).to.deep.equal({ parentId: 'created::origin' }); + expect(result.created[0].tagIds).to.deep.equal([ + 'tag-cat-1', 'created:created::type:branded', 'created:created::origin:ai', + ]); }); }); @@ -1955,7 +1984,7 @@ describe('handlers/prompts.js — unified type classification (serenity-docs#31) expect(result.status).to.equal(200); expect(result.body.tagIds).to.deep.equal([ - TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, + TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, TAG_IDS.originAi, ]); // The injector's output is the full replacement set the tag write sends. expect(transport.updatePromptTagsByIds).to.have.been.calledOnceWithExactly( @@ -1963,7 +1992,7 @@ describe('handlers/prompts.js — unified type classification (serenity-docs#31) 'proj-us-en', [{ id: 'old-id', - references: [TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded], + references: [TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, TAG_IDS.originAi], replace: true, }], ); @@ -2001,32 +2030,40 @@ describe('handlers/prompts.js — unified type classification (serenity-docs#31) }; const inject = makeTypeInjector(transport, WORKSPACE, classifyByBrandMention, fakeLog()); - // Resolving one (project, type) key reads two levels: the roots, then the - // children of the `type` root. + // Resolving one (project, type) key reads two levels (the roots, then the + // children of the `type` root); the `origin` step then reads two more (the + // roots again, then the origin root's children) — 4 reads for the first call. const a = await inject('proj-1', { text: 'love Acme', geoTargetId: 2840, tagIds: ['x'] }); - expect(transport.listProjectTags).to.have.callCount(2); + expect(transport.listProjectTags).to.have.callCount(4); // Same project + same computed type => served from cache, no new reads. + // The `origin` cache key (project + AI) is likewise unchanged, so no reads. const b = await inject('proj-1', { text: 'Acme rocks', geoTargetId: 2840, tagIds: ['y'] }); - expect(transport.listProjectTags).to.have.callCount(2); - expect(a.tagIds).to.deep.equal(['x', TAG_IDS.typeBranded]); - expect(b.tagIds).to.deep.equal(['y', TAG_IDS.typeBranded]); + expect(transport.listProjectTags).to.have.callCount(4); + expect(a.tagIds).to.deep.equal(['x', TAG_IDS.typeBranded, TAG_IDS.originAi]); + expect(b.tagIds).to.deep.equal(['y', TAG_IDS.typeBranded, TAG_IDS.originAi]); - // A different computed type is a new cache key => one more resolution. + // A different computed type is a new cache key => one more type resolution + // (2 reads). Origin is still cached, so it adds none. const c = await inject('proj-1', { text: 'best running shoes', geoTargetId: 2840, tagIds: ['z'] }); - expect(transport.listProjectTags).to.have.callCount(4); - expect(c.tagIds).to.deep.equal(['z', TAG_IDS.typeNonBranded]); + expect(transport.listProjectTags).to.have.callCount(6); + expect(c.tagIds).to.deep.equal(['z', TAG_IDS.typeNonBranded, TAG_IDS.originAi]); expect(transport.createProjectTags).to.not.have.been.called; }); - it('passes the input through untouched when no classifier is supplied', async () => { - const transport = { listProjectTags: sinon.stub(), createProjectTags: sinon.stub() }; + it('skips the type step but still injects origin when no classifier is supplied', async () => { + const transport = { + listProjectTags: makeListProjectTagsStub(), + createProjectTags: sinon.stub(), + }; const inject = makeTypeInjector(transport, WORKSPACE, undefined, fakeLog()); const out = await inject('proj-1', { text: 'anything', geoTargetId: 2840, tagIds: ['x'] }); - expect(out.tagIds).to.deep.equal(['x']); - expect(transport.listProjectTags).to.not.have.been.called; + // No classifier => `type` is left untouched, but `origin` is always + // injected (AI on create), so the tag tree is still read for it. + expect(out.tagIds).to.deep.equal(['x', TAG_IDS.originAi]); + expect(transport.listProjectTags).to.have.been.called; }); }); }); From 790ae52aca5644ffdce6cb0fc1dae60eab6f0576 Mon Sep 17 00:00:00 2001 From: Alicia Adriani Date: Mon, 20 Jul 2026 21:21:04 +0200 Subject: [PATCH 7/9] test(serenity): cover the update-preserves-human-origin asymmetry (LLMO-6275) Review found no test exercised the create/update asymmetry's core: an edit re-injecting an existing human origin rather than falling back to ai. Without it a regression could silently relabel human-authored prompts as AI on every edit. Adds a handleUpdatePrompt test asserting an input human origin tag is preserved (not defaulted to ai). Co-Authored-By: Claude Opus 4.8 --- .../support/serenity/handlers/prompts.test.js | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/test/support/serenity/handlers/prompts.test.js b/test/support/serenity/handlers/prompts.test.js index 6bbe6120f8..9ff0dd2bea 100644 --- a/test/support/serenity/handlers/prompts.test.js +++ b/test/support/serenity/handlers/prompts.test.js @@ -1998,6 +1998,45 @@ describe('handlers/prompts.js — unified type classification (serenity-docs#31) ); }); + // The create/update asymmetry (the whole point of `mode: 'update'`): an edit + // must NOT re-derive origin. A human-authored prompt keeps `human` on every + // subsequent edit — the injector re-injects whichever origin tag the caller + // round-tripped, never the AI default it applies on create. Without this, + // every edit would silently relabel a human prompt as AI-authored. + it('preserves an existing human origin on edit — never relabels it to ai', async () => { + const dataAccess = makeDataAccess([]); + dataAccess.BrandSemrushProject.findBySlice.resolves(project()); + const transport = { + listProjectTags: makeListProjectTagsStub(), + renamePrompt: sinon.stub().resolves({ id: 'old-id', name: 'now mentions Acme', is_updated: true }), + updatePromptTagsByIds: sinon.stub().resolves(null), + publishProject: sinon.stub().resolves(), + }; + + const result = await handleUpdatePrompt(transport, dataAccess, BRAND, WORKSPACE, 'old-id', { + text: 'now mentions Acme', + geoTargetId: 2840, + languageCode: 'en', + // The caller round-trips the prompt's stored human origin tag. + tagIds: [TAG_IDS.categoryRunningShoes, TAG_IDS.originHuman], + }, fakeLog(), classifyByBrandMention); + + expect(result.status).to.equal(200); + // origin stays human — re-injected from the caller's tags, not defaulted to ai. + expect(result.body.tagIds).to.deep.equal([ + TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, TAG_IDS.originHuman, + ]); + expect(transport.updatePromptTagsByIds).to.have.been.calledOnceWithExactly( + WORKSPACE, + 'proj-us-en', + [{ + id: 'old-id', + references: [TAG_IDS.categoryRunningShoes, TAG_IDS.typeBranded, TAG_IDS.originHuman], + replace: true, + }], + ); + }); + // The tree read (and any provisioning it triggers) must happen BEFORE any // upstream write, so a classification failure leaves the prompt intact. it('resolves the type BEFORE writing to the prompt', async () => { From ce33699cbf3883c205cf2ca6658f33e0223cc197 Mon Sep 17 00:00:00 2001 From: Alicia Adriani Date: Mon, 20 Jul 2026 21:51:12 +0200 Subject: [PATCH 8/9] test(it): expect the injected origin tag on serenity prompt create (LLMO-6275) it-postgres caught the create path now appending an origin tag (WP-O2b) in addition to the type tag: the id-based create test supplied 2 tags and asserted 3 (2 + computed type); it is now 4 (2 + type + origin). Update the count and comment. Only exact tagIds-count assertion in the suite; other lengthOf checks are on created/skipped counts, unaffected. Co-Authored-By: Claude Opus 4.8 --- test/it/shared/tests/serenity.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/it/shared/tests/serenity.js b/test/it/shared/tests/serenity.js index ea892efc21..9f9457f421 100644 --- a/test/it/shared/tests/serenity.js +++ b/test/it/shared/tests/serenity.js @@ -533,11 +533,12 @@ export default function serenityTests( expect(created.status).to.equal(200); expect(created.body.created).to.have.lengthOf(1); expect(created.body.created[0].semrushPromptId).to.be.a('string').that.is.not.empty; - // The write path now server-computes a branded/non-branded `type:` tag and - // appends it to the supplied tagIds, so the created prompt carries the two - // supplied tags plus one computed type tag. + // The write path server-computes two tags and appends them to the supplied + // tagIds: a branded/non-branded `type` tag and an `origin` tag (`ai` on + // create — WP-O2b). So the created prompt carries the two supplied tags + // plus the computed type and origin tags. expect(created.body.created[0].tagIds).to.include.members([category.body.id, child.body.id]); - expect(created.body.created[0].tagIds).to.have.lengthOf(3); + expect(created.body.created[0].tagIds).to.have.lengthOf(4); expect(created.body.failed).to.deep.equal([]); // by_tags correlation: the id-based create embeds the tag ids, so filtering the prompt list From 2708d752209135599c6760a7ee0c3d627d91cc16 Mon Sep 17 00:00:00 2001 From: Alicia Adriani Date: Tue, 21 Jul 2026 00:20:27 +0200 Subject: [PATCH 9/9] chore: re-trigger MysticatBot review + CI (LLMO-6275) Empty commit to fire a synchronize event on #2864 after reopen. Squashed away on merge. (--no-verify: local node_modules is stale post-main-merge re: the PE-client facade; CI type-check is green on this tree.) Co-Authored-By: Claude Opus 4.8