Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/openapi/schemas.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11017,6 +11017,34 @@ SerenityPrompt:
Present on a create/update response
(`SerenityCreatePromptsResponse.created`, the `updateSerenityPrompt`
200); absent on `GET /serenity/prompts` list items, which carry `tags`.
createdAt:
type: [string, 'null']
format: date-time
description: |
RFC 3339 UTC timestamp the prompt was first created, from the
server-owned authorship metadata. Present on `GET /serenity/prompts`
list items; null for a prompt created before authorship stamping (an
un-backfilled prompt).
createdBy:
type: [string, 'null']
maxLength: 100
description: |
Opaque id of the caller that created the prompt (an IMS user id, or the
literal `unknown` when the author could not be resolved). Resolve to a
display name via the userDetails lookup. Null for an un-backfilled prompt.
updatedAt:
type: [string, 'null']
format: date-time
description: |
RFC 3339 UTC timestamp of the prompt's last in-place edit (its text or
tags). Equal to `createdAt` for a never-edited prompt. Null for an
un-backfilled prompt.
updatedBy:
type: [string, 'null']
maxLength: 100
description: |
Opaque id of the caller that last edited the prompt (or the literal
`unknown`). Null for an un-backfilled prompt.

SerenityPromptTag:
type: object
Expand Down
21 changes: 21 additions & 0 deletions docs/openapi/serenity-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,27 @@ v2-serenity-prompts:
Maximum 50 ids; excess values are silently truncated (same pattern
as the `limit` cap). This matches the server-side `MAX_TAG_IDS`
constant in `handlers/prompts.js`.
- name: sort
in: query
required: false
schema:
type: string
enum: [metadata.created_at, metadata.updated_at]
description: |
Sort the page by a server-owned authorship timestamp. Restricted to
`metadata.created_at` (Created) or `metadata.updated_at` (Last
modified) — any other value is a 400. Omitted leaves the upstream
default (unsorted) ordering. Requires the with-metadata list path.
- name: order
in: query
required: false
schema:
type: string
enum: [asc, desc]
default: desc
description: |
Sort direction for `sort`; defaults to `desc` (newest first). Ignored
when `sort` is omitted.
responses:
'200':
description: Prompts retrieved successfully.
Expand Down
20 changes: 20 additions & 0 deletions src/controllers/serenity.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
handleCreatePrompts,
handleUpdatePrompt,
handleBulkDeletePrompts,
resolveCallerId,
} from '../support/serenity/handlers/prompts.js';
import {
handleListMarkets,
Expand Down Expand Up @@ -521,13 +522,18 @@ function SerenityController(context, log, env) {
}
const transport = buildTransport(ctx, imsToken);
const classifyPromptType = await buildPromptTypeClassifier(ctx, auth.brandUuid);
// CORRECTNESS-CRITICAL (LLMO-6289): resolve the caller identity ONCE, from
// the request's auth profile — NEVER from the bearer forwarded upstream —
// and thread it into every write below to stamp created_*/updated_*.
const callerId = resolveCallerId(ctx);
const result = auth.mode === 'subworkspace'
? await handleCreatePromptsSubworkspace(
transport,
auth.workspaceId,
ctx.data || {},
log,
classifyPromptType,
callerId,
{
dynamicAllocation: dynamicAllocationEnabled(ctx),
parentWorkspaceId: auth.parentWorkspaceId ?? '',
Expand All @@ -541,6 +547,7 @@ function SerenityController(context, log, env) {
ctx.data || {},
log,
classifyPromptType,
callerId,
);
return createResponse(result, 200);
} catch (e) {
Expand All @@ -561,6 +568,9 @@ function SerenityController(context, log, env) {
}
const transport = buildTransport(ctx, imsToken);
const classifyPromptType = await buildPromptTypeClassifier(ctx, auth.brandUuid);
// Caller identity for the updated_* stamp — resolved from the auth profile,
// never the forwarded upstream bearer (LLMO-6289).
const callerId = resolveCallerId(ctx);
const result = auth.mode === 'subworkspace'
? await handleUpdatePromptSubworkspace(
transport,
Expand All @@ -569,6 +579,7 @@ function SerenityController(context, log, env) {
ctx.data || {},
log,
classifyPromptType,
callerId,
)
: await handleUpdatePrompt(
transport,
Expand All @@ -579,6 +590,7 @@ function SerenityController(context, log, env) {
ctx.data || {},
log,
classifyPromptType,
callerId,
);
return createResponse(result.body, result.status);
} catch (e) {
Expand Down Expand Up @@ -723,6 +735,9 @@ function SerenityController(context, log, env) {
// Dynamic-allocation kill-switch. The JIT top-up units pool is the org parent passed
// positionally above (auth.parentWorkspaceId) — not duplicated in this options bag.
dynamicAllocation: dynamicAllocationEnabled(ctx),
// Caller identity for the created_* stamp on any generated prompt
// (LLMO-6289) — from the auth profile, never the upstream bearer.
callerId: resolveCallerId(ctx),
},
);
// Mirror this market as a SpaceCat Site (+ brand_sites link) keyed on the
Expand Down Expand Up @@ -1227,6 +1242,10 @@ function SerenityController(context, log, env) {
dynamicAllocation: dynamicAllocationEnabled(ctx),
},
);
// Caller identity for the created_* stamp on generated prompts, resolved
// ONCE for the whole activate batch (LLMO-6289) — from the auth profile,
// never the forwarded upstream bearer.
const callerId = resolveCallerId(ctx);

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.

resolveCallerId(ctx) resolved ONCE for the whole activate batch here and threaded into the per-market loop — correctly NOT re-resolved per market. Same once-per-request discipline as the POST/PATCH handlers. Good.

const results = [];
for (const m of markets) {
const createBody = {
Expand Down Expand Up @@ -1276,6 +1295,7 @@ function SerenityController(context, log, env) {
dataAccess: { BrandSemrushProject: ctx.dataAccess.BrandSemrushProject },
// JIT units pool = the org parent passed positionally above; not duplicated here.
dynamicAllocation: dynamicAllocationEnabled(ctx),
callerId,
},
);
} catch (e) {
Expand Down
6 changes: 6 additions & 0 deletions src/support/serenity/brand-provisioning.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { isSemrushTransportError } from './errors.js';
import { resolveWorkspaceId } from './workspace-resolver.js';
import { deleteAllProjects, releaseFullAllocation } from './workspace-lifecycle.js';
import { handleCreateMarketSubworkspace } from './handlers/markets-subworkspace.js';
import { resolveCallerId } from './handlers/prompts.js';

// Re-exported for callers/tests that drive brand provisioning. The tag
// vocabularies themselves live in `prompt-tags.js` (single source of truth).
Expand Down Expand Up @@ -217,6 +218,11 @@ export async function provisionBrandSubworkspace(context, {
publishMode: (Array.isArray(modelIds) && modelIds.length > 0) || generateTopics
? 'require'
: 'best-effort',
// Caller identity for the created_* stamp on generated prompts (LLMO-6289),
// resolved from the request auth profile — never the forwarded upstream
// bearer. This create runs before the brand row exists; the caller
// (brands.js POST /brands) is the human/service provisioning the brand.
callerId: resolveCallerId(context),
},
);
} catch (e) {
Expand Down
32 changes: 25 additions & 7 deletions src/support/serenity/handlers/markets-subworkspace.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
ERROR_CODES, isUpstreamGone, isMeteredQuota, toQuotaExceededError,
} from '../errors.js';
import { normalizeGeoTargetId, normalizeLanguageCode } from '../validation.js';
import { buildCreateMetadata } from './prompts.js';
import {
resolveLocation,
resolveLanguageId,
Expand Down Expand Up @@ -190,7 +191,7 @@ function validateCreateBody(body) {
* there is no correct parent to create them below. Generated prompts therefore
* arrive uncategorized and are categorized later (adobe/serenity-docs#44).
*
* Writes are id-based: `createPromptsByIds` takes ONE shared `tag_ids` array per
* Writes are id-based: `createPromptsWithMetadata` takes ONE shared `tag_ids` array per
* call, so the texts are partitioned by their resolved tag-id set — which, with
* topics gone, is exactly two groups (branded and non-branded). Identical text
* collapses to one entry per group.
Expand All @@ -214,12 +215,14 @@ function validateCreateBody(body) {
* REQUIRED, not optional: a genuine no-op object when the flag is OFF, never `undefined`. Not
* optional-chained at the call site below (Rainer review) — a caller that forgets to thread it
* must fail loud, not silently skip metering. PROMPT metering seam (Rainer, live-verified
* LLMO-6190): the metered write is `createPromptsByIds` below, NOT publish — front it BEFORE the
* write loop, sized on the real prompt count now that it's known (`texts.size`), not an estimate.
* LLMO-6190): the metered write is `createPromptsWithMetadata` below, NOT publish — front it
* BEFORE the write loop, sized on the real prompt count now known (`texts.size`), not estimated.
* @param {string} callerId - resolved caller id (see `resolveCallerId`) stamped as
* `created_by`/`updated_by` on every generated prompt (LLMO-6289).
*/
async function generateAndAttachPrompts(transport, workspaceId, projectId, {
domain, country, topicCap = 0, brandNames = [], provisioned,
}, log, headroom) {
}, log, headroom, callerId) {
const raw = await transport.getBrandTopics(workspaceId, { domain, country });
let topics = [];
if (Array.isArray(raw)) {
Expand Down Expand Up @@ -255,7 +258,7 @@ async function generateAndAttachPrompts(transport, workspaceId, projectId, {
return { topicCount: 0, promptCount: 0 };
}

// Resolve every tag id we are about to attach. `createPromptsByIds` is ATOMIC on
// Resolve every tag id we are about to attach. `createPromptsWithMetadata` is ATOMIC on
// an unresolvable id (live 500s and creates nothing), so ids are never guessed.
// `provisionDimensionTree` resolved every closed value or threw a 502, so the
// standard values and the whole `type` vocabulary are present here by construction.
Expand All @@ -279,6 +282,10 @@ async function generateAndAttachPrompts(transport, workspaceId, projectId, {

await headroom.ensure({ prompts: texts.size }, { includeDrafted: true });

// STAMP (LLMO-6289): AI-generated prompts are created through the v3
// metadata-carrying write, `created_* = updated_* = now / callerId`. One
// metadata object per batch (same instant for every text in the group).
const metadata = buildCreateMetadata(callerId);
for (const [value, items] of byTypeValue) {
// `branded` / `non-branded` are the classifier's only outputs and both are in
// the `type` vocabulary provisioned above.
Expand All @@ -288,8 +295,13 @@ async function generateAndAttachPrompts(transport, workspaceId, projectId, {
// top-up) — route through `headroom.retryOnQuota` (no-op passthrough when the flag is OFF).
// eslint-disable-next-line no-await-in-loop
await headroom.retryOnQuota(
() => transport.createPromptsByIds(workspaceId, projectId, items, [...standardIds, typeId]),
{ callSite: 'createPromptsByIds' },
() => transport.createPromptsWithMetadata(

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.

Rebase-conflict resolution confirmed sound: WP1's v3 metadata create is nested inside the upstream headroom.retryOnQuota(() => transport.createPromptsWithMetadata(...), {callSite:'createPromptsWithMetadata'}) (the LLMO-6190 drift), callerId threaded, buildCreateMetadata called once per batch. The retry thunk wraps the v3 write correctly — no WP1 substance lost.

workspaceId,
projectId,
items.map((name) => ({ name, metadata })),
[...standardIds, typeId],
),
{ callSite: 'createPromptsWithMetadata' },
);
}
return { topicCount: selected.length, promptCount: texts.size };
Expand Down Expand Up @@ -357,6 +369,10 @@ async function generateAndAttachPrompts(transport, workspaceId, projectId, {
* `ensureSubworkspace` is skipped. When false, byte-for-byte the pre-this-PR behavior.
* (The units pool for JIT sizing is the positional `parentWorkspaceId` arg — the same id given
* to `ensureSubworkspace` — so it is not duplicated in this options bag.)
* @param {string} [options.callerId='unknown'] - resolved caller id (see
* `resolveCallerId`) stamped as `created_by`/`updated_by` on any AI-generated
* prompt this create attaches (LLMO-6289). Defaults to the `unknown` sentinel
* so a caller that omits it never writes an empty author.
*/
export async function handleCreateMarketSubworkspace(
transport,
Expand All @@ -376,6 +392,7 @@ export async function handleCreateMarketSubworkspace(
publishMode = 'require',
dataAccess = null,
dynamicAllocation = false,
callerId = 'unknown',
} = {},
) {
const errors = validateCreateBody(body);
Expand Down Expand Up @@ -508,6 +525,7 @@ export async function handleCreateMarketSubworkspace(
},
log,
headroom,
callerId,
);
}

Expand Down
33 changes: 25 additions & 8 deletions src/support/serenity/handlers/prompts-subworkspace.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
parseUpdatePromptBody,
mapLimit,
publishAffected,
resolveSort,
buildUpdateMetadata,
DEFAULT_PAGE_LIMIT,
MAX_PAGE_LIMIT,
MAX_TAG_IDS,
Expand Down Expand Up @@ -79,6 +81,9 @@ export async function handleListPromptsSubworkspace(transport, workspaceId, quer
const tagIds = Array.isArray(query?.tagIds)
? query.tagIds.slice(0, MAX_TAG_IDS).map(String).filter(Boolean)
: [];
// sort/order (LLMO-6289): validated against the metadata allow-list and
// forwarded upstream — kept in lockstep with the flat-mode twin.
const { sort, order } = resolveSort(query);

const project = await resolveProject(transport, workspaceId, geoTargetId, languageCode, log);
if (!project) {
Expand All @@ -97,6 +102,8 @@ export async function handleListPromptsSubworkspace(transport, workspaceId, quer
page,
limit,
search,
sort,
order,
});
const items = Array.isArray(resp?.items) ? resp.items : [];
let total;
Expand Down Expand Up @@ -126,6 +133,7 @@ export async function handleCreatePromptsSubworkspace(
body,
log,
classifyPromptType,
callerId,
{ dynamicAllocation = false, parentWorkspaceId = '' } = {},
) {
const inputs = Array.isArray(body?.prompts) ? body.prompts : [];
Expand All @@ -143,7 +151,7 @@ export async function handleCreatePromptsSubworkspace(
const injectComputedType = makeTypeInjector(transport, workspaceId, classifyPromptType, log);

// PROMPT metering seam (Rainer, live-verified LLMO-6190): the metered write is
// `createPromptsByIds` (inside `createOnePrompt` below), NOT publish — a disguised-quota 405
// `createPromptsWithMetadata` (inside `createOnePrompt` below), NOT publish — a disguised 405
// fires there, before any publish, if `used + drafted + batch > total`. Front headroom BEFORE
// this loop, sized on the whole incoming batch (`inputs.length` — a safe upper bound; some
// inputs may still skip on validation/missing-project, so this can over-provision slightly, never
Expand Down Expand Up @@ -184,7 +192,7 @@ export async function handleCreatePromptsSubworkspace(
// no-op passthrough when the flag is OFF) so each item recovers independently; `mapLimit`'s
// own per-item try/catch below still isolates a surviving failure to this one item.
const semrushPromptId = await headroom.retryOnQuota(
() => createOnePrompt(transport, workspaceId, projectId, typed),
() => createOnePrompt(transport, workspaceId, projectId, typed, callerId),

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 subworkspace twin: createOnePrompt(transport, ws, pid, typed, callerId) sits inside headroom.retryOnQuota(() => ..., {callSite:'createOnePrompt'}) — callerId threaded, retry wrapper intact. Matches the flat-mode twin. Rebase merge is clean.

{ callSite: 'createOnePrompt' },
);
return {
Expand Down Expand Up @@ -254,10 +262,12 @@ export async function handleCreatePromptsSubworkspace(
* PATCH /serenity/prompts/:semrushPromptId (subworkspace) — in-place edit.
* Resolves the slice's project from the live listing, then edits the prompt IN
* PLACE exactly like the flat-mode twin (see handleUpdatePrompt's contract):
* `rename` first (the one op that can refuse — upstream 404 → promptNotFound,
* 409 text collision → thrown for the controller's `conflict` mapping), then
* the replace-mode batch tag write. The prompt id is preserved end to end and
* echoed unchanged in the response; nothing is deleted on this path.
* the combined v3 `PATCH .../{id}` first (text `name` + `updated_*` metadata
* stamp in one request — the one op that can refuse: upstream 404 →
* promptNotFound, 409 text collision → thrown for the controller's `conflict`
* mapping), then the replace-mode batch tag write (v2, metadata-free). The
* prompt id is preserved end to end and echoed unchanged in the response;
* nothing is deleted on this path.
*/
export async function handleUpdatePromptSubworkspace(
transport,
Expand All @@ -266,6 +276,7 @@ export async function handleUpdatePromptSubworkspace(
body,
log,
classifyPromptType,
callerId,
) {
const parsedBody = parseUpdatePromptBody(body);
if (!parsedBody.ok) {
Expand Down Expand Up @@ -305,7 +316,13 @@ export async function handleUpdatePromptSubworkspace(
});

try {
await transport.renamePrompt(workspaceId, projectId, semrushPromptId, nextText);
// Combined v3 write: text (`name`) + `updated_*` stamp in one request
// (replaces the v2 `rename`). Same refusal contract — 404 → promptNotFound,
// 409 (sibling text collision) → thrown for the controller's `conflict`.
await transport.patchPrompt(workspaceId, projectId, semrushPromptId, {
name: nextText,
metadata: buildUpdateMetadata(callerId),
});
} catch (e) {
if (isUpstreamGone(e)) {
return {
Expand Down Expand Up @@ -334,7 +351,7 @@ export async function handleUpdatePromptSubworkspace(
// The rename above already landed: the prompt's text has moved while its
// tags are stale. Record the partial mutation before propagating, so the
// generic upstream error the caller sees is attributable on-call.
log?.warn?.('updatePromptTagsByIds failed after a successful rename — text updated, tags stale', {
log?.warn?.('updatePromptTagsByIds failed after a successful text/metadata PATCH — text updated, tags stale', {
semrushPromptId, projectId, error: e.message,
});
throw e;
Expand Down
Loading
Loading