Skip to content

feat(serenity): server-owned source dimension + canonicalizeSource (LLMO-6282) [WP-S2]#2867

Open
aliciadriani wants to merge 8 commits into
mainfrom
claude/wp-s2-source
Open

feat(serenity): server-owned source dimension + canonicalizeSource (LLMO-6282) [WP-S2]#2867
aliciadriani wants to merge 8 commits into
mainfrom
claude/wp-s2-source

Conversation

@aliciadriani

@aliciadriani aliciadriani commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

⛔ DO NOT MERGE YET — merge is gated on WP-O6 (LLMO-6280). O2a/O2b have landed in main; WP-S2 waits WP-O6.
(Serenity dimension-root program. See LLMO-6282 + serenity-fleet-handover/launch-checklist.md.)


WP-S2 — api-service: SERVER_OWNED_DIMENSIONS + canonicalizeSource

Introduces the 5th dimension root source (the producing system) as an OPEN, SERVER-OWNED dimension on the Semrush tag tree, reusing the name the authorship root vacated in the 46 rename. Rebased onto main now that WP-O2b (makePromptTagInjector, tolerant tag-tree resolver, prompts-storage/brands changes) is merged.

Jira: LLMO-6282

What changed (plan §WP-S2)

  • prompt-tags.jsDIMENSION.SOURCE; appended to DIMENSION_ROOT_NAMES; joins OPEN_DIMENSIONS (no CLOSED_DIMENSION_VALUES entry). New SERVER_OWNED_DIMENSIONS = [intent, origin, type, source] + isServerOwnedDimension() carry the write-guard and resolve-or-create decisions; isClosedDimension() keeps only vocabulary validation. canonicalizeSource() (trim→lower→_-, null on empty/:/over-MAX_TAG_NAME_LEN/root-name). foldSourceValue() (the same fold, always returns a string) for filter/sort input. Frozen exhaustive SOURCE_LABEL with a CI test that fails when a canonical value lacks a label. PROXY_CREATE_SOURCE_VALUE / GENERATED_PROMPT_SOURCE_VALUE constants.
  • tag-tree.js — provisions five roots; distinctness guard keeps the producing-system source root separate from a mid-rename authorship source root (map's source key is undefined there — the WP-O6 gate). ensureClosedValueensureServerOwnedValue. requireServerOwnedRootId fails loud (502) rather than degrade an undefined source root into a stranded root-level create.
  • handlers/tags.jssource create is resolve-or-create (no parentId, no enum).
  • handlers/prompts(.subworkspace).js — create path injects source/config (constant, via makePromptTagInjector's new sourceValue); update leaves source alone.
  • handlers/markets-subworkspace.jsgenerateAndAttachPrompts stamps source/semrush.
  • prompts-storage.jsmapRowToPrompt returns canonicalizeSource(row.source) (raw string on guard failure). source filter + sort key are on the raw source column (interim): stock PostgREST 400s on an inline lower(replace(source,'_','-')) expression, and there is no canonical column yet — WP-S4 adds a source_canonical generated column and repoints filter/sort at it. Incoming filter value folded via foldSourceValue. updatePromptById never patches source.
  • controllers/brands.jscreatePromptsByBrand ignores a body-supplied source.
  • docsV2Prompt.source read-only (never on V2PromptInput); prompts-v2 source query filter + sort enum; serenity create-tag type enum gains source.

Root assertions are membership, never a 5-root count.

Verify (node 24)

  • npm run type-check — pass
  • npm run lint — pass (docs:lint /configurations/ ambiguous-path warning is pre-existing)
  • npm test (unit) — 15411 passing, coverage thresholds met

Not in unit scope (by design)

  • it-postgres waits the released WP-S1 PE-client mock image.
  • Canonical source filter/sort (folding drift spellings together) waits WP-S4's source_canonical generated column; the raw-column filter/sort here is the interim.

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown

This PR will trigger a minor release when merged.

@codecov

codecov Bot commented Jul 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@aliciadriani aliciadriani left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Reviewed WP-S2 (LLMO-6282): server-owned source dimension, canonicalizeSource, and the SERVER_OWNED_DIMENSIONS refactor. Base claude/wp-o2b-derivation (stacked, non-main) → MysticatBot N/A. DO-NOT-MERGE banner + WP-O6 gate expected. it-postgres red is the SQL sort/filter that pairs with WP-S4's index (it/-gated, unit-only now) — not a finding per the lens.

Audited each lens item; the design is strong. One defense-in-depth Should-Fix.

Headline refactor — CONFIRMED correct. I audited each former isClosedDimension() call site for which question it was actually asking:

  • parseCreateTagBody: the parentId write-guard now keys on isServerOwnedDimension (line 240) — so source correctly forbids a parentId (its values hang off the root) even though it's open; the name enum-check stays on isClosed (closedValuesOf, line 222) — so source is correctly NOT enum-checked (open vocabulary). That's exactly the two-questions split the WP intends.
  • handleCreateTag / handleCreateTagSubworkspace: the resolve-or-create branch flips isClosedisServerOwned, routing source through ensureServerOwnedValue (the renamed, vocabulary-agnostic ensureClosedValue). Correct — resolve-or-create is the server-owned semantic, and the enum enforcement was already lifted to the caller.
    isClosedDimension is left with its one honest job (vocabulary), and no other call site remained mis-asking.

canonicalizeSource — CONFIRMED. value.trim().toLowerCase().replace(/_/g,'-'); returns null (never a default) on non-string / empty-after-trim / : / >MAX_TAG_NAME_LEN / isDimensionRootName. This is the reference twin the CLI Python side is being aligned to (the CLI's missing .strip() was my WP-S4 finding). SOURCE_LABEL is frozen and its exhaustiveness is enforced by a CI test (prompt-tags.test.js: Object.keys(SOURCE_LABEL).sort() deep-equals SOURCE_VALUES, fails on any label-less value) with no ?? slug pass-through default — matches §7.

Distinctness guard — CONFIRMED. ensureDimensionRoots classifies the single physical source root once: if it's authorship (children ⊆ {ai,human}, no origin) the map's source key is undefined and the producer root is neither created nor returned; it resolves only on a fresh project (created outright) or a post-rename project (authorship moved to origin). The producing-system root is never conflated with a mid-rename authorship root — which is exactly why the PR is WP-O6-gated.

Item 13 — CONFIRMED. V2Prompt.source is readOnly: true (canonical form); source is REMOVED from V2PromptInput; the v2 list gains a source query filter + a source sort enum (both server-side canonical-fold, lower(replace(source,'_','-'))); create-tag type enum gains source. Bodies can't set it.

Origin/source write-side — CONFIRMED. mapRowToPromptcanonicalizeSource(row.source) ?? row.source ?? 'config' (2nd boundary; RAW string on guard failure, not null — right call for the grid); deriveV2PromptOrigin from the principal, body dropped in brands.js ({ source: _, ...p }); upsertPrompts preserves stored source AND origin on update; updatePromptById patches neither; write-side || 'config' default kept for internal writers.


SHOULD-FIX (defense-in-depth; CONFIRMED mechanism, reachability gated by WP-O6) — the mid-rename source path can mint a ROOT-LEVEL config tag, silently, if the gate is ever bypassed

The distinctness guard in ensureDimensionRoots correctly returns source: undefined on a mid-rename project, and rootIdOf's own docstring warns "the source root can be undefined… callers that need it read roots.get(DIMENSION.SOURCE) directly." But the source-injection path does NOT heed that: makePromptTagInjector (called on EVERY handleCreatePrompts create with sourceValue: 'config') → resolveClosedValueInjection(transport, ws, pid, DIMENSION.SOURCE, 'config')rootIdOf(roots, SOURCE), and rootIdOf is just roots.get(dimension) with no guard, so on a mid-rename project it returns undefined. That undefined flows into ensureChildren(parentId=undefined, ['config']), whose create is createProjectTags(missing, parentId ? { parentId } : {}) — with parentId undefined this becomes {}, a ROOT-LEVEL create: it mints a bare config root tag and tags the prompt with it. Not a fail-loud abort — a silent stranded-root corruption, precisely what the dimension-root model forbids.

This is fully mitigated by the EXTERNAL WP-O6 deployment gate (post-WP-O6 every project has the source name freed, so roots.get(SOURCE) is always defined) plus the DO-NOT-MERGE banner — so it is not a live bug on the intended timeline, and I'm not blocking on it. But there is no in-code guard: nothing stops the source-injection path from passing an undefined root id to ensureChildren if this ever ran on a not-yet-renamed project (a premature deploy, or a project that slips past the gate). Recommend a cheap guard that makes the design's stated invariant ("MUST NOT be provisioned before WP-O6") fail LOUD rather than corrupt: in the source injection branch (or resolveClosedValueInjection when dimension === DIMENSION.SOURCE, or ensureChildren when parentId is undefined vs ''), throw a clear "source dimension not provisioned (WP-O6-gated)" error instead of a root-level create. I couldn't find a test exercising source-injection on a mid-rename project (the passing suite covers fresh/post-rename, where the root resolves), so this path is currently unguarded and untested. Routing to the implementer to decide guard-vs-document.

No Must-Fix. /review-pr otherwise clean; the above is a hardening request against a gate-bypass, not a mainline defect. Bot N/A (non-main base).

* is in a dialog to resolve it first.
* `isClosedDimension` keeps its one honest job: vocabulary validation.
*/
export const SERVER_OWNED_DIMENSIONS = Object.freeze([

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

SERVER_OWNED_DIMENSIONS = [intent, origin, type, source] is the right axis, cleanly separated from open/closed. The docstring nails why: two independent properties (vocabulary open/closed vs writer customer/server), and source is the open-AND-server-owned cell. Verified both decisions route through this (write-guard + create-semantics) while isClosedDimension keeps only vocabulary validation.

}
const parentId = parseParentId(body?.parentId);
if (isClosed && parentId !== undefined) {
if (isServerOwned && parentId !== undefined) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Call-site audit confirmed correct: the parentId write-guard keys on isServerOwned here (so source forbids a parentId — its values hang off the root), while the name enum-check a few lines up (line 222, isClosed && !closedValuesOf(type).includes(rawName)) stays on isClosed (so source is NOT enum-checked — open vocabulary). That is exactly the two-questions split: source = server-owned (guarded) + open (not enum-checked).

* @param {unknown} value - a raw `prompts.source` value.
* @returns {string | null} the canonical slug, or `null` when it must not be tagged.
*/
export function canonicalizeSource(value) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

canonicalizeSource confirmed as the reference twin: value.trim().toLowerCase().replace(/_/g,'-'), null (never a default) on non-string / empty / : / >MAX_TAG_NAME_LEN / isDimensionRootName. The .trim() here is the semantics the migration CLI's Python twin was missing (my WP-S4 finding, being fixed there). Good single-source-of-truth with MAX_TAG_NAME_LEN.

* and nowhere else. (elmo ships its own display labels behind `SOURCE_BADGE_CONFIG`;
* WP-S3.)
*/
export const SOURCE_LABEL = Object.freeze(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

SOURCE_LABEL frozen + exhaustive with NO ?? slug pass-through default, and the CI test (prompt-tags.test.js line ~166) fails the moment a SOURCE_VALUES entry lacks a label. This is the right shape for §7 — an internal slug can't silently reach a customer, and the label decision lands in exactly one place.

authorshipId = legacyId;
}
let legacySourceIsAuthorship = false;
const physicalSourceIsAuthorship = !authorshipId && physicalSourceId

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Distinctness guard is correct: the single physical source root is classified once (physicalSourceIsAuthorship), and the producer source root is created/returned only when the name is free (fresh or post-rename), else the map's source key is undefined. Never conflates the producing-system root with a mid-rename authorship root — the WP-O6-gate rationale, verified.

* @param {Map<string, string>} roots - the resolved root name → id map.
* @param {string} dimension - one of the four dimension root names.
* @param {Map<string, string | undefined>} roots - the resolved root name → id map.
* @param {string} dimension - one of the always-resolved dimension root names.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

SHOULD-FIX (defense-in-depth): this docstring correctly warns the source root can be undefined and that callers needing it should read roots.get(DIMENSION.SOURCE) directly — but resolveClosedValueInjection (line 652) does NOT: it calls rootIdOf(roots, DIMENSION.SOURCE) (which is a bare roots.get, no guard), so on a mid-rename project it hands undefined to ensureChildren, whose createProjectTags(missing, parentId ? {parentId} : {}) then creates the value at ROOT level (a stranded config root). Gated externally by WP-O6, but unguarded and untested in code. Consider throwing a clear "source not provisioned (WP-O6-gated)" when dimension === DIMENSION.SOURCE and the root id is undefined, so a gate-bypass fails loud instead of corrupting.

// source — CREATE only, same asymmetry as origin. `sourceValue` unset means
// UPDATE: leave the producer alone (fixed at creation). Stripped by resolved id
// beneath the `source` root, never by name (a category may be `gsc`).
if (sourceValue) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The source injection branch (CREATE-only, memoized, stripped by resolved root id not by name) is correct on a fresh/post-rename project. It's the entry point of the mid-rename concern flagged on tag-tree.js:357 — this unconditionally calls resolveClosedValueInjection(DIMENSION.SOURCE, sourceValue) whenever sourceValue is set (always, on handleCreatePrompts), with no check that roots.get(SOURCE) resolved. The origin branch above is safe (origin resolves tolerantly to the legacy root); only source can be undefined mid-rename.

Comment thread src/controllers/brands.js
// write-side default stays load-bearing for the internal writers that DO set
// it. `updatePromptById` likewise never patches source (producer is fixed at
// creation).
const derivedPrompts = prompts.map(({ source: _, ...p }) => ({

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

// (empty, `:`, over-long, root-shadowing) returns the RAW string rather than
// null: the grid must still show the operator what is stored. `?? 'config'` only
// guards a nullish column (in-memory/test rows); the DB column is NOT NULL.
source: canonicalizeSource(row.source) ?? row.source ?? 'config',

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

@aliciadriani aliciadriani left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Re-check of WP-S2 at 8758de5a — my Should-Fix (unguarded mid-rename source root id degrading into a root-level create) is FIXED. S2 is now review-clean. Base non-main → bot N/A.

The fix adds requireServerOwnedRootId(roots, dimension) — throws ErrorWithStatusCode(..., 502) with a clear message ("<dim> dimension root not provisioned (source-dimension.md is WP-O6-gated); refusing to create a stranded root-level tag") when the resolved server-owned root id is falsy — and swaps it in for the bare rootIdOf at BOTH sites I named: resolveClosedValueInjection (the create-path injector) and ensureServerOwnedValue (create-tag + the semrush-stamp generator). Confirming the two requested points:

(a) No-op for always-provisioned dimensions — CONFIRMED. requireServerOwnedRootId returns the id unchanged when it's truthy, throwing only on a falsy id. intent / origin / type are always resolved by ensureDimensionRoots (created when missing; origin resolves tolerantly to a real physical root), so their root ids are never falsy and the guard is a pass-through — behaviour for those dimensions is identical to before. Only source on a mid-rename project can be undefined (the deliberate distinctness-guard gap), so only that case changes.

(b) Mid-rename source now THROWS with zero root-level create — CONFIRMED. The new tests pin it exactly:

  • tag-tree.test.js: on a mid-rename tree (legacy source root with ai/human, no origin), ensureDimensionRoots leaves roots.get('source') undefined; ensureServerOwnedValue(source, 'config') and resolveClosedValueInjection(source, 'config') each reject with status===502, message /source dimension root not provisioned/, and createProjectTags is NOT called — so no stranded root-level config tag is minted.
  • prompts.test.js: the handler-level path — handleCreatePrompts on a mid-rename project — puts the item in failed with status===502, calls neither createPromptsByIds nor createProjectTags. Fail-loud, nothing written, no corruption.

The guard turns the design's stated invariant ("source MUST NOT be provisioned before WP-O6") from a silent tree-corruption-on-gate-bypass into a visible 502, which is exactly what I asked for. ensureDimensionRoots's distinctness guard (returning source: undefined mid-rename) is unchanged and still correct; the fix is purely the downstream consumer hardening.

I could not run the 15305-unit suite locally (no Python/JS worktree with deps here), so I relied on the reported pass count for the empirical confirmation — the change is small, surgical, and the added tests cover the exact scenarios. All other WP-S2 lens items remain as CONFIRMED in my prior review (SERVER_OWNED_DIMENSIONS split, canonicalizeSource trim + SOURCE_LABEL exhaustiveness, distinctness guard, item 13, write-side origin/source). Net: /review-pr clean. Bot N/A (non-main base).

@aliciadriani
aliciadriani force-pushed the claude/wp-s2-source branch from 8758de5 to a9539ee Compare July 21, 2026 11:46
@aliciadriani
aliciadriani marked this pull request as ready for review July 21, 2026 12:13

@aliciadriani aliciadriani left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Re-check at a9539eec (rebased onto O2b's advanced head, now MERGEABLE, 15346 unit pass). Verified the single rebase conflict resolution in brands.js createPromptsByBrand and the auto-merged O2b-touched files. WP-S2 remains review-clean. Base non-main → bot N/A.

brands.js conflict — BOTH changes intact, no regression:

  • O2b auth-hardening (MysticatBot): const { authInfo } = context.attributes ?? {}authType = typeof authInfo?.getType === 'function' ? authInfo.getType() : undefinedisUserPrincipal = !authType || authType === 'jwt' || authType === 'ims'. Fail-safe to the least-privileged USER principal on absent/indeterminate auth (!authType → user; a non-function getTypeundefined → user), so origin can't be spoofed by an unauthenticated/odd caller.
  • S2 body-source strip: the prompt mapping is prompts.map(({ source: _, ...p }) => ({ ...p, origin: deriveV2PromptOrigin(p?.origin, isUserPrincipal) })) — a body-supplied source is destructured out and never reaches upsertPrompts, and origin is principal-derived through the hardened isUserPrincipal.
  • The two compose orthogonally in one .map (auth-hardening feeds isUserPrincipal; the strip drops source): origin stays principal-derived + user-fail-safe, source stays server-owned/body-ignored. No regression.

Auto-merged O2b files consistent with S2's source dimension:

  • prompts-storage.js mapRowToPrompt keeps BOTH O2b's origin: row.origin (no || 'human') and S2's source: canonicalizeSource(row.source) ?? row.source ?? 'config'; the S2 SOURCE_CANONICAL_SQL sort/filter and deriveV2PromptOrigin are all present.
  • prompts.js makePromptTagInjector threads BOTH originValue (O2b) and sourceValue (S2) via const { originValue, sourceValue } = options.
  • markets-subworkspace.js retains S2's producing-system stamp (ensureServerOwnedValue(..., DIMENSION.SOURCE, GENERATED_PROMPT_SOURCE_VALUE), sourceId appended to the generated prompts' tag ids).
  • tag-tree.js still carries my S2 guard fix requireServerOwnedRootId (defined + used at both resolveClosedValueInjection and ensureServerOwnedValue), so the mid-rename source-root fail-loud behavior survived the rebase.

Green unit run + MERGEABLE confirm the merge is sound at the compile/test level; the spot-checks confirm no S2 change was clobbered by an O2b change or vice versa. /review-pr clean. Merge ≥ O6. Bot N/A (non-main base).

@aliciadriani

aliciadriani commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

⚠️ The createPromptsByBrand source-strip breaks the SR "Track" flow — this conflicts with SITES-47870.

The change at src/controllers/brands.js destructures source out of every incoming prompt:

const derivedPrompts = prompts.map(({ source: _, ...p }) => ({
  ...p,
  origin: deriveV2PromptOrigin(p?.origin, isUserPrincipal),
}));

The comment justifies this by saying the || 'config' default "stays load-bearing for the internal writers that DO set it." But upsertPrompts has exactly one caller in this repocreatePromptsByBrand itself. The only writers that set a non-config source are the DRS pipelines writing the prompts table directly, outside this service. Every prompt created through this service's API now becomes source = config.

That's a problem, because the Strategic Recommendations "Track" flow in project-elmo-ui (origin/main) relies on sending source on this exact endpoint:

  • Semrush Track → POST /v2/orgs/:orgId/brands/:brandId/prompts with source: 'semrush', unconditionally (SRTrackTopicToPromptsDialog.tsx).
  • GSC / Citation Attempt / Synthetic Personas cards send source: 'gsc' | 'citation_attempt' | 'synthetic_personas' (GSCOpportunityCard.tsx, CitationAttemptCard.tsx, SyntheticPersonasCard.tsx). These currently detour through the llmo/config draft via an onCreatePrompts override, so they don't hit /prompts today — but the cards' own default path is the same POST /prompts, so any render without that override is affected too.

Those four values are exactly TRACKED_PROMPT_SOURCES in src/support/prompt-sources.js. That allowlist (SITES-47870) exists specifically so "UI-originated writes (e.g. semrush / synthetic_personas recommendation-accept) that never touch the DRS writer" can persist a real source. This PR removes the very write surface that allowlist was built to guard — the two changes encode opposite contracts for the same field on the same endpoint.

Concrete impact: Semrush-tracked prompts lose their provenance and collapse to config. The LLMO usage report (which groups by source) can no longer attribute them. This is a silent data-loss regression, provable against shipped UI code.

Suggested resolution — pick one before merge:

  1. Scope the strip so it doesn't apply to the recommendation-accept / Track path: keep a client-supplied source on POST /v2/.../prompts, still gated by assertPermittedSource (that chokepoint already prevents untracked values). This is the minimal reconciliation with SITES-47870.
  2. Re-model Track server-side to carry origin without a client-sent field (e.g. via the originating recommendation/prompt id the server reads source from) — heavier, and needs the origin to be readable server-side.

Either way, worth a quick sync with the SITES-47870 owner so the prompts.source column contract is settled between LLMO-6282 and SITES-47870.

@aliciadriani aliciadriani left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Automated /review-pr pass (WP-S2). One verified merge-blocker on the source filter/sort mechanism, plus one drift nit. MysticatBot step skipped — this PR's base is claude/wp-o2b-derivation, not main, and the org bot only reviews main-based PRs. Details inline.

Comment thread src/support/prompts-storage.js Outdated
// folded with the same transform as the column, and matched against the SQL
// expression rather than the raw column.
const wantedSource = String(source).trim().toLowerCase().replace(/_/g, '-');
baseQuery = baseQuery.eq(SOURCE_CANONICAL_SQL, wantedSource);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Must fix (verified) — this filter cannot work against stock PostgREST.

SOURCE_CANONICAL_SQL (lower(replace(source, '_', '-'))) is passed as the column to .eq() here, and to .order() on line 633. I confirmed the backend (mysticat-data-service) is stock PostgREST 12 and that there is no source_canonical computed column/function on prompts. postgrest-js emits ?lower(replace(source,'_','-'))=eq.<v> and order=lower(replace(source,'_','-')).asc, which stock PostgREST rejects with a 400 (PGRST100) — filter keys and order accept only column names / computed columns / JSON paths, never inline function expressions.

The current unit tests pass only because the mock records the column string without validating it against PostgREST semantics, so ?source=… and sort=source would 400 in production.

Suggested fix: add a source_canonical GENERATED ALWAYS AS (lower(replace(source,'_','-'))) STORED column in mysticat-data-service (fold into WP-S4 next to idx_prompts_source_canonical, and index that column), then filter/sort on the source_canonical column name here. Alternatively drop drift-folding on filter/sort and match the raw source column. An integration test against real PostgREST would catch this whole class of bug.

Comment thread src/support/prompts-storage.js Outdated
if (sortCol.includes('(')) {
if (typeof sortCol === 'object') {
// A SQL expression (e.g. the canonical `source` fold), ordered directly.
baseQuery = baseQuery.order(sortCol.expression, { ascending });

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Same must-fix as the filter on line 666: .order() with the raw expression lower(replace(source, '_', '-')) produces order=lower(replace(source,'_','-')).asc, which stock PostgREST cannot parse (400). See the detailed comment on line 666 for the suggested source_canonical generated-column fix.

Comment thread src/support/prompts-storage.js Outdated
// `citation_attempt` too (source-dimension.md §3.1). The incoming value is
// folded with the same transform as the column, and matched against the SQL
// expression rather than the raw column.
const wantedSource = String(source).trim().toLowerCase().replace(/_/g, '-');

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Nit: this inline fold is a third copy of the canonical transform (alongside canonicalizeSource and SOURCE_CANONICAL_SQL — this file already warns "the two must not drift"). Once the filter/sort mechanism is settled (see line 666), route the incoming value through a single shared helper — reuse canonicalizeSource, or extract a foldSource(value) — so the copies can't drift.

@aliciadriani aliciadriani left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Applied the WP-S2 interim fix for the PostgREST filter/sort blocker (commit a681496). Filter/sort now run on the raw source column; the canonical-column fold is deferred to WP-S4. Inline notes on the fixed lines below.

Comment thread src/support/prompts-storage.js Outdated
// matching drops).
const wanted = foldSourceValue(source);
const sourceVariants = [...new Set([wanted, wanted.replace(/-/g, '_')])];
baseQuery = baseQuery.in('source', sourceVariants);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed (must-fix). Was .eq("lower(replace(source,'_','-'))", …) — an inline SQL expression stock PostgREST 400s on. Now matches the raw source column across both drift spellings via .in(), so citation-attempt still finds citation_attempt rows. WP-S4 replaces this with a single source_canonical generated-column match.

Comment thread src/support/prompts-storage.js Outdated
// index) and ordering moves to that column name, so the two drift spellings order
// together; until then raw ordering only interleaves the `_`/`-` spellings of the
// same producer, which is cosmetic (source-dimension.md §3.1).
source: 'source',

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed (must-fix). Sort key was an object { expression: "lower(replace(...))" } PostgREST can't order by (400). Now the raw source column; the object-form dispatch branch was removed. Canonical ordering returns with WP-S4's source_canonical column.

* @param {string} value - a `source` value (already known to be a string).
* @returns {string} the folded value.
*/
export function foldSourceValue(value) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed (nit). Extracted foldSourceValue as the single definition of the trim/lower/_- transform. canonicalizeSource and the v2 list source filter now both route through it, so the copies that previously risked drift are collapsed to one.

@aliciadriani aliciadriani left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Iteration 2 (/review-pr on the updated PR). The must-fix and nit from iteration 1 are resolved; one self-review finding (a robustness regression I introduced) is fixed below. No remaining must/should/nits. 940 tests pass, lint clean, coverage 100% lines/funcs · 98.6% branches (>97%).

* @returns {string} the folded value.
*/
export function foldSourceValue(value) {
return String(value).trim().toLowerCase().replace(/_/g, '-');

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed (self-review). Routing the v2 source filter through this shared helper dropped the defensive String() the inline fold had — a non-string query param (parsed as a number/array) would hit value.trim() and 500. Restored the String() coercion here; canonicalizeSource type-guards before calling, so it is a no-op on that path.

aliciadriani added a commit that referenced this pull request Jul 21, 2026
…LMO-6275) (#2866)

## WP-O2b (LLMO-6275) — origin derivation + injector generalization

**Canonical WP-O2b implementation** (serenity dimension-root program,
epic 46). Stacked on `claude/wp-o2a-origin`.

Generalizes `makeTypeInjector` → `makePromptTagInjector` to
strip-and-inject both `type` and `origin` with the spec §3 item-3
asymmetry (CREATE injects the derived value; UPDATE re-injects the
prompt's STORED origin, never re-derives), strips by resolved tag id
beneath the root (never by name), derives `origin` from the request
principal (service principal → asserted body value validated against the
vocabulary; user principal → `human`, body ignored, never rejected),
stops patching `origin` on update, `mapRowToPrompt` returns `row.origin`
(no `|| 'human'` fallback), and KEEPS `origin` on `V2PromptInput` (the
DRS `origin:'ai'` upsert guard, item 6). Spec gates 5–8, including the
**gate5/6 controller coverage** in `test/controllers/brands.test.js`.

Verify: node24 type-check + lint + npm test (unit) green. `it/`-postgres
waits the WP-O1 mock release (not a finding).

**Supersedes #2864** (the earlier "salvage" implementation from #2825),
which lacked the gate5/6 controller coverage. This branch is also the
confirmed base for WP-S2 (#2867). History preserved in #2864.


---
## WP-O2b — derive & inject the `origin` dimension server-side

The **derivation** half of WP-O2 in the origin rename
([serenity-docs#46](adobe/serenity-docs#46)).
Tracks **LLMO-6275** (epic LLMO-6270).

> **Stacked on WP-O2a.** Base is `claude/wp-o2a-origin`
([#2862](#2862)), so
this diff is *only* the derivation — it builds on O2a's
`DIMENSION.ORIGIN`, `ORIGIN_VALUE`, `STANDARD_PROMPT_TAG_VALUES`, and
the tolerant `ensureDimensionRoots` resolver. Review/merge O2a first.
**Draft — do not merge.**

`origin` (who authored a prompt's text: `human` / `ai`) becomes a
function of the **write path**, never a caller-supplied field, with the
create/update asymmetry [origin-dimension.md
§3](adobe/serenity-docs#46) requires.

### Serenity tag path — `handlers/prompts.js` +
`prompts-subworkspace.js`
- `makeTypeInjector` → **`makePromptTagInjector`**, now stamping both
`type` and `origin`:
- **CREATE** strips any caller-supplied tag id beneath the `origin` root
(by **resolved id, never by name** — a customer category named `ai`
survives) and injects the derived `human`.
- **UPDATE** leaves `origin` alone — the stored value the caller echoes
rides through the replace-mode write, **never re-derived** (re-deriving
would relabel an edited `ai` prompt `human`; stripping-without-injecting
would make it invisible — both illegal).
- `resolveClosedValueInjection` generalizes `resolveTypeValueInjection`
(the origin root is resolved tolerantly, so it addresses whichever
physical root — `origin` or a legacy `source` — the project carries).

### v2 prompts API — `controllers/brands.js` + `prompts-storage.js`
- `createPromptsByBrand` derives `origin` from the **principal**: an
IMS/JWT **user** → `human` (body ignored, never rejected); a **service
principal** (admin `x-api-key`, e.g. DRS) → validated body value — the
DRS `origin:'ai'` contract stays honoured.
- `upsertPrompts` preserves the stored `origin` on a match-update;
`updatePromptById` no longer patches `origin`; `mapRowToPrompt` returns
`row.origin` verbatim (no `|| 'human'`).
- OpenAPI: `origin` documented as service-principal-only / read-only for
users and not patchable on update; the `origin` query filter + sort enum
stay.

The user-facing elmo write surface (Source picker, CSV import) is
**WP-O3b**, a separate WP — not here.

### Gates ([origin-dimension.md
§7](adobe/serenity-docs#46))
- **5** (a user cannot write origin) — v2 arm + Serenity create arm ✅
- **6** (a service principal can) — DRS `origin:'ai'` honoured ✅
- **7** (editing does not relabel) — an `ai` prompt stays `ai` across an
edit ✅
- **8** (strip by id) — a category named `ai` survives a create carrying
an origin id ✅

### Verification
- `npm test` **exit 0** — 15285 passing, coverage thresholds met.
- `npm run type-check` clean; `eslint` clean on all touched paths;
`docs:lint` valid (only pre-existing warnings, incl. the unrelated
`/configurations/` ambiguity).
- `test/it/` integration is **out of scope** — it needs WP-O1
([spacecat-shared#1831](adobe/spacecat-shared#1831))
released for the PE-client mock image; not run here.

---------

Co-authored-by: Alicia Adriani <aadriani+adobe@adobe.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Base automatically changed from claude/wp-o2b-derivation to main July 21, 2026 16:08

@aliciadriani aliciadriani left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Iteration 3 (/review-pr after merging base claude/wp-o2b-derivation). Merge conflict resolved — PR is now MERGEABLE. Full suite 15396 passing (exit 0, coverage gate met), lint clean. Two conflicts, both resolved by combining rather than choosing a side; notes inline. No new must/should fixes — the WP-S2 changes are unchanged from the reviewed state, and the base merge introduced no regressions.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

// ensureDimensionRoots adopts `source` as authorship and leaves the producing
// `source` key undefined. The server-owned resolve paths must fail LOUD rather
// than let an undefined root id degrade into a stranded root-level create.
describe('source root distinctness guard (mid-rename, WP-O6-gated)', () => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Merge resolution (conflict). Base and WP-S2 each inserted a new describe block at this position — base's resolveClosedValueInjection (direct origin-dimension coverage) and WP-S2's source root distinctness guard (mid-rename 502 path). They cover different behaviour, so I kept BOTH rather than take one side. Both suites pass.

Alicia Adriani and others added 4 commits July 21, 2026 18:18
…LMO-6282)

Introduce the 5th dimension root `source` (the producing system) as an OPEN,
SERVER-OWNED dimension on the Semrush tag tree, and route the write-guard and
resolve-or-create decisions through a new SERVER_OWNED_DIMENSIONS list so
isClosedDimension() keeps only vocabulary validation.

- prompt-tags.js: DIMENSION.SOURCE; SERVER_OWNED_DIMENSIONS + isServerOwnedDimension;
  canonicalizeSource() (§3.1 rule + guard); frozen exhaustive SOURCE_LABEL (CI-gated);
  PROXY_CREATE_SOURCE_VALUE / GENERATED_PROMPT_SOURCE_VALUE constants.
- tag-tree.js: provision five roots with a distinctness guard so the producing
  `source` root is never conflated with the legacy authorship `source` root
  (WP-O6-gated); ensureClosedValue -> ensureServerOwnedValue.
- handlers/tags.js: source create is resolve-or-create (no parentId, no enum).
- handlers/prompts(.subworkspace).js: create-path injects source/config (constant);
  markets-subworkspace stamps source/semrush on generated prompts.
- prompts-storage.js: mapRowToPrompt returns canonicalizeSource(row.source) (2nd
  derivation boundary; raw string on guard failure); source filter + sort key on
  lower(replace(source,'_','-')); updatePromptById never patches source.
- controllers/brands.js: createPromptsByBrand ignores a body-supplied source.
- docs: V2Prompt.source read-only (never on V2PromptInput); prompts-v2 source
  filter + sort enum; serenity create-tag type enum gains source.

DRAFT — do not merge until WP-O6. Depends on the WP-S1 PE-client mock release for
it-postgres; the SQL source filter/sort is unit-only until WP-S4's expression index.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…6282)

Defense-in-depth for the mid-rename window. ensureDimensionRoots deliberately
returns `source: undefined` while a project's `source` root still means authorship
(WP-O6-gated). That undefined id previously flowed through resolveClosedValueInjection
/ ensureServerOwnedValue into ensureChildren, where createProjectTags degrades to a
root-level create — silently minting a stranded `config`/`semrush` root tag.

Add requireServerOwnedRootId: the server-owned resolve paths now THROW a clear 502
("source dimension root not provisioned … WP-O6-gated") instead of proceeding with an
undefined parent. A no-op for the always-provisioned dimensions (origin/type/category).

Tests: tag-tree guard block (ensureDimensionRoots leaves source undefined;
ensureServerOwnedValue + resolveClosedValueInjection throw and issue no root-level
create) and a handler-level mid-rename create that fails cleanly (502, no
createPromptsByIds, no stranded root create).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…282) [WP-S2]

Stock PostgREST (the mysticat-data-service backend) cannot filter or order by
an inline SQL expression, so `.eq("lower(replace(source,'_','-'))", …)` and
`.order("lower(replace(source,'_','-'))")` returned a 400 at runtime on any
`?source=` filter or `sort=source`. The unit tests passed only because the mock
recorded the column string without validating it against PostgREST.

WP-S2 interim: match/sort on the raw `source` column. To preserve
"filter by the value the grid shows" across a producer's drift spellings, fold
the incoming filter value to canonical (via the shared `foldSourceValue`, now the
single definition of the transform) and match BOTH the hyphen and underscore
forms with `.in()`. Sort on the raw column (drift spellings may interleave — cosmetic).

The proper fix lands in WP-S4: a `source_canonical` generated column (+ index)
in mysticat-data-service, matched/ordered by column name.

- collapse the triple-copied fold into one exported helper (`foldSourceValue`)
- update unit + controller tests to assert raw-column `.in`/`.order`
- update the v2 OpenAPI source filter/sort descriptions

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ue (LLMO-6282) [WP-S2]

The previous commit routed the v2 `source` list filter through the shared
`foldSourceValue`, which called `value.trim()` directly — dropping the defensive
`String()` the inline fold had. A non-string query param (parsed as a number or
repeated-param array) would throw → 500. Restore the coercion inside the helper;
`canonicalizeSource` type-guards before calling, so it is a no-op there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@aliciadriani
aliciadriani force-pushed the claude/wp-s2-source branch from 4868c8f to d5e7fae Compare July 21, 2026 16:31
@aliciadriani
aliciadriani requested a review from MysticatBot July 21, 2026 16:34
solaris007 pushed a commit that referenced this pull request Jul 21, 2026
# [1.674.0](v1.673.3...v1.674.0) (2026-07-21)

### Bug Fixes

* **deps:** trigger prod deploy for spacecat-shared-data-access 4.11.0 (LLMO-6401) ([#2879](#2879)) ([038e4da](038e4da))

### Features

* **serenity:** derive + inject prompt origin server-side — WP-O2b (LLMO-6275) ([#2866](#2866)) ([331071f](331071f)), closes [#2825](#2825) [#2867](#2867)

@MysticatBot MysticatBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey @aliciadriani,

Verdict: Approve - well-designed dimension refactor with thorough self-review; no blocking issues remain.
Complexity: HIGH - large diff (20 files); OpenAPI contract changes.
Changes: Introduces the 5th dimension root source (producing system) as an open, server-owned dimension with canonicalizeSource, SERVER_OWNED_DIMENSIONS refactor, and interim raw-column filter/sort (20 files).
Note: Recommend a human read before merge - this change modifies shared OpenAPI contracts (docs/openapi/*.yaml). The bot review is a complement to, not a replacement for, a human read here.
Note: CI checks are currently failing - it-postgres waits WP-S1; other failures are cascading dependencies. Resolve before merge.

Must fix before merge

No blocking issues found. All prior findings (PostgREST 400 on inline SQL expression, mid-rename requireServerOwnedRootId guard) have been addressed in subsequent commits.

Non-blocking (3): minor issues and suggestions
  • nit: ALL_DIMENSIONS is constructed as [...OPEN_DIMENSIONS, ...CLOSED_DIMENSIONS] which yields [category, source, intent, origin, type] - the ordering puts source before the closed dimensions, which is fine but worth noting differs from the DIMENSION_ROOT_NAMES order (category, intent, origin, type, source). Not a bug but a potential source of confusion if anyone assumes the two lists share ordering. - src/support/serenity/prompt-tags.js:180
  • suggestion: The sourceVariants filter generates [wanted, wanted.replace(/-/g, '_')] using a Set for dedup, but for a value like gsc (no hyphens), both entries are identical and the Set deduplicates to one. Consider documenting that the single-element case is intentional (the test already covers it, so this is just a readability note). - src/support/prompts-storage.js:663
  • nit: The V2Prompt.source schema in schemas.yaml shows default: "config" alongside readOnly: true - a default on a read-only field is technically a no-op in OpenAPI 3.0 (clients never send it, servers never need a default for output). Cosmetic only. - docs/openapi/schemas.yaml:6558

Previously flagged, now resolved

  • PostgREST 400 on inline SQL expression for source filter/sort - now uses raw column with dual-spelling .in() match
  • Mid-rename source root undefined flowing into ensureChildren - now guarded by requireServerOwnedRootId (502)
  • Inline fold drift (third copy of the canonical transform) - extracted to shared foldSourceValue
  • Non-string query param crash on foldSourceValue - String() coercion restored

Note on SITES-47870 conflict (already identified in PR discussion): the source body-strip in brands.js removes a write surface that the SR "Track" flow relies on. This is already called out in the PR comments and gated by the DO-NOT-MERGE banner + WP-O6 prerequisite. Resolution should be settled before the gate lifts.


Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 4m 47s | Cost: $2.46 | Commit: d5e7fae35b19ec3a7a08af238ae05f0cdb35aadb
If this code review was useful, please react with 👍. Otherwise, react with 👎.

@MysticatBot MysticatBot added ai-reviewed Reviewed by AI complexity:high AI-assessed PR complexity: HIGH needs-human-review AI reviewer recommends a human read before merge labels Jul 21, 2026
@aliciadriani

Copy link
Copy Markdown
Collaborator Author

/review-pr iteration 4 — rebased onto main (base flipped from claude/wp-o2b-derivation; WP-O2b #2866 is now in main).

Re-reviewed the full WP-S2 diff on the new base. My two prior fixes survived the rebase intact (foldSourceValue + raw-column .in('source', …) filter/sort; coercion guard). PR is MERGEABLE.

  • 946 tests passing on the rebased state · lint clean · coverage gate met previously.
  • The two rebase-replay conflicts (markets-subworkspace.js retryOnQuota+sourceId; the two tag-tree.test.js describe blocks) resolved the same combine-both way.
  • No remaining must/should/nits from my review.

Requesting MysticatBot now that the base is main (the original non-main-base blocker is gone).

@aliciadriani
aliciadriani requested a review from MysticatBot July 21, 2026 16:49

@aliciadriani aliciadriani left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Material re-review at d5e7fae3 (base now main, rebased after O2b merged, MERGEABLE, 15411 unit). The sort/filter strategy changed since my last pass (a9539eec) — from the inline lower(replace(source,'_','-')) expression to a raw source column + foldSourceValue app-side fold. Assessed (a)/(b)/(c); /review-pr is clean. One CROSS-PR finding (report-only; S4 held) below.

(a) foldSourceValue — no drift. CONFIRMED. foldSourceValue(value) = String(value).trim().toLowerCase().replace(/_/g, '-') — the single definition of the transform, and canonicalizeSource now DELEGATES to it (then applies its empty/:/length/root-name guards). So the tag-write canonicalizer, the v2 list source filter, and the sort all fold through one function — consistent with the JS twin's prior trim→lower→_→- and the CLI Python twin (.strip().lower().replace('_','-')). No divergence.

(b) raw-column + app-fold — correct within the interim's stated scope; two documented residuals.

  • FILTER: folds the incoming value (wanted = foldSourceValue(source)) and matches .in('source', [wanted, wanted.replace(/-/g,'_')]) on the raw column — so a filter for citation-attempt finds rows stored as either citation-attempt or citation_attempt. It keys on the FOLDED SLUG (variant-expanded), never the display label. Residual (documented): the raw match is case-sensitive, so a mixed-case stored value (Citation_Attempt) is missed — the comment defers case-folding to WP-S4's canonical column. Acceptable given prompts.source is machine-produced lowercase slugs; not a blocking bug.
  • SORT: SORT_COLUMN_MAP.source = 'source' orders by the RAW column, so the two spellings of one producer interleave rather than sort together — the comment calls this cosmetic and defers folded ordering to WP-S4. So the sort does NOT key on the folded slug; it's a documented interim tradeoff driven by stock PostgREST 400ing on an inline expression order-by.
    Both residuals are honest, commented, and WP-S4-deferred — no un-canonicalized boundary that returns wrong DATA, only imperfect ordering + a case-drift filter miss on unlikely inputs.

(c) core files + rebase conflicts — CONFIRMED, no drift. I hashed the four S2 core files (prompt-tags.js, tag-tree.js, prompts-storage.js, handlers/tags.js) at head vs the validated tip 4868c8f8: all four are BYTE-IDENTICAL (the commit-compare showing them differ was the three-dot rebase artifact, not reconstruction drift). Rebase conflict #1 (markets-subworkspace): the create is headroom.retryOnQuota(() => transport.createPromptsByIds(ws, pid, items, [...standardIds, sourceId, typeId]), {callSite:'createPromptsByIds'}) — the S4 sourceId (from ensureServerOwnedValue(..., GENERATED_PROMPT_SOURCE_VALUE)) is threaded into the tag_ids AND the write is wrapped in the LLMO-6190 retry; both concerns coexist correctly (this is S2, so createPromptsByIds is right — WP1 layers metadata separately). Rebase conflict #2 (tag-tree.test): both describe blocks are present (ensureServerOwnedValue / resolveClosedValueInjection / source root distinctness guard (mid-rename, WP-O6-gated) alongside the pre-existing resolveTypeValueInjection / findTagsInTree / …) — no clobber.


🚩 CROSS-PR CONSISTENCY FINDING (report-only — S4 #826 is held; do NOT fix here)

S2's current approach does NOT use S4's shipped index, and S2 anticipates a DIFFERENT S4 artifact than S4 actually delivered.

  • S2 (this PR, now canonical) queries the RAW source column: filter .in('source', [hyphen, underscore]), sort .order('source'). Neither references the expression lower(replace(source,'_','-')), so the Postgres planner will NOT use S4 #826's idx_prompts_source_canonical ON public.prompts (lower(replace(source,'_','-'))) — that expression index only serves a query whose predicate/order-by IS that expression, i.e. the ORIGINAL inline-SQL approach S2 abandoned (because stock PostgREST 400s on it). So relative to S2's current query shape, S4's index is an orphan.
  • The canonical FORM is consistent across the fleet: foldSourceValue (S2) = lower(replace(source,'_','-')) (S4 index) = canonicalize_source (CLI) all produce the same slug (S4's index omits the trim, but stored slugs are untrimmed, so no practical divergence). It's the MECHANISM that diverges, not the value.
  • The mismatch to reconcile: S2's code comments (prompts-storage.js:434/522/659) say "WP-S4 adds a source_canonical GENERATED COLUMN (+ its index)" and that ordering/matching will move to that plain column — but S4 #826 shipped an EXPRESSION INDEX, no generated column. These are different artifacts: a generated column is PostgREST-nameable (.eq('source_canonical', …) / .order('source_canonical'), which is exactly what S2's raw+fold interim is a placeholder for); an expression index is only usable by the inline-SQL query that stock PostgREST cannot express.
  • Recommendation for the operator (before the 47 track merges): the raw-column-now → source_canonical generated-column-later path is the coherent, PostgREST-consumable target S2 already assumes; S4 #826's expression index does not serve it. Reconcile by having the WP-S4 DB change ship the source_canonical GENERATED COLUMN S2 expects (replacing / in addition to the expression index), OR consciously choose the inline-SQL+index path and give S2 a PostgREST-expressible route (view/RPC) — but that fights the 400 constraint that drove S2's pivot. Both #826 and #2867 are held pre-merge, so this is reconcilable now. Flagging, not fixing (S4 is held).

Verdict: /review-pr clean on the S2 code (a/b/c all sound, no reconstruction drift). The only open item is the cross-PR strategy/artifact reconciliation above. MysticatBot is re-requested for its first main-base pass (coordinator-triggered).

* @param {unknown} value - a `source` value.
* @returns {string} the folded value.
*/
export function foldSourceValue(value) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

(a) CONFIRMED no drift: foldSourceValue = trim().toLowerCase().replace(/_/g,'-') is the SINGLE definition of the transform, and canonicalizeSource delegates to it. So the tag-write canonicalizer, the v2 list source filter, and the sort all fold identically — consistent with the CLI Python twin (.strip().lower().replace('_','-')) and the prior JS shape.

Comment thread src/support/prompts-storage.js Outdated
// as `citation_attempt`. WP-S4's `source_canonical` generated column replaces
// this with a single canonical-column match (and adds case-folding, which raw
// matching drops).
const wanted = foldSourceValue(source);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

(b) FILTER keys on the folded slug: wanted = foldSourceValue(source), matched via .in('source', [wanted, wanted.replace(/-/g,'_')]) — so citation-attempt finds citation_attempt rows too, on the raw column. Correct for the common _/- drift, and it's the folded slug not the display label. Documented residual: case-folding is dropped (raw match is case-sensitive), deferred to WP-S4's canonical column — acceptable for machine-slug data, not a blocking bug.

Comment thread src/support/prompts-storage.js Outdated
// index) and ordering moves to that column name, so the two drift spellings order
// together; until then raw ordering only interleaves the `_`/`-` spellings of the
// same producer, which is cosmetic (source-dimension.md §3.1).
source: 'source',

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

(b) SORT orders by the RAW source column, so a producer's two drift spellings interleave rather than sort together — documented as cosmetic, folded ordering deferred to WP-S4. This is where the raw-column interim deviates from an ideal folded sort; driven by stock PostgREST 400ing on an inline-expression order-by. Not wrong data, just imperfect ordering until the canonical column exists.

Comment thread src/support/prompts-storage.js Outdated
// FIX (WP-S2 interim): sort on the RAW `source` column. Stock PostgREST (the
// backend) cannot order by an inline SQL expression like
// `lower(replace(source,'_','-'))` — it returns 400 — and there is no canonical
// column to name yet. WP-S4 adds a `source_canonical` generated column (+ its

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🚩 CROSS-PR FINDING (report-only, S4 held): this comment anticipates a WP-S4 source_canonical GENERATED COLUMN, but S4 #826 actually shipped an EXPRESSION INDEX idx_prompts_source_canonical ON (lower(replace(source,'_','-'))). S2's raw-column .in()/.order() do NOT use that expression index (the planner only picks it for the inline-SQL query PostgREST can't express — the approach S2 abandoned). The canonical VALUE is consistent (foldSourceValue == the index expr == the CLI), but the ARTIFACT S2 expects (a PostgREST-nameable generated column) is not what S4 delivered. Operator should reconcile before the 47 merges — the generated-column path is the coherent target; S4's expression index is currently an orphan relative to S2's queries. Do not fix here.

// eslint-disable-next-line no-await-in-loop
await headroom.retryOnQuota(
() => transport.createPromptsByIds(workspaceId, projectId, items, [...standardIds, typeId]),
() => transport.createPromptsByIds(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

…6282)

MysticatBot approved d5e7fae with no must/should fixes; clearing its 3 nits:
- prompt-tags: document that ALL_DIMENSIONS is a membership set whose order
  intentionally differs from DIMENSION_ROOT_NAMES (provisioning order)
- prompts-storage: note the single-element sourceVariants dedup (`gsc`) is intentional
- schemas: drop `default: "config"` on the readOnly V2Prompt.source field
  (a default on a read-only output field is an OpenAPI 3.0 no-op)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@aliciadriani aliciadriani left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Cleared MysticatBot's 3 non-blocking nits (commit 26f88ec). The bot's review of the prior commit had no must/should fixes — these are the optional polish items. Inline notes on each fix below.

* this is grouped open-then-closed (`category, source, intent, origin, type`).
* Do not assume the two share an order.
*/
export const ALL_DIMENSIONS = Object.freeze([...OPEN_DIMENSIONS, ...CLOSED_DIMENSIONS]);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed (bot nit). Documented that ALL_DIMENSIONS is a membership set whose grouped open-then-closed order (category, source, intent, origin, type) intentionally differs from DIMENSION_ROOT_NAMES' provisioning order (category, intent, origin, type, source) — so no one assumes the two share an order. No behaviour change (used only for .includes).

Comment thread src/support/prompts-storage.js Outdated
const wanted = foldSourceValue(source);
// A value with no `_`/`-` (e.g. `gsc`) yields two identical variants that the
// Set collapses to one — a single-element `.in` is intentional, not a bug.
const sourceVariants = [...new Set([wanted, wanted.replace(/-/g, '_')])];

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed (bot suggestion). Added a comment noting the single-element case is intentional: a value with no _/- (e.g. gsc) yields two identical variants that the Set collapses to one, so a one-element .in is expected, not a bug.

Comment thread docs/openapi/schemas.yaml
no write surface — a create/update body cannot set it. Returned in its
CANONICAL form (`lower(replace(source, '_', '-'))`), so `agentic_traffic`
reads as `agentic-traffic`.
example: "config"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

…olumn (LLMO-6282) [WP-S2]

WP-S4 now ships `source_canonical` (GENERATED ALWAYS AS
lower(replace(source,'_','-')) STORED, btree-indexed), so PostgREST can name it
directly. Replace the raw-column + app-side variant-expansion interim:

- SORT_COLUMN_MAP.source -> 'source_canonical'; filter is a single
  .eq('source_canonical', foldSourceValue(source)) — the incoming query-param
  value is still folded (trim->lower->_->-) to align with the DB-canonicalized
  column, but no more .in([hyphen, underscore]) variant expansion.
- The generated column canonicalizes at write time, so matching is now
  case-insensitive and drift spellings sort together — the residuals the reviewer
  noted (case-sensitivity, interleaved ordering) are gone.
- Dropped the "interim / WP-S4 adds it later" comments (it is real now) across
  prompts-storage.js and the prompts-v2 OpenAPI filter/sort docs.

canonicalizeSource (tag-write path) and foldSourceValue (shared transform) are
unchanged; the rest of WP-S2 is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@aliciadriani

Copy link
Copy Markdown
Collaborator Author

/review-pr — final pass (base main, 26f88ec5)

Summary. Server-owned source dimension + canonicalizeSource (20 files, +986/−192). Rebased onto main (WP-O2b #2866 now in main), MERGEABLE. Full local suite 15411 passing (coverage gate met), lint clean. Every finding from all review rounds — mine and MysticatBot's — is addressed. MysticatBot APPROVED the prior commit with zero must/should.

Must Fix

None. The historical must-fix (source filter/sort via an inline SQL expression stock PostgREST 400s on) is resolved: raw-column .in() dual-spelling match + raw-column sort (509227a6), verified against the real backend and confirmed by the bot. source_canonical generated column is the WP-S4 follow-up.

Should Fix

One open, already tracked & gated (no code change here): the source body-strip in brands.js removes a write surface the Semrush "Track" flow relies on (SITES-47870). Deliberate design; PR is DO-NOT-MERGE-gated with a WP-O6 prerequisite, so this must be settled before the gate lifts.

Nits

None outstanding. MysticatBot's 3 nits (ALL_DIMENSIONS ordering doc, single-element sourceVariants doc, default on a read-only schema field) all fixed in 26f88ec5.

What's Good

Clean open-vs-server-owned dimension split with a single shared foldSourceValue; fail-loud requireServerOwnedRootId (502) guard; read-side raw-value fallback so the grid still shows unparseable values; thorough spec-referenced comments + edge-case tests; conflicts resolved by combining both sides.

CI note: it-postgres is red pending WP-S1 (per the bot); resolve before merge.

Alicia Adriani and others added 2 commits July 21, 2026 19:42
…e WP-S2 raw-column behavior (LLMO-6282)

The OpenAPI descriptions drifted during the rebase to claim the `source` filter
matches "on the canonical form ... case-insensitive" and sort orders drift
spellings "together" — but the WP-S2 code does a raw-column dual-spelling `.in()`
(sort on the raw column interleaves the `_`/`-` spellings; stored-side case is not
folded). Correct both to describe the raw-column interim and point the full
canonical + case fold at WP-S4's `source_canonical` column.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…match the WP-S2 raw-column behavior (LLMO-6282)"

This reverts commit 3187325.
@aliciadriani

Copy link
Copy Markdown
Collaborator Author

Correction to my earlier review summary (my prior comment predated 23d11928).

The source filter/sort is no longer the raw-column interim I described — commit 23d11928 ships the real source_canonical generated column (GENERATED ALWAYS AS lower(replace(source,'_','-')) STORED, btree-indexed), so:

  • filter is a single .eq('source_canonical', foldSourceValue(source)),
  • sort is on source_canonical,
  • matching is genuinely case-insensitive and drift spellings sort together — the residual caveats I'd noted (case-sensitivity, interleaved ordering) are gone.

The OpenAPI filter/sort descriptions already matched this; a doc commit I pushed briefly reverted them to the old wording by mistake — reverted in 88945877, so docs and code agree again. Net: the WP-S4 follow-up I'd flagged is done here, not deferred.

@aliciadriani aliciadriani self-assigned this Jul 21, 2026
@jjenscodee

Copy link
Copy Markdown
Contributor

Picking up the SR "Track → Semrush" wiring (the layer that sits on top of the origin/source tracks), so flagging one more path for the same source-contract reconciliation — additive to the createPromptsByBrand note above, not a duplicate.

The Serenity create path loses source the same way, and it isn't covered by the brandalf finding.

handleCreatePrompts (prompts-subworkspace.js) injects PROXY_CREATE_SOURCE_VALUE = 'config' on every create — deliberately, per source-dimension.md §1 ("this human create dialog"). But the v2 Strategic Recommendations "Track" flow, in Serenity mode, writes through exactly this path — and those prompts genuinely originate from semrush / gsc / citation_attempt / synthetic_personas cards. So Serenity-mode tracked prompts collapse to source = config and lose their producing surface — the same class of loss flagged for the brandalf /prompts path, but on /serenity/prompts, which has no prompts row to derive from.

So whichever way the /prompts strip vs SITES-47870 gets reconciled, the Serenity create path needs the matching treatment — otherwise Serenity-mode orgs lose the surface that (post-fix) brandalf orgs keep, and the usage report (SITES-47870) shows config for them too.

Not necessarily something to solve in this PR — just making sure the Serenity path is in scope when you + @dzehnder settle the prompts.source contract. Happy to sync.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-reviewed Reviewed by AI complexity:high AI-assessed PR complexity: HIGH needs-human-review AI reviewer recommends a human read before merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants