Skip to content
Merged
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
75 changes: 75 additions & 0 deletions docs/decisions/006-serenity-v1-v2-read-drift.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# ADR-006: Serenity (Semrush) v1/v2 read drift — which layer each read sees

## Context

The Serenity transport (`src/support/serenity/rest-transport.js`) talks to the
Semrush Project Engine through two API versions, `/v1` and `/v2`, and the choice
is **not** "prefer v2 everywhere". The two versions read **different layers of a
project's state**, and getting the version wrong silently misreads an unpublished
draft as empty or as the wrong geo. This was surfaced while reviewing the
all-PAID Semrush migration readiness (serenity-docs#18) and is tracked as one of
the implementation adjustments in
https://github.com/adobe/spacecat-api-service/issues/2687 .

A Semrush AIO project has a **draft layer** and a **live (published) layer**.
Mutations (settings, AI-model assignments, prompts) stage in the draft layer and
only reach the live layer on `publishProject`. The two API versions expose these
layers differently:

- **Project SETTINGS** (name, brand_names, language, country, location, model
set): the **v1 default view is draft-faithful**. The v2 list (and v1 with
`live=true`) returns a **live-view materialisation** — for a never-published
draft it defaults the location to US/2840 and nulls `brand_names`, giving the
wrong slice identity. Verified live 2026-06-11 against the prod tenant.
- **Prompt layer** (counts, lists, tags): reads **only the live layer**, and
there is **no v1 variant at all** — `aio/prompts/by_tags` exists only on
`/v2`. A populated-but-unpublished draft reads **empty**. This is the source of
every "201-but-count-0" symptom.
- **Init status** (`aio/init_status`, an AIO-readiness boolean): moved from `/v1`
to `/v2` in `@adobe/spacecat-shared-project-engine-client` **1.2.0** (the v1
route was removed from the generated contract). It is a readiness flag, not
draft settings, so the v2 live-view carries no draft-faithfulness concern.

## Decision

Each Serenity read picks its API version by **which layer it must observe**, not
by a blanket "use the newest version" rule:

| Read | Version | Layer | Why |
|------|---------|-------|-----|
| `listProjects` (`GET /projects?type=ai`) | **v1** | draft | draft-faithful settings; v2 list returns live-view with wrong location/null brand_names |
| `getProject` (`GET /projects/{id}?draft=true&type=ai`) | **v1** | draft | single-project draft settings; `draft` query is required upstream |
| `listPromptsByTags` (`POST /aio/prompts/by_tags`) | **v2** | live | no v1 variant exists; prompt layer is live-only |
| `getInitStatus` (`GET /aio/init_status`) | **v2** | n/a | readiness boolean; v1 route removed in client 1.2.0 |
| writes that need to go live (`addAiModel`, `createTaggedPrompts`, …) | per-op¹ | draft → publish | mutations stage in draft; `publishProject` commits them |

¹ Writes are out of this ADR's scope (it is about which layer each *read*
observes). For a write, the API **version does not affect draft-faithfulness** —
every mutation stages in the draft layer regardless of v1/v2 and only reaches
live on `publishProject`, so picking the version is a pure contract question, not
a layer question. The authoritative per-method v1/v2 map for writes lives in the
transport method JSDoc (`src/support/serenity/rest-transport.js`) and the
serenity transport-endpoint audit (rule: prefer v2 where a compatible v2 variant
exists). A developer adding a write picks the version from that map; this ADR
governs only the read side, where the version determines the layer.

Consequences for **migration-verification / UI-enrichment reads**:

- Treat a v2 prompt/tag read of **0** on an **unpublished** project as
"not yet published", **never** as "migration didn't land". Publish first (or
read after the known publish), then re-read.
- Never read draft project **settings** through the v2 project list — its
location/brand_names are a live-view default, not the draft's real values.
- Model-set edits via `PUT /serenity/models` republish by default
(`syncModelsForProject({ publish: true })`) precisely so the new set reaches
the live layer that subsequent reads observe; the only `publish: false` caller
is brand-create, which batches one final publish itself.

## Status

Accepted. The transport already reads settings via v1 (`listProjects`,
`getProject`) and prompts via v2 (`listPromptsByTags`); this ADR records the
**reason** so the choice is not "corrected" into a uniform v2 swap. The
per-method v1/v2 audit map lives alongside the transport methods' JSDoc. Future
prompt-layer reads inherit the live-only constraint until/unless Semrush ships a
draft-faithful prompt read.
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ Add thin wrappers (all under `API_PREFIX`, all via the existing `request()` help
| `deleteWorkspace(wsId)` | DELETE `…/v1/workspaces/{id}` | **test cleanup only** — guard comment; production flows never call it |
| `listProjects(wsId)` | GET `…/v1/workspaces/{id}/projects` | v1 **default view** (draft‑faithful) |
| `getProject(wsId, projectId)` | GET `…/v1/workspaces/{id}/projects/{id}` | v1 by‑id read |
| `getInitStatus(wsId, projectId)` | GET `…/v1/workspaces/{id}/projects/{id}/aio/init_status` | detail‑read enrichment only |
| `getInitStatus(wsId, projectId)` | GET `…/v2/workspaces/{id}/projects/{id}/aio/init_status` | detail‑read enrichment only (moved v1→v2 in project-engine-client 1.2.0; v1 route removed — see ADR-006) |

Extend **`errors.js`** with normative classification (design §6) and new `ERROR_CODES`:
- `405` + `text/html` body on publish → **permanent allocation failure** (`allocationFailure`) — never retried as transient.
Expand Down
45 changes: 13 additions & 32 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@
"@adobe/spacecat-shared-http-utils": "1.31.0",
"@adobe/spacecat-shared-ims-client": "1.12.7",
"@adobe/spacecat-shared-launchdarkly-client": "1.3.0",
"@adobe/spacecat-shared-project-engine-client": "1.1.1",
"@adobe/spacecat-shared-project-engine-client": "1.2.0",
"@adobe/spacecat-shared-rum-api-client": "2.40.13",
"@adobe/spacecat-shared-scrape-client": "2.6.3",
"@adobe/spacecat-shared-slack-client": "1.6.7",
Expand Down
6 changes: 6 additions & 0 deletions src/support/serenity/handlers/markets.js
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,12 @@ function evictTagCacheIfNeeded() {
* how the slice resolves to a `projectId` (DB row vs live listing); the cache,
* pagination, truncation guard, and sort are identical, so they live here once.
* `logCtx` is spread into the truncation warning for diagnosability.
*
* LIVE-LAYER READ: tags are derived by paginating `transport.listPromptsByTags`,
* which reads the LIVE (published) prompt layer (no v1 draft variant exists — see
* docs/decisions/006-serenity-v1-v2-read-drift.md). A slice whose prompts are
* staged in an unpublished draft therefore yields an EMPTY tag set here until the
* project is published — that is correct, not a missing-data bug.
*/
export async function listTagsForProject(transport, semrushWorkspaceId, projectId, logCtx, log) {
const cacheKey = tagCacheKey(semrushWorkspaceId, projectId);
Expand Down
Loading
Loading