Skip to content

feat(serenity): sub-workspace dual-mode provisioning (brand-create, activate/deactivate, subworkspace reads/writes)#2610

Merged
rainer-friederich merged 45 commits into
mainfrom
feat/serenity-subworkspace-dual-mode
Jun 18, 2026
Merged

feat(serenity): sub-workspace dual-mode provisioning (brand-create, activate/deactivate, subworkspace reads/writes)#2610
rainer-friederich merged 45 commits into
mainfrom
feat/serenity-subworkspace-dual-mode

Conversation

@rainer-friederich

@rainer-friederich rainer-friederich commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

What

Implements Phase 1 (synchronous dual-mode) of Serenity Semrush sub-workspace provisioning per adobe/serenity-docs PR 12. Every /serenity/* operation resolves, in one place, whether a brand runs in flat mode (shared org parent workspace) or subworkspace mode (its own Semrush sub-workspace), driven entirely by brands.semrush_workspace_id. The flat path is unchanged; the subworkspace path is added alongside. This PR also adds serenity-first brand creation (provision the sub-workspace before the brand row) and propagates a brand's full identity (URLs, competitors, aliases) onto its Semrush projects.

NULL brands.semrush_workspace_id means the brand is not connected to a Semrush sub-workspace - nothing more. It does not imply anything about flat mode or whether the brand uses Semrush at all.

How it works

resolveBrandWorkspace(ctx, spaceCatId, brandId) returns { mode, workspaceId }:

  • semrush_workspace_id set -> { mode: 'subworkspace', workspaceId: <brand sub-workspace> }
  • absent -> { mode: 'flat', workspaceId: <org parent ws> }

The controller dispatches on mode. The only thing that differs between modes is how a (geoTargetId, languageCode) slice resolves to an upstream project - flat reads the BrandSemrushProject mapping; subworkspace resolves it live from the brand's own workspace via listProjects. Everything downstream (the Semrush prompt/tag/model calls, publish-once, per-project tag-cache invalidation, bulk caps) is shared, project-keyed logic. Auth is identical in both modes: an IMS bearer is required and forwarded to the Semrush gateway; only the workspace id in the upstream URL differs.

Surface

  • Transport: workspace/project methods (sub-workspace create + settle/poll, family listing, resources/transfer, project list ?type=ai, init-status, benchmark create/delete) + upstream error classification (allocation-failure, workspace-not-ready, workspace-drift). Sub-workspace deletion is fail-closed behind SERENITY_ALLOW_WORKSPACE_DELETE (never set in any deployed env), so it is test-cleanup only.
  • Lifecycle: ensureSubworkspace (lazy-create -> poll created -> persist column; re-grant path; ambiguous-create adoption by family title) and decommissionBrandWorkspace (delete every project + release the allocation back to the parent pool - never deletes the workspace).
  • Subworkspace handlers: markets (list/get/create/delete) + prompts/tags/models, reusing the flat cores via additive exports + behaviour-preserving extraction (flat handlers delegate to project-keyed cores; their tests are unchanged and green).
  • Brand-create (serenity-first): when POST /v2/orgs/:id/brands carries a semrushMarket, provision the sub-workspace + one published AIO project before writing the brand row, attach the chosen models, generate+attach topics/prompts, and publish. On a post-provision DB failure the orphaned allocation is released back to the parent pool (best-effort) and logged, not leaked.
  • Brand identity propagation: the brand's own URLs (sites + social + earned) are pushed onto the project's own-brand benchmark (created on demand); the brand's competitors are tracked as region-filtered project benchmarks (AIO projects have no settings.ci, so the legacy CI-competitor list was a no-op); brand aliases drive branded/non-branded prompt classification. Re-synced across markets on brand edit.
  • New endpoints: POST /serenity/activate (ensure the sub-workspace once -> publish caller-supplied markets -> set brand active) and POST /serenity/deactivate (decommission -> clear the semrush_workspace_id pointer to disconnect the brand back to flat mode -> set brand pending). A future activate allocates a fresh sub-workspace; the emptied one is never deleted.
  • Catalogue endpoint: GET /v2/orgs/:id/serenity/languages returns the Semrush-supported language catalogue so the UI only offers languages the AIO gateway accepts (sibling to the existing serenity/models). Both org-level catalogue routes are mapped organization:read.
  • Republish fix: PUT /serenity/models publishes after a model-set change so it goes live (latent bug; fixed in the shared core for both modes).
  • OpenAPI: documents activate/deactivate, the previously-undocumented PUT /serenity/models, and additive subworkspace-mode market fields (status, semrushProjectId, initialized) - all optional, so flat responses still validate.

Hardening (multi-agent review pass)

  • Register both org-level catalogue routes in routeRequiredCapabilities (route-coverage gate).
  • Brand-create releases the provisioned sub-workspace allocation + logs the orphan id when the row write fails after provisioning.
  • Brand-create enforces the IMS-only contract (getType() === 'ims') before forwarding the bearer upstream, matching requireImsBearer on the rest of /serenity/*.
  • Redact the gateway URL (internal host + workspace UUIDs) from upstream 401/403 responses.
  • Drop an unused aliased data-access dependency.

Validation

  • Serenity unit + controller-dispatch + OpenAPI-contract + route-coverage tests green; docs:lint valid; docs:build OK; lint clean.
  • Live through-api e2e against the real dev Semrush gateway: flat markets -> activate (real sub-workspace created + persisted + market published) -> subworkspace markets/detail/models/prompts/tags -> deactivate (projects deleted, allocation released, pointer cleared). Net-zero cleanup verified. Brand-create provisioning (sub-workspace + URLs + competitor benchmarks + own-brand benchmark) verified live.

Cross-repo ordering

The runtime dataAccess.Brand entity and the brands.semrush_workspace_id column are merged and released:

UI companion: adobe/project-elmo-ui 2078.

Known follow-ups (not blocking)

  • First-time multi-market activate carves a flat CREATE_ALLOCATION (1 project); sizing it from marketCount is pending live Gate-A verification of the limits contract.
  • Brand-edit Semrush re-sync runs serially across markets inside the synchronous PATCH; bound the fan-out (or add per-market failure observability) before high-market-count edits.

🤖 Generated with Claude Code

rainer-friederich and others added 14 commits June 15, 2026 12:39
Codebase-grounded Phase-1 plan implementing serenity-docs PR 12 on the current
main baseline, with full local-dev setup (real Semrush dev gateway).

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

rest-transport.js gains 9 thin workspace/project wrappers (createChildWorkspace,
getWorkspaceStatus, listWorkspaceFamily, transferWorkspaceResources,
removeWorkspaceMember, deleteWorkspace [test-cleanup only], listProjects,
getProject, getInitStatus) for the child path. errors.js gains the normative
classification predicates (isAllocationFailure = 405+HTML permanent quota
failure; isWorkspaceNotReady = 500 transient post-create; isWorkspaceDrift =
403 out-of-band deletion) and new ERROR_CODES tokens. No behaviour change to
existing handlers. 51 transport+errors unit tests pass; lint clean.

Implements serenity-docs brand-semrush-provisioning-v2-phase1-implementation.md Step 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Brand-level cache (mirrors the org cache) holding the brand's own child
workspace id; resolveBrandWorkspace returns {mode:'child',workspaceId:childWs}
when brands.semrush_workspace_id is set, else {mode:'legacy',workspaceId:<org
parent>} (reusing resolveWorkspaceId so the parent never goes stale behind a
brand entry). No 'null'-string coercion (per requester). 21 unit tests; lint
clean. Not yet wired into the controller (next commit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ensureChildWorkspace: re-grant onto a kept workspace when the column is set;
else create child → poll until 'created' → persist the column (flips brand to
child mode) → clear resolver cache. Ambiguous-create (504 timeout) recovers by
adopting a unique parent-family match by exact title; multiple matches fail
with an ambiguousWorkspace alert. decommissionBrandWorkspace: delete every
listed project (404-as-success) then release the allocation; never deletes the
workspace. Poll timing is injectable for tests. Transfer payloads marked as
Gate-A live-smoke pins. 16 unit tests; lint clean.

Implements serenity-docs v2-phase1 Steps 3/6 (lifecycle).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
child-projects.js centralizes the child-mode listing/mapping rules so every
child handler resolves a slice the same way: mapPublishStatus (5 states 1:1),
projectToSlice (elmo DTO + additive status/semrushProjectId from settings.ai
location/language + project timestamps), listChildMarkets (one v1 GET), and
resolveChildProject (slice match with deterministic oldest-created_at-wins +
alert on duplicate slice, design §7). 9 unit tests; lint clean.

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

Gate-A net-zero live smoke against the dev parent (bb0f4e1c) surfaced four
issues the design doc/unit tests alone missed; all fixed and re-validated live:

- Workspace lifecycle (create child/status/family/resources/members/delete) is
  served by the user-manager API under /enterprise/users/api, NOT the
  project-engine prefix /enterprise/projects/api (which 404s those routes).
- listProjects requires ?type=ai (omitting it 500s; the v2 list 400s
  'type query parameter is required').
- Project list items echo NESTED settings.ai.location.id (geo) and
  settings.ai.language.name (lang), not scalars; they carry updated_at (and
  published_at when live) but no created_at — so the duplicate-slice oldest
  rule orders by created_at ?? updated_at.
- resources/transfer is async: it briefly flips the workspace to 'locked' and a
  subsequent op 422s 'workspace not ready'. ensureChildWorkspace now settles
  (polls to 'created') before AND after a re-grant transfer.

Full child-mode flow (create→list→draft→publish→decommission→delete) verified
end-to-end live with the actual transport + child-projects mapping; tenant left
at baseline. 99 serenity support unit tests pass; lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
markets-child.js implements the child path: list (live listing -> slice DTOs),
get (resolve + init_status enrichment, 404 marketNotFound), create (ensure
workspace -> adopt-leftover-draft-or-create -> publish-once, no DB write, no
floor, 409 only on a LIVE slice), delete (resolve + 404-as-success, no floor).
Exports resolveLanguageId/defaultMarketName from the frozen markets.js (no
behaviour change). 16 unit tests; lint clean. Controller dispatch wired next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
authorize() now returns {brandUuid, mode, workspaceId, parentWorkspaceId} via
resolveBrandWorkspace. Markets list/get/create/delete dispatch legacy-vs-child;
legacy handlers unchanged. Prompts/tags/models 501 for child-mode brands
(child resolution is the documented follow-up). New POST /serenity/activate
(ensure workspace + per-market create/publish, sets brand active) and POST
/serenity/deactivate (decommission, sets brand pending) endpoints + routes.
301 serenity+routes tests pass (38 legacy controller tests unchanged + 11 new
dual-mode/activate/deactivate); lint clean.

Implements serenity-docs v2-phase1 Steps 3-6 (controller + endpoints).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…i contract test

The dual-mode authorize() now resolves the brand workspace, so the contract
test's mock context needs dataAccess.Brand + the resolveBrandWorkspace stub
(legacy mode). 11 contract tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the child-mode 501 stubs for prompts/tags/models with real
handlers. The child path differs from legacy only in slice->project
resolution: legacy reads BrandSemrushProject, child resolves the project
from the brand's own child workspace via the live listProjects listing.
Everything downstream (the Semrush prompt/tag/model calls, publish-once,
per-project tag-cache invalidation, bulk caps) is shared, project-keyed
logic — reused via additive exports and behaviour-preserving extraction:

- prompts.js: additively export the pure leaf helpers (buildPromptDto,
  normalizePromptInput, mapLimit, publishAffected, limit constants).
- markets.js: extract project-keyed cores (listTagsForProject,
  listGlobalModelCatalog, listSliceModels, syncModelsForProject); the
  legacy handlers now delegate to them with identical behaviour (legacy
  tests unchanged and green).
- child-projects.js: add buildChildSliceProjectMap + sliceKey for the
  bulk child write handlers (one listing, oldest-wins dedup).
- new prompts-child.js + tags/models child handlers in markets-child.js.
- controller dispatches child handlers on mode === 'child'.

342 serenity unit/contract/route tests pass; lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… publish on model change

- OpenAPI: add POST /serenity/activate + POST /serenity/deactivate paths and
  the previously-undocumented PUT /serenity/models, with request/response
  schemas (SerenityActivateRequest/Response, SerenityDeactivateResponse,
  SerenityUpdateModelsRequest). Additive child-mode fields on the market
  schemas: SerenityMarketStatus + status/semrushProjectId on SerenityMarket,
  initialized on SerenityMarketDetail (all optional → legacy responses still
  validate). Contract test extended with the 3 new operations + parity.
- handleUpdateModels: publish the project after a real add/remove so the new
  model set goes live — the one deliberate exception to the frozen-legacy rule,
  in the shared core so legacy + child both get it. Latent bug: PUT /models
  never published before.

docs:lint valid, docs:build OK, 351 serenity tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…vate/PUT models

Extend the serenity IT suite to the new dual-mode routes, matching the existing
suite's scope (the JWT harness can't mint IMS tokens, so handler behaviour stays
covered by unit + contract + the live through-api e2e). Locks that POST activate,
POST deactivate, and PUT models are registered and enforce the same pre-handler
contract: 401 for non-IMS auth, 400 route-gate on a non-UUID brandId. Adds a
`put` method to the shared IT http-client.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The non-child resolution path is the flat (shared parent-workspace) mode.
Calling it "legacy" wrongly implied the brand was deprecated or never a
Serenity brand. Rename the resolveBrandWorkspace mode value 'legacy' ->
'flat' and reword every comment, JSDoc, OpenAPI description, and test to
say "flat (resolution) mode". Dispatch only branches on mode === 'child',
so the value rename has no behavioural effect.

Also clarify the brand-cache comment: a null cached value means no child
workspace is bound, not that the brand is legacy.

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

Copy link
Copy Markdown

This PR will trigger a minor release when merged.

rainer-friederich and others added 6 commits June 15, 2026 15:25
… cleanup

The transport DELETE /v1/workspaces/{ws} call was guarded only by a doc
comment ("TEST CLEANUP ONLY"). Nothing actually prevented it from running
in a deployed environment. Add a fail-closed runtime guard: deleteWorkspace
throws unless SERENITY_ALLOW_WORKSPACE_DELETE === "true", which no deployed
environment (dev/stage/prod) sets — only a local box running the net-zero
live smoke opts in.

Production decommission (decommissionBrandWorkspace) already empties and
releases a workspace but never deletes it (design SS6); this closes the
remaining gap so deletion is impossible in prod by construction, not just
by convention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The non-flat mode operates on the brand's own Semrush *sub-workspace*;
"child" was ambiguous (projects also live "inside" a workspace). Rename
the mode value (child -> subworkspace), the handler files
(markets-child/prompts-child/child-projects -> *-subworkspace), and all
symbols/comments accordingly. Internal helpers in subworkspace-projects.js
drop the now-redundant qualifier (resolveProject/listMarkets/
buildSliceProjectMap). Dispatch still branches on mode, so no behaviour
change. Unrelated uses of "child" (brand child tables, child_process,
ephemeral parent/child runs) are left untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deactivation now deletes the sub-workspace's projects, releases the
allocation, then CLEARS brands.semrush_workspace_id (the disconnect) and
invalidates the resolver brand cache so the brand returns to flat mode
immediately. The sub-workspace itself is still never deleted (deletion is
forbidden) — it is left empty and unowned; a future activate allocates a
fresh one. Updates the decommission/ensure docs (the re-grant branch no
longer handles a decommissioned brand, which now has a NULL pointer) and
the deactivate OpenAPI description.

Also finishes the child -> subworkspace rename in the OpenAPI specs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
What to delete, what shared cores to keep, and the naming cleanup so the
codebase shows no trace of two modes once flat mode is retired. Includes
precondition signals (forward-only retire) and per-phase validation gates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pulls in the Brand entity (adobe/spacecat-shared#1679) that the Serenity
dual-mode resolver consumes at runtime via dataAccess.Brand. Replaces the
local npm-link used for through-api e2e validation with the released
package. Serenity unit suite (352) green against 3.75.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…auth + cache correctness

- required-capabilities: map activate/deactivate to organization:write (fixes the
  route-coverage drift test that was failing CI — every route must be in
  routeRequiredCapabilities or INTERNAL_ROUTES).
- activate: ensure the sub-workspace ONCE for the whole batch, sized to the real
  market count, then create each market against the resolved workspace — instead
  of re-granting + double-polling per market (N markets risked the Lambda
  timeout and under-sized the allocation). handleCreateMarketSubworkspace takes
  an optional pre-resolved workspace id; the single-market POST path still
  ensures on the spot.
- authorize: a brand already in subworkspace mode resolves against its OWN
  workspace, so a missing/cleared org parent pointer no longer 404s it — only
  flat mode without a parent is a genuine 404.
- deactivate: invalidate the resolver brand cache BEFORE the save (the upstream
  is already emptied by decommission), so a failed save can't keep routing to the
  emptied sub-workspace for the positive-TTL window.
- workspace-lifecycle: clarify the ambiguous-create guard as
  !(e instanceof ErrorWithStatusCode) instead of `=== false`.
- rest-transport: correct the createTaggedPrompts body-shape comment to match the
  actual prompt-text-keyed shape both modes send.
- drop a stray log arg passed to the 4-param flat handleListMarkets.

Tests updated/added for batch-once activate, subworkspace-survives-missing-parent,
cache-cleared-on-failed-save, and the pre-resolved-workspace create path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@rainer-friederich rainer-friederich changed the title feat(serenity): sub-workspace dual-mode provisioning (activate/deactivate + child-mode reads/writes) feat(serenity): sub-workspace dual-mode provisioning (activate/deactivate + subworkspace-mode reads/writes) Jun 15, 2026
@rainer-friederich

Copy link
Copy Markdown
Contributor Author

Pushed a round of fixes from a local multi-perspective review (correctness, simplicity/DRY, conventions/security), plus the dependency bump and a description refresh.

Code fixes (commit c2f3cff):

  • Route mapping: activate/deactivate are now in routeRequiredCapabilities as organization:write. This is the fix for the failing CI — the route-coverage drift test requires every route to be in routeRequiredCapabilities or INTERNAL_ROUTES, and these two were unmapped.
  • Batch activate: ensureSubworkspace now runs once for the whole batch, sized to the real market count, and each market is created against the resolved workspace. Previously it re-granted + double-polled per market, so a multi-market activate risked the Lambda timeout and under-sized the allocation.
  • Auth correctness: a subworkspace-mode brand is no longer 404'd when the org parent workspace pointer is missing — it resolves against its own sub-workspace. Only flat mode without a parent is a real 404.
  • Cache correctness: deactivate invalidates the resolver brand cache before the save (the upstream is already emptied by decommission), so a failed save cannot keep routing to the emptied sub-workspace for the positive-TTL window.
  • Readability/docs: clarified the ambiguous-create guard !(e instanceof ErrorWithStatusCode), corrected the createTaggedPrompts body-shape comment to match the prompt-text-keyed shape both modes send, and dropped a stray log arg passed to the 4-param flat handleListMarkets.

Tests: added/updated for batch-once activate, subworkspace-survives-missing-parent, cache-cleared-on-failed-save, and the pre-resolved-workspace create path. Serenity unit + dispatch + OpenAPI-contract + route-coverage suites green; lint and docs:lint clean.

Dependency: bumped @adobe/spacecat-shared-data-access to 3.75.0 (the released Brand entity), replacing the local npm-link used for e2e.

Deliberately deferred: the remaining flat/subworkspace prompts-orchestration duplication (list/create/bulk-delete) is left as-is and tracked in docs/specs/2026-06-15-serenity-flat-mode-removal-plan.md — extracting it now would couple the frozen flat handlers that are slated for deletion.

@codecov

codecov Bot commented Jun 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

… full branch/line coverage

Safety guard (new): a brand's sub-workspace must never be the org's shared
parent workspace. If they coincide, every sub-workspace operation - most
dangerously deactivate's decommission (deletes all projects + releases the
allocation) - would run against the shared parent pool and wipe it for the
whole org. Enforced at the authorize() chokepoint (409 workspaceMisconfigured,
refuses ALL operations) plus defense-in-depth assertNotParent() guards in
ensureSubworkspace (re-grant and create paths).

Local-review follow-ups:
- Delete the unwired error-classification surface (isAllocationFailure,
  isWorkspaceNotReady, isWorkspaceDrift + their ERROR_CODES) and the unused
  transport methods getProject/removeWorkspaceMember - tested-but-never-consumed
  code whose docstrings claimed wiring that did not exist (3-reviewer consensus).
- activate: a single market throwing no longer aborts the batch (would strand
  already-published markets live while the brand stays pending); record the
  failure per market and continue.
- Fold parentWorkspaceId into resolveBrandWorkspace's return so authorize stops
  resolving the org workspace twice.
- subworkspace-projects: deterministic id tie-break in orderKey when timestamps
  are equal/absent (was listing-order-dependent).

Coverage: add tests so the changed serenity surface is fully covered (every
handler's mapError + auth.error short-circuit, authorize error branches, the
new guard, prompts-subworkspace create/bulk-delete error paths, markets-
subworkspace validation branches, the default poll timer). 2983 serenity tests
pass; lint + docs clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…409, cover fallbacks

Second local review round (no Critical/Important findings). Addressed:
- decommissionBrandWorkspace now self-defends: takes parentWorkspaceId and
  refuses to empty/release the org parent workspace, so the destructive
  primitive is protected even if a future caller bypasses the authorize()
  guard. deactivate passes auth.parentWorkspaceId.
- OpenAPI: document the new 409 workspaceMisconfigured response on
  activate/deactivate.
- Plan doc: note the implementation deviations (dropped unwired error
  predicates + transport methods, added the sub-workspace!=parent invariant).
- Tests: cover the statusless-throw fallbacks (activate 502, prompts-subworkspace
  create/bulk-delete 500), flat-mode createMarket/bulkDeletePrompts dispatch,
  the decommission self-guard, and assert brand.save on the partial-failure path.

All serenity files have zero uncovered lines; 2989+ serenity tests pass; lint +
docs:lint clean. (codecov/patch + ci build already green on the prior commit.)

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

Copy link
Copy Markdown
Contributor Author

Second local review round (multi-perspective: correctness/architecture, security/auth, QA). No Critical or Important findings - both the security and architecture passes approved the new sub-workspace-vs-parent safety guard. Addressed the minor follow-ups:

  • Self-defending decommission: decommissionBrandWorkspace now takes the parent workspace id and refuses to empty/release the org parent pool, so the most destructive primitive is protected even if a future caller ever bypasses the authorize() chokepoint. deactivate passes the resolved parent.
  • OpenAPI: documented the new 409 workspaceMisconfigured response on activate and deactivate.
  • Plan doc: noted the implementation deviations (the unwired error predicates and the two unused transport methods were dropped; the sub-workspace must-not-equal-parent invariant was added).
  • Tests: covered the statusless-throw fallbacks (activate -> 502; prompts-subworkspace create/bulk-delete -> 500), flat-mode createMarket/bulkDeletePrompts dispatch, the decommission self-guard, and added a brand.save assertion on the partial-failure path.

Coverage: every changed serenity file now has zero uncovered lines; codecov/patch and ci build are green. Lint and docs:lint clean.

Context on the headline guard (answering an earlier question): if a brand's semrush_workspace_id ever equals the org's parent workspace id, the brand would resolve to sub-workspace mode pointing at the shared parent - and deactivate's decommission (delete all projects + release allocation) would wipe the parent pool for every brand in the org. That is now forbidden at three layers: the authorize() chokepoint (409, refuses all operations), ensureSubworkspace (re-grant and create paths), and decommissionBrandWorkspace itself.

rainer-friederich and others added 2 commits June 17, 2026 20:20
…space-dual-mode

# Conflicts:
#	src/controllers/brands.js
#	src/support/brands-storage.js
#	test/support/brands-storage.test.js
Add tests for the untested error/skip branches surfaced after merging
main: org-catalogue handler failures (no data-access, org-not-found,
upstream error), brand-URL benchmark create/skip logging, the
no-resolvable-benchmark market skip, competitor-sync summary logging,
markets-subworkspace best-effort publish success + {items} topics
envelope + zero-prompt skip, and the createBenchmarks/deleteBenchmarks
transport wrappers. All previously-uncovered lines in the serenity
dual-mode files are now exercised.

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

Apply the local review fixes and bring the PR's changed code to 100% line
coverage so the codecov/patch gate passes.

Review fixes:
- workspace-resolver: brand sub-workspace positive cache uses a dedicated
  BRAND_CACHE_TTL_MS (10s) instead of the 5min org TTL; resolve brand and
  workspace ids concurrently.
- serenity deactivate: wrap brand.save() so a decommissioned-upstream but
  failed-persist divergence is logged (SERENITY_DEACTIVATE_SAVE_DIVERGENCE)
  and surfaced instead of silently swallowed.
- markets-subworkspace: correct the JSDoc to match the best-effort (non-fatal)
  brand-URL push behaviour.
- workspace-lifecycle: sub-workspace title hard-fails when no brand id is
  resolved rather than building a non-unique name-only title.
- brand-provisioning: use the strict IMS token helper on release; drop the
  unused non-strict import; clarify naming.
- prompts handlers: rename shadowed loop vars; note records are pre-redacted.

Coverage:
- Add targeted tests for the previously-uncovered defensive paths across the
  serenity helpers, handlers and the brands controller (every changed file is
  now at 100% line coverage). Includes direct unit tests for locations and
  prompt-tags. Full suite green (12328 passing); lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
.scout/scout.config.yaml is per-developer local tooling config and was
accidentally committed on this branch. Untrack it and gitignore .scout/.

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

Copy link
Copy Markdown
Contributor Author

Backwards compatibility check: flat mode

I reviewed whether existing brands (no semrush_workspace_id, i.e. flat mode running against the org's shared parent workspace) still behave exactly as before this change. Verdict: flat mode is preserved. No breakage to existing flat brands.

Routing is additive. The new routes (serenity/models, serenity/languages, serenity/activate, serenity/deactivate) are all new; no existing serenity route changed.

Mode resolution falls through to flat safely. resolveBrandWorkspace returns mode: 'flat' with workspaceId = the org parent whenever the brand has no semrush_workspace_id, resolved via the same resolveWorkspaceId lookup main used. A null brand subworkspace id is cached negatively, so a flat brand can never be routed into subworkspace code.

Dispatch preserves the original handlers. Every operation uses mode === 'subworkspace' ? <new handler> : <original flat handler>, and each flat branch calls the original handler with args equivalent to main (auth.workspaceId equals the old auth.semrushWorkspaceId). All eleven existing operations (prompts list/create/update/bulk-delete, markets list/get/create/delete, tags, models list/update) were enumerated and pass equivalent args.

Markets handlers keep identical flat behavior. handleListMarkets, handleGetMarket, handleCreateMarket, handleDeleteMarket, handleListTags, handleListModels produce the same upstream calls, response shapes, validation, and cache keys. The markets.js refactor only extracted behavior-preserving helpers (resolveLanguageId, listTagsForProject, syncModelsForProject, defaultMarketName) and moved resolveLocation to locations.js (verbatim, re-exported).

Brand create/edit gates the new behavior. Semrush provisioning is gated on semrushMarket being present; a create without it follows the existing path unchanged (forceBrandId/semrushWorkspaceId default to null, and the status computation collapses to the old hasBaseSite logic). The edit re-sync is gated on the brand having a semrush_workspace_id, so flat edits skip it entirely. mapDbBrandToV2 only adds semrushWorkspaceId (null for flat brands); no existing field is removed or renamed.

One intentional behavior change worth flagging: the flat PUT /models (handleUpdateModels) now publishes the project after a model-set change, via syncModelsForProject. On main the assignment was staged but never published, so the change never went live. This is a latent-bug fix rather than a regression: nothing that previously worked breaks; an endpoint that silently no-op'd now actually takes effect.

@calvarezg

Copy link
Copy Markdown
Contributor

Multi-persona review (Architect · DBA · Security · Tester)

Strong, unusually well-defended PR — the destructive-safety design (3-layer parent-equality guard, never-delete decommission, brand-uuid adoption titles, fail-closed delete flag) and security posture (in-org authorize on every route, server-derived workspace ids, uniform 401/403 redaction, IMS-only bearer forwarding) all hold up. Security found nothing exploitable. One Major worth fixing before merge.

🟠 Major — activate status-save is unguarded (asymmetric with deactivate)

src/controllers/serenity.js:L2324-L2327

After markets go live, brand.setStatus('active'); await brand.save(); has no try/catch. If the save fails: markets are live upstream (pointer is correctly persisted by ensureSubworkspace), but brands.status stays pending, the throw hits mapError, and the caller gets a 5xx instead of the 207/200 body listing which markets went live. There's also no greppable alert token — unlike deactivate, which deliberately wraps the same seam with SERENITY_DEACTIVATE_SAVE_DIVERGENCE (L2397-L2416).

Fix: mirror deactivate — try/catch around save(), log a distinct token with {brandId, semrushWorkspaceId, marketsLive}, and return the 207 body (markets are live) rather than collapsing to a 5xx. Blast radius is bounded (re-activate converges), but the asymmetry reads as an oversight.

🟡 Minor / Questions

  • Concurrent-activate "both-read-null" race isn't pinned by a test (workspace-lifecycle.js:L6196-L6234). 100% line coverage hides it (the null-winner test runs the same lines). Add a both-read-null characterization test before the tracked conditional-write fix lands.
  • Brand-edit re-sync has no per-market failure observability (brands.js:L1606-L1619) — author-acknowledged. A mid-fan-out throw logs aggregate counts but not which market split. Log {workspaceId, projectId, market} before propagating.
  • Deferred prompts-orchestration duplication (prompts-subworkspace.js vs prompts.js) — defensible given planned flat-mode removal; add a cross-reference comment in both files so a future fixer knows the twin exists.
  • Q: flat PUT /serenity/models now publishes — disclosed in the description as a latent-bug fix, so not silent, but confirm no flat caller relied on staging model changes before a separate publish.
  • Q: brand_name_display always uses brandNames[0], silently ignoring an explicit brandDisplayName (markets-subworkspace.js buildCreateProjectBody). Either honor it or drop it from the contract.
  • Minor: deactivate save-failure stale-pointer window (documented, self-healing, has alert token — fine for Phase 1); unicode brand-name title round-trip untested.

Recommendation: Approve with reservations — address the Major before merge; the rest are discretionary or already tracked.

🤖 Multi-persona review (findings verified against the diff)

…ollow-ups

Addresses calvarezg's multi-persona review on PR 2610.

Major — activate status-save was unguarded (asymmetric with deactivate):
after markets go live, `brand.setStatus('active'); save()` had no try/catch.
A save failure threw to mapError and collapsed to a 5xx, discarding the
per-market results that tell the caller which markets went live. Mirror the
deactivate seam: catch the save, emit a distinct greppable token
(SERENITY_ACTIVATE_SAVE_DIVERGENCE) with {brandId, semrushWorkspaceId,
marketsLive}, force the partial-failure path so the caller gets a 207 (markets
ARE live) instead of a 5xx, and fall through to return the multi-status body.
A re-activate converges (every live market returns 409), so the divergence
self-heals.

Minor follow-ups:
- Per-market failure observability: the brand-edit re-sync fan-outs
  (brand-urls, competitor-benchmarks) now log {workspaceId, projectId, market}
  (status only — upstream text carries the gateway URL) before rethrowing, so a
  mid-fan-out throw names which market split, not just an aggregate count.
- brand_name_display now honors an explicit brandDisplayName (falling back to
  brandNames[0]), keeping the project display name consistent with the own-brand
  benchmark built from brandDisplayName and with the re-sync read path.
- prompts.js / prompts-subworkspace.js: added explicit TWIN-FILE notes flagging
  the duplication as deferred pending flat-mode removal.

Tests: activate save-divergence (207 + token); per-market throw logging for both
re-sync fan-outs; brandDisplayName honored vs fallback; and a both-read-null
two-writer characterization test pinning the documented residual race.

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

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough multi-persona pass — addressed in ab640f85.

Major — activate status-save now guarded (symmetric with deactivate)

Wrapped the setStatus('active'); save() seam in a try/catch that mirrors deactivate's divergence guard:

  • emits a distinct, greppable token SERENITY_ACTIVATE_SAVE_DIVERGENCE with {brandId, semrushWorkspaceId, marketsLive};
  • forces the partial-failure path so the caller gets a 207 with the per-market results (markets ARE live) instead of a 5xx that discards them;
  • falls through to return the multi-status body rather than throwing to mapError.

A re-activate converges (every live market returns 409), so the divergence self-heals. New test asserts 207 + status: active + the token on a save failure.

Minor / Questions

  • Both-read-null race characterization — added a two-writer Promise.all characterization test in workspace-lifecycle.test.js: two activations that both re-read null both persist their own distinct workspace id and neither releases. Pins the documented residual race so the future conditional-write fix has a target to flip.
  • Brand-edit re-sync per-market observability — addressed at the fan-out level (the better place — the controller can't know which market threw). Both syncBrandUrlsAcrossMarkets and syncCompetitorBenchmarksAcrossMarkets now log {workspaceId, projectId, market} (status only — the upstream text carries the gateway URL) before rethrowing. Tests cover the per-market throw path.
  • Deferred prompts duplication — added explicit TWIN FILE notes to both prompts.js and prompts-subworkspace.js flagging the duplication as deferred pending flat-mode removal, so a future fixer knows the twin exists and to keep them in lockstep.
  • Q: flat PUT /serenity/models now publishes — confirmed safe. There is a single PUT /serenity/models route and no separate publish-models step; on main, flat handleUpdateModels made zero publishProject calls, so model changes never propagated upstream at all. No flat caller could have relied on staging-without-publish — the change simply makes the PUT take effect.
  • Q: brand_name_display ignoring brandDisplayName — now honored. buildCreateProjectBody uses brandDisplayName when present (falling back to brandNames[0]), making it consistent with the own-brand benchmark built from brandDisplayName (attachBrandUrlsToProject) and with the re-sync path that reads brand_name_display back as the benchmark name. Tests cover honored-vs-fallback.
  • Unicode brand-name title round-trip / deactivate stale-pointer window — leaving as-is for Phase 1 (the deactivate window is documented, self-healing, and already has an alert token, as you noted).

All modified files at 100% line coverage; full serenity + brands suites green, lint clean.

@calvarezg

Copy link
Copy Markdown
Contributor

Review follow-up — finding status after ab640f855

Re-checked every finding from the multi-persona review against the current head. ab640f855 ("guard activate status-save divergence + close review follow-ups") closes all actionable items. Status below.

Note: the earlier comment's serenity.js:L2324/L2397 cites were offsets into a concatenated diff, not source lines — the real activate seam is serenity.js:849. Apologies for the confusion.

# Finding Severity Status
1 activate status-save unguarded (asymmetric with deactivate) 🟠 Major FixedSERENITY_ACTIVATE_SAVE_DIVERGENCE token + forces 207 (markets are live) instead of 5xx, with test (serenity.js:850)
2 Brand-edit re-sync had no per-market failure observability 🟡 Minor Fixed — fan-outs now log {workspaceId, projectId, market} before rethrow, + tests
3 brand_name_display ignored explicit brandDisplayName ❓ Question Fixed — now honors brandDisplayName, falls back to brandNames[0], + test
4 Concurrent-activate "both-read-null" race not pinned by a test 🟡 Minor Fixed — two-writer characterization test added
5 Deferred flat/subworkspace prompts duplication 🟡 Minor Fixed — explicit TWIN-FILE cross-reference notes in both files
6 Flat PUT /serenity/models now publishes ❓ Question No change needed — disclosed in the PR description as a latent-bug fix; the open part is a product confirmation (did any flat caller rely on staging model changes before a separate publish?), not a code defect
7 deactivate save-failure stale-pointer window 🟡 Minor Accepted for Phase 1 — already carries the SERENITY_DEACTIVATE_SAVE_DIVERGENCE alert token; documented and self-healing on re-activate
8 Unicode brand-name → workspace-title round-trip untested 🟡 Minor Open (nit) — lowest priority; plain JS strings in JSON bodies, low risk

Net: 0 Critical, the 1 Major closed, all actionable minors addressed with tests. Items 6–8 are a disclosed product question and two explicitly-accepted Phase-1 residuals — none is a merge blocker. Code is in good shape to accept once ci / build goes green and an approving review lands.

🤖 Multi-persona review follow-up

rainer-friederich and others added 2 commits June 18, 2026 08:48
Audited the transport against the new project-engine + user-manager swaggers
for the "prefer v2 when available" rule. addAiModel was the one clean gap: it
POSTed to /v1/.../ai_models, but v2 exposes the same route
(POST /v2/workspaces/{ws}/projects/{pid}/ai_models) with identical request
(CreateProjectAIModelRequest) and response (ProjectAIModelResponse) schemas —
a drop-in, matching the existing createBenchmarks v2 move.

The sibling list/delete ai_models routes have no v2 variant (v2 ai_models is
POST-only), so listAiModels/deleteAiModelsByIds stay on v1 — same pattern as
listBenchmarks/deleteBenchmarks (v2 benchmarks is also POST-only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the v2 audit. The v1 resources/transfer route's documented body
(handlers.WorkspaceResources) is a FLAT object with no `ai` key — it never
matched what the transport sends. Our `{ ai: { projects, prompts } }` payload is
exactly v2's aiProductResources shape, already proven live as the v2 child-create
`resources` body (createSubworkspace). v2 wraps it under a `resources` key
(WorkspaceResourcesTransferV2Form), so the transport now POSTs
`{ resources: payload }` to POST /v2/workspaces/{ws}/resources/transfer.

Callers (resourceAllocation / RELEASE_ALLOCATION / CREATE_ALLOCATION) are
unchanged — they still build the bare resources object; the v2 envelope lives in
the transport. Only the allocation values (release = zeroed ai) remain a Gate-A
live-smoke pin; the wire shape is now contract-correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
rainer-friederich and others added 3 commits June 18, 2026 09:11
GET /v1/workspaces/{id}/family returns a BARE ARRAY of workspaces (live-verified
against the gateway; the swagger types it as a top-level array too). The code read
`family?.items`, assuming an `{ items: [...] }` envelope — on the real response
`.items` is undefined, so EVERY entry was discarded:

- adoptFromFamily always saw zero matches → ambiguous-create recovery (its whole
  purpose) was dead on arrival, throwing 502 "no family match to adopt" even when
  the parent returned real children.
- decommissionBrandWorkspace's enforceLinkedGuard branch (default-off, latent) saw
  zero children → would have let a decommission proceed against a parent that
  still has live linked sub-workspaces.

Added a familyItems() normalizer used at both sites: prefer the bare array,
tolerate a legacy `.items` envelope. New tests pin the real bare-array shape for
both the adopt path and the linked-child guard.

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

The gateway only ever returns a bare array for GET /v1/workspaces/{id}/family, so
familyItems() no longer tolerates a hypothetical { items: [...] } envelope — it
reads the array directly, guarding only against a non-array (null/malformed) body.
Updated every family test mock to the real bare-array shape (the {items} mocks
were encoding the original bug's assumption) and folded the two added bare-array
characterizations into the existing tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@rainer-friederich rainer-friederich enabled auto-merge (squash) June 18, 2026 08:24
@rainer-friederich rainer-friederich merged commit 47b8352 into main Jun 18, 2026
18 checks passed
@rainer-friederich rainer-friederich deleted the feat/serenity-subworkspace-dual-mode branch June 18, 2026 08:33
solaris007 pushed a commit that referenced this pull request Jun 18, 2026
# [1.579.0](v1.578.2...v1.579.0) (2026-06-18)

### Features

* **serenity:** sub-workspace dual-mode provisioning (brand-create, activate/deactivate, subworkspace reads/writes) ([#2610](#2610)) ([47b8352](47b8352)), closes [throu#api](https://github.com/throu/issues/api) [hi#market-count](https://github.com/hi/issues/market-count)
@solaris007

Copy link
Copy Markdown
Member

🎉 This PR is included in version 1.579.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants