Skip to content

feat(serenity): Track producer-source passthrough + can_track gate (PROTOTYPE, SITES-47870) - #2910

Closed
aliciadriani wants to merge 17 commits into
mainfrom
claude/serenity-source-passthrough-proto
Closed

feat(serenity): Track producer-source passthrough + can_track gate (PROTOTYPE, SITES-47870)#2910
aliciadriani wants to merge 17 commits into
mainfrom
claude/serenity-source-passthrough-proto

Conversation

@aliciadriani

@aliciadriani aliciadriani commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

DRAFT / RFC — prototype for discussion, not for merge. From the PR #2867 thread, making @jjenscodee's Serenity-path note concrete. Stacked on claude/wp-s2-source (WP-S2).

Problem

The Serenity create path injects a fixed PROXY_CREATE_SOURCE_VALUE = 'config' on every prompt. The SR "Track" flow in Serenity mode writes through the same path, but those prompts genuinely originate from semrush / gsc / citation-attempt / synthetic-personas cards — so they collapse to source = config. And /serenity/prompts has no prompts row for the SITES-47870 read-side treatment to derive from, so it needs a fix at the write site.

Commits (3)

  1. Injector seammakePromptTagInjector honours a per-item input.source override (canonicalized, config fallback, memoized per (project, source)).
  2. Track wiringPOST /serenity/prompts accepts a top-level assertSource: true opt-in; when set, each item's source is threaded through. Without it, a body source is ignored (default surface stays closed, §1 item 6).
  3. Capability gateassertSource is gated on a FACS producer capability <product>/can_track (e.g. llmo/can_track), distinct from llmo/can_configure. Enforced in the controller via authInfo.hasFacsPermission; non-FACS sessions (admin/internal/s2s) bypass, mirroring the ReBAC pattern in brands.js. A flag set without the capability is dropped (→ config) and logged — not a hard 403 — so a mixed batch still writes.

What's here

  • handlers/prompts.js, handlers/prompts-subworkspace.js — injector seam + assertSource wiring (twins).
  • controllers/serenity.js — the capability gate (body sanitized before dispatch).
  • docs/openapi/schemas.yamlassertSource + per-item source + the capability note.
  • Tests: injector (5) + flat handler (4) + subworkspace handler (2) + controller gate (3) — override, canonicalization, closed-by-default, unparseable-fallback, (project,source) caching, honoured-with-cap / dropped+warned-without / non-FACS-bypass. Full suite green (15,425), coverage gate met.

Open items for the design sync (@dzehnder / @jjenscodee)

  1. Where does the Track flow supply the source? — per-item source under assertSource.
  2. Which principals may set assertSource? — gated on <product>/can_track. Sub-decisions remain: (a) register can_track in the MAC capability catalog; (b) reject-vs-ignore an unpermitted flag (this prototype ignores); (c) confirm can_track is the right capability name/granularity.
  3. Alignment with the /prompts strip vs SITES-47870 — this should match so Serenity-mode and brandalf orgs behave the same.
  4. Should source also accept a tag id, not just a bare value?

Not to be merged until the prompts.source contract (esp. item 2's sub-decisions + item 3) is settled.


Drafted MAC capability-catalog entry (llmo/can_track)

The api-service-side catalog doc block in facs-capabilities.js is updated in this PR. The authoritative catalog lives in mysticat-architecture/platform/decisions/mac-state-layer.md §"Capability catalog" (not in this repo) — here is the entry to register there:

Field Value
Capability llmo/can_track
Product LLMO
Resource scope brand (same as the POST .../serenity/prompts route)
Grants Assert the producing source on a Serenity "Track" prompt write — set assertSource: true + a per-item source on POST /v2/orgs/:spaceCatId/brands/:brandId/serenity/prompts.
Relation to can_configure Strictly additional / narrower. can_configure (still the route requirement) lets a caller create/edit prompts; it does not permit attributing a producing system. can_track is checked in addition, at runtime.
Enforcement Runtime, in the serenity controller via authInfo.hasFacsPermission('llmo/can_track')not a route entry in PRODUCTS_ROUTES. Non-FACS sessions (admin / internal / s2s) bypass.
Denied behaviour Fail-safe: an assertSource set without the grant is dropped (source → config) and logged; no 403.
Granted to The SR "Track" feature's identity only. TBD (design sync): which persona/role binding carries it, and whether to switch denied behaviour to a hard 403.

Once registered + granted, context.attributes.authInfo.hasFacsPermission('llmo/can_track') returns true for Track callers and the prototype gate lights up end-to-end.


Drafted state-layer binding grant (llmo/can_track)

can_track is now listed in PRODUCTS_CAPABILITIES.LLMO (this PR) so the grant validates through createMapping/patchMapping and appears in GET /product/capabilities. The binding grants can_track to the SR "Track" feature identity, scoped to brand. Baseline llmo/can_view is auto-added by ensureBaselineCanView. The grant must be performed by a caller holding llmo/can_manage_users for the resource.

A. Via api-service (preferred) — new binding

POST /state/access-mappings
x-product: LLMO
Authorization: Bearer <manager token holding llmo/can_manage_users>
Content-Type: application/json

{
  "subjectType": "user",
  "subjectId": "<track-identity>@<authSrc>",
  "resourceType": "brand",
  "resourceId": "<brandId>",
  "grantedCapabilities": ["llmo/can_track"]
}

Add to an EXISTING binding instead (set-semantics): PATCH /state/access-mappings/:id with { "grantedCapabilities": ["llmo/can_configure", "llmo/can_track"] }.

B. Via the set-capabilities RPC (existing mapping id)

SELECT * FROM wrpc_set_facs_access_mapping_capabilities(
  '<mapping-id>'::uuid,
  '<imsOrgId>',
  'LLMO',
  ARRAY['llmo/can_view', 'llmo/can_track']   -- full desired set (replace, not append)
);

C. Raw row (reference only — bypasses controller validation)

INSERT INTO facs_access_mappings
  (subject_type, subject_id, resource_type, resource_id, ims_org_id, product,
   granted_capabilities, created_by)
VALUES
  ('user', '<track-identity>@<authSrc>', 'brand', '<brandId>', '<imsOrgId>', 'LLMO',
   ARRAY['llmo/can_view', 'llmo/can_track'], 'system');

Open (design sync): the Track identity + subject granularity — per-user vs an org-scoped binding (subjectType: "org", subjectId = <imsOrgId>, org-wide resourceId) if Track acts for the whole org rather than per brand. And whether the grant is provisioned automatically when a brand enables Track, or issued manually.

@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@github-actions

Copy link
Copy Markdown

This PR will trigger a minor release when merged.

aliciadriani pushed a commit that referenced this pull request Jul 27, 2026
…47870)

Adds `llmo/can_track` to the LLMO capability-catalog doc block in
facs-capabilities.js — the api-service-side mirror of the MAC capability catalog
(mac-state-layer.md §"Capability catalog"). Documents that it is strictly narrower
than can_configure, enforced at RUNTIME in the serenity controller (not a route
requirement — the route stays can_configure), scoped to `brand`, and fail-safe
(unpermitted assertSource dropped → config, not 403).

The authoritative catalog lives in mysticat-architecture/.../mac-state-layer.md;
the matching entry to register there is drafted in PR #2910's description.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
aliciadriani pushed a commit that referenced this pull request Jul 27, 2026
… grants validate (SITES-47870)

createMapping / patchMapping validate every granted capability against
PRODUCTS_CAPABILITIES[product] (the machine-readable catalog, also served by
GET /product/capabilities and used by the capability picker). Without listing
`llmo/can_track` there, a Track grant would be rejected as not-in-catalog. Add it
(prototype) so the state-layer binding grant drafted in PR #2910 is acceptable
end-to-end. Still runtime-enforced in the serenity controller, not a route requirement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@aliciadriani
aliciadriani marked this pull request as ready for review July 27, 2026 15:26
@aliciadriani

Copy link
Copy Markdown
Collaborator Author

/review-pr — self-review of this prototype

Summary. Per-item source injector seam + assertSource opt-in + runtime FACS can_track gate + catalog entries + grant round-trip test (9 files, +403/−22). Well-tested, lint/docs-lint clean. One security-model issue must be settled before this could merge; the rest are should/nit.

Must Fix (before merge)

  • Fail-OPEN non-FACS bypass (serenity.js). assertSourceAllowed = !facs?.enabled || hasFacsPermission('…/can_track'). Per the brands.js precedent, facs.enabled is false not only for admin/internal/s2s but for non-FACS-enrolled orgs — so a regular IMS user in a non-enrolled org inherits the bypass and can assert an arbitrary source, crossing the "no client write surface" line (source-dimension.md §1 item 6) for those orgs. Should be fail-closed: allow only when the caller holds can_track or is an admin.

Should Fix

  • Gate and enforcement in different layers. The controller decides; the handlers honour body.assertSource. A future caller reaching handleCreatePrompts/…Subworkspace without going through createPrompts bypasses the gate. Make the handler require an explicit trusted-caller signal, not just a body field.
  • No known-vocabulary observability. A permitted caller can assert any canonicalizable source; a typo (semrsh) silently resolve-or-creates a junk producer tag. source is open (don't reject), but log when a per-item source isn't in SOURCE_VALUES.

Nits

  • serenity.js evaluates ctx.data || {} twice.
  • Missing a handler-level test: assertSource: true + an item with no source → falls back to config.

What's Good

Value-level fail-safe (unparseable/absent → config, never raw); default surface stays closed without the flag (tested in both twins); cache correctly re-keyed to (project, source); thorough symmetric tests + spec-referenced comments.


Addressing all of the above now (fail-closed, explicit handler opt-in, unknown-source warn, both nits); inline notes will follow on each fixed line.

@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 every finding from the self-review (commit 51cca38). Inline notes on each fixed line. Full suite green (15,430), lint + docs-lint clean.

// regular IMS users inherited it and could assert an arbitrary producer, crossing the
// "no client write surface" line (source-dimension.md §1 item 6) for those orgs.
const assertSourceAllowed = authInfo?.hasFacsPermission?.(trackCapability) === true
|| authInfo?.isAdmin?.() === true;

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: fail-OPEN → fail-CLOSED). assertSource is now honoured only when the caller explicitly holds <product>/can_track OR is an admin. The prior !facs?.enabled term was fail-open — a non-FACS-enrolled org's regular IMS users inherited the bypass and could assert an arbitrary producer, crossing source-dimension.md §1 item 6 for those orgs. Denied-by-default now.

Comment thread src/controllers/serenity.js Outdated
body,
log,
classifyPromptType,
{ allowAssertSource: assertSourceAllowed },

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 (should-fix: layer split). The gate result is passed to the handler as an explicit allowAssertSource option, not by rewriting body. Combined with the handler-side change, the producer surface can only be enabled via this capability-checked controller path.

// on the explicit option — not `body.assertSource` alone — means a future caller that
// reaches this handler WITHOUT going through the capability-checked controller cannot
// enable the producer surface with a bare body field.
const assertSource = options.allowAssertSource === true && body?.assertSource === true;

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 (should-fix: layer split). The opt-in now requires BOTH options.allowAssertSource (set only by the trusted controller) AND the body flag. A future caller that reaches this handler without going through the capability-checked controller cannot enable the producer surface with a bare body field.

// typo (`semrsh`) that would resolve-or-create a junk producer tag is observable
// rather than silent.
const knownProducers = /** @type {readonly string[]} */ (SOURCE_VALUES);
if (perItemSource && !knownProducers.includes(perItemSource)) {

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 (should-fix: unknown-producer observability). Logs when a per-item source is not in SOURCE_VALUES. It is NOT rejected — source is an OPEN dimension, so a genuinely-new producer must still resolve — but a typo like semrsh that would resolve-or-create a junk producer tag is now observable instead of silent.

// FIX note. When on, the item's real `source` is honoured (canonicalized, `config`
// fallback); otherwise the per-item `source` is ignored so the default surface stays
// closed (source-dimension.md §1 item 6).
const assertSource = allowAssertSource === true && body?.assertSource === true;

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 (should-fix: layer split). Subworkspace twin of the flat-handler gate — requires BOTH the trusted controller's allowAssertSource option AND the body flag.

@aliciadriani
aliciadriani requested a review from MysticatBot July 27, 2026 16:09
@aliciadriani aliciadriani self-assigned this Jul 27, 2026
Alicia Adriani and others added 9 commits July 27, 2026 18:14
…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>
…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>
…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>
…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.
…(LLMO-6282)

Addresses /review-pr Nit: the producing-system `source` branch takes an
existing `source` root as producing whenever `origin` is present, without
re-checking `childrenAreAuthorship`. Document the relied-on migration invariant
(no project carries both `origin` and a `source`-authorship root; reshape
renames in place; WP-O6 removes the path) and why no runtime guard is added
(it would regress the deliberate common-path no-extra-read optimisation). The
CLI/WP-S4 guards the equivalent split-brain on its write side.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@aliciadriani
aliciadriani force-pushed the claude/wp-s2-source branch from c8a14b0 to b9a78bb Compare July 27, 2026 16:23
…LMO-6282)

- Rename resolveClosedValueInjection -> resolveServerOwnedValueInjection (+ JSDoc)
  for parity with ensureServerOwnedValue: it serves the open `source` dimension
  too, not just the closed type/origin/intent.
- tag-tree.test: assert roots.get('source') resolves to the producing-system root
  on the origin+source split-brain path; assert values.has('source') === false in
  provisionDimensionTree (the open source dimension is not pre-provisioned).
- prompts.test: negative assertion that an update never injects `source`
  (CREATE-only, mirroring the origin non-injection assertion).
- prompts-storage.test: mapRowToPrompt returns the RAW value for the dimension-root
  shadow `source` (root-name guard), never null or `config`.

Introduced by: N/A

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@aliciadriani
aliciadriani requested a review from jjenscodee July 27, 2026 17:55
aliciadriani pushed a commit that referenced this pull request Jul 27, 2026
…n_track gate (SITES-47870)

Rebased onto the current claude/wp-s2-source (which was itself rebased onto newer
main + gained intent-classification). Re-applies the full prototype onto the evolved
base — reconciled with the `resolveClosedValueInjection` → `resolveServerOwnedValueInjection`
rename, the new intent injector chaining, and the new `env`/`writeDeadline`/`ceiling`
handler params.

Contents (previously 7 commits, squashed on rebase):
- makePromptTagInjector per-item `input.source` seam (canonicalized, config fallback,
  memoized per (project, source); warns on an unknown-but-valid producer)
- `assertSource` opt-in on POST /serenity/prompts, honoured only with BOTH the body
  flag AND the trusted controller's `allowAssertSource` option
- FAIL-CLOSED FACS gate in the controller: `<product>/can_track` OR admin
- llmo/can_track in the capability catalog doc + PRODUCTS_CAPABILITIES
- OpenAPI docs; injector/handler/controller/catalog/grant-round-trip tests

Full suite green (16,073). Draft / not for merge — see PR #2910 for the open
contract decisions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@aliciadriani
aliciadriani force-pushed the claude/serenity-source-passthrough-proto branch from 51cca38 to ac2c48c Compare July 27, 2026 18:05
@aliciadriani aliciadriani changed the title [PROTOTYPE][RFC] Serenity create-path per-item source passthrough (SITES-47870) feat(serenity): Track producer-source passthrough + can_track gate (PROTOTYPE, SITES-47870) Jul 27, 2026
Alicia Adriani and others added 2 commits July 27, 2026 20:25
…ng WP-S4 (LLMO-6282)

The rebased WP-S2 branch left three it-postgres assertions stale for the
server-owned `source` producing-system dimension:

- serenity.js: first-touch now provisions FIVE dimension roots (source
  joined category/intent/origin/type) — expect 5, not 4.
- serenity.js: a proxy-create now stamps FOUR computed dimensions
  (type, origin, source, intent) on top of the caller's tags — expect the
  created prompt to carry 6 tagIds, not 5.
- categories-prompts.js: SKIP the "Prompt-list source filter" block. It
  matches on the `source_canonical` generated column added by WP-S4
  (mysticat-data-service#826, LLMO-6284), which is unmerged and in no
  released tag; the IT data-service image pins v5.70.0 without it, so the
  filter 500s. Re-enable once #826 releases and the compose image pin is
  bumped (condition documented inline).

Unit suites already cover the source injection; this only realigns the
it-postgres shared expectations. eslint clean.
…n_track gate (SITES-47870)

Rebased onto the current claude/wp-s2-source (which was itself rebased onto newer
main + gained intent-classification). Re-applies the full prototype onto the evolved
base — reconciled with the `resolveClosedValueInjection` → `resolveServerOwnedValueInjection`
rename, the new intent injector chaining, and the new `env`/`writeDeadline`/`ceiling`
handler params.

Contents (previously 7 commits, squashed on rebase):
- makePromptTagInjector per-item `input.source` seam (canonicalized, config fallback,
  memoized per (project, source); warns on an unknown-but-valid producer)
- `assertSource` opt-in on POST /serenity/prompts, honoured only with BOTH the body
  flag AND the trusted controller's `allowAssertSource` option
- FAIL-CLOSED FACS gate in the controller: `<product>/can_track` OR admin
- llmo/can_track in the capability catalog doc + PRODUCTS_CAPABILITIES
- OpenAPI docs; injector/handler/controller/catalog/grant-round-trip tests

Full suite green (16,073). Draft / not for merge — see PR #2910 for the open
contract decisions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@aliciadriani
aliciadriani force-pushed the claude/serenity-source-passthrough-proto branch from ac2c48c to b8134a3 Compare July 27, 2026 19:04
@aliciadriani

Copy link
Copy Markdown
Collaborator Author

/review-pr iteration 1 — Suggestion (test coverage)

The capability gate is defence-in-depth: the controller drops assertSource when unpermitted, AND the handler requires options.allowAssertSource === true before honouring a per-item source. The handler side has requires the body flag too (allowAssertSource true + no body flag → config) but no test for the mirror, security-relevant case: a denied caller (allowAssertSource: false) that does set body.assertSource:true + a real source must still fall back to config. That's the assertion proving a caller reaching handleCreatePrompts with a bare body can't open the producer surface. Adding it.

(No must/should bugs found this pass — the fail-closed gate, explicit-option coupling, and unknown-producer warn from the prior review are all in place.)

…(SITES-47870)

Covers the security-relevant mirror of 'requires the body flag too': a denied
caller (allowAssertSource:false) that sets body.assertSource:true + a real per-item
source must still fall back to config — proving a caller reaching handleCreatePrompts
with a bare body cannot open the producer surface.

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.

Iteration 1 fix (the one suggestion). Inline note on the added test.

expect(result.created[0].tagIds).to.not.include(TAG_IDS.sourceSemrush);
});

it('denied caller: body opts in but allowAssertSource:false keeps source closed (config)', async () => {

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 (suggestion: test coverage). Added the denied-caller handler test: allowAssertSource:false + body.assertSource:true + a real per-item source → still config. Complements requires the body flag too (the true+no-flag case), so both halves of the AND-gate are pinned and the capability gate is proven un-bypassable at the handler boundary.

@aliciadriani

Copy link
Copy Markdown
Collaborator Author

/review-pr iteration 2 — converged. Re-reviewed after the iteration-1 fix (528a27a2): no remaining must/should fixes, nits, or suggestions. The prototype's capability gate is fail-closed + defence-in-depth, and coverage now pins both halves of the AND-gate (allowed/denied) plus the controller gate, unknown-producer warn, canonicalization, and the grant round-trip. Ending the review loop. (Still draft / not-for-merge pending the WP-S4 + MAC-catalog + Track-identity decisions noted earlier.)

@aliciadriani
aliciadriani force-pushed the claude/wp-s2-source branch from 7c01f84 to 0a1c669 Compare July 30, 2026 01:14
Base automatically changed from claude/wp-s2-source to main July 30, 2026 02:03
Resolve conflicts by rebuilding the SITES-47870 prototype's unique surface —
the FACS `can_track` / `assertSource` capability gate — on top of main's now-merged
implementations, rather than keeping the prototype's superseded foundation:

- Per-item producing `source`: keep main's LLMO-6556 mechanism (`normalizePromptInput`
  reads + validates `source` from the request body; injector uses `input.source ??
  sourceValue`) and LAYER the gate on top. `handleCreatePrompts` /
  `handleCreatePromptsSubworkspace` gain an `allowAssertSource` option; the per-item
  `source` is stripped before normalize unless BOTH the controller granted it (FACS
  `<product>/can_track` OR admin) AND the body opted in via `assertSource`.
- tag-tree.js / prompt-tags.js: take main's WP-O6 (LLMO-6280) strict `origin`
  resolution; the prototype's tolerant producing-`source` root distinctness guard is
  superseded and dropped.
- Controller: keep main's `callerId` (LLMO-6289) + quota-alert `orgId`/`brandId`
  threading; add the fail-closed capability gate that computes `allowAssertSource`.
- Tests: take main's LLMO-6556 normalize/injector unit tests wholesale (the gate is a
  handler-layer concern, so they stay green), re-add the prototype's handler + controller
  gate tests adapted to main's signatures (callerId positional, createPromptsWithMetadata).

type-check (both tiers), lint, and the affected serenity unit suites pass.

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

Copy link
Copy Markdown
Collaborator Author

⚠️ Provisioning gap: llmo/can_track can't be granted via the state-layer as this stands

While planning rollout I traced how can_track is actually evaluated vs. how it can be granted. They don't line up — flagging before anyone tries to provision it.

Finding

The gate here is JWT-only:

  • serenity.js checks authInfo.hasFacsPermission('<product>/can_track') || authInfo.isAdmin().
  • hasFacsPermission() (spacecat-shared-http-utils auth-info.js) reads only profile.facs_permissions — the JWT claim.
  • can_track is intentionally not a facsWrapper route requirement (facs-capabilities.js — "Enforced at RUNTIME in the serenity controller (hasFacsPermission), NOT as a route requirement"). So the wrapper's JWT ∪ state-layer union never runs for can_track, and the wrapper doesn't backfill authInfo.

Where the JWT comes from — confirmed in spacecat-auth-service (src/ims/login.js): facs_permissions is populated solely from MacGiver (macGiverClient.checkAllPermission({ userId, imsOrgId, namespaces:[product] })), gated by the FT_MAC_FACS_PERMISSIONS[<product>] LD flag. There are zero references to facs_access_mappings / granted_capabilities in auth-service — state-layer grants are never folded into the JWT.

Impact

can_track is catalogued (PRODUCTS_CAPABILITIES.LLMO) and therefore accepted by POST /state/access-mappings (createMapping) — which strongly implies the intended admin/UI grant path is the state layer. But a state-layer grant of can_track never reaches the JWT, and this gate never reads the state layer, so that grant silently no-ops — the caller still falls back to source=config.

As written, the gate is satisfiable only by:

  • admin (is_admin), or
  • a MacGiver namespace policy that grants llmo/can_track (→ JWT at next login), with FT_MAC_FACS_PERMISSIONS[LLMO] ON in that env.

i.e. provisioning is a MacGiver policy change, not an access-mappings call.

Two remediation options (if we want the state-layer/UI grant path to work)

Option A — make can_track a facsWrapper-evaluated capability.
Add it to the route's FACS capability handling so the wrapper performs the JWT ∪ state-layer union (as it already does for can_view/can_configure). Pro: the access-mappings grant path "just works," consistent with every other capability; admins grant via the existing UI/API. Con: it becomes a route-level gate (or a second wrapper-resolved value), a bit more than "one extra check on one body field," and needs the per-brand resource resolution the wrapper already does.

Option B — have the serenity controller consult the state layer directly.
Keep the in-controller gate but union the JWT check with a state-layer read, e.g. findFacsResourceBinding (spacecat-shared-http-utils facs-state-layer.js) for <product>/can_track on the request's brand. Pro: minimal blast radius, route requirement stays can_configure, the "additional gate on one body field" framing holds. Con: duplicates a slice of the wrapper's union logic in-controller; must match the wrapper's subject/resource resolution to avoid drift.

Either option makes POST /state/access-mappings grants of can_track actually effective. Without one of them, document that can_track is MacGiver/admin-only so no one wastes time granting it via the state layer.

Near-term (prototype)

isAdmin() already satisfies the gate, so admin sessions can smoke-test the Track flow today without any provisioning. For a non-admin pilot, a MacGiver grant in the llmo namespace is the only current lever.

…rce gate (SITES-47870, Option B)

The `assertSource` gate checked only `authInfo.hasFacsPermission` (the JWT). Because
`can_track` is an in-controller gate, not a facsWrapper route requirement, the wrapper's
JWT ∪ state-layer union never ran for it — so a `POST /state/access-mappings` grant of
`llmo/can_track` (catalog-valid, brand-scoped) silently no-opped and the caller fell back
to `source=config`. Auth-service confirmed it mints `facs_permissions` from MacGiver only
and never folds state-layer grants into the JWT.

Option B: union the in-controller JWT/admin check with a per-brand state-layer read.
- New `callerHasStateLayerCapability` (src/support/facs-identity.js) checks both subject
  scopes the wrapper unions (the caller's user-subject row + org-subject row), keyed on
  the canonical IMS org + uppercase product + brand resource. Fail-closed: any missing
  dependency or read error resolves false (the assert is dropped, never a 500).
- Keys are derived from shared resolvers (resolveCallerImsOrgIdentBare / resolveCallerUserIdent),
  extracted from state-access-mappings.js into the shared module so the READ path here and
  the WRITE path there can never drift out of key alignment; normalizeImsOrgId /
  findFacsResourceBinding are borrowed from spacecat-shared-http-utils (runtime exports; the
  bridge file stays unchecked since the pinned version's types lag, matching
  state-access-mapping-utils.js).
- Gate short-circuits on JWT/admin first (no DB read on the common path) and only reads the
  state layer when the request opted in via body.assertSource and the JWT denied.

Tests: facs-identity.test.js (both subject scopes, keying, fail-closed, guards) + serenity
controller wiring (state-layer grant honoured, short-circuit paths, no read when not opted
in). type-check + lint clean; 360 unit tests pass.

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

Copy link
Copy Markdown
Collaborator Author

Resolved: implemented Option B (58046eb8)

Following the provisioning-gap analysis above, went with Option B — union the in-controller can_track check with a per-brand FACS state-layer read, so a POST /state/access-mappings grant is now honoured (not just MacGiver-JWT/admin).

  • New callerHasStateLayerCapability (src/support/facs-identity.js) checks the two subject scopes facsWrapper unions — the caller's user-subject row and org-subject row — keyed on canonical IMS org + uppercase product + brand resource. Fail-closed: any missing dependency or read error → not granted (the assert is dropped, never a 500).
  • No key drift: the org/subject resolvers (resolveCallerImsOrgIdentBare / resolveCallerUserIdent) were extracted into the shared module and are now consumed by both this READ path and the state-access-mappings WRITE path; normalizeImsOrgId / findFacsResourceBinding are the shared http-utils ones. Verified the representations line up: resolveBrandUuid returns the same UUID as the route :brandId (== wrapper resource_id == stored resource_id), and subject = profile.sub everywhere.
  • Gate still short-circuits on JWT/admin first (no DB read on the common path); the state-layer read only runs when body.assertSource opted in and the JWT denied.

Tests: test/support/facs-identity.test.js (both scopes, keying, fail-closed, guards) + serenity controller wiring (state-layer grant honoured, short-circuits, no read when not opted in). type-check + lint clean; 360 unit tests pass.

Net effect: can_track is now grantable via the state-layer admin/UI path or MacGiver or admin — the access-mappings route is no longer a silent no-op.

…can_track path (SITES-47870)

Adds integration coverage for the producer `source` gate to the serenity PostgreSQL IT
(previously the suite only asserted the `config` default). New describe:

- admin asserts `source: semrush` → the prompt carries the semrush producer tag.
- brandManager (empty JWT facs_permissions) asserts semrush and it is HONOURED — proving
  Option B end-to-end: a seeded `facs_access_mappings` binding on BRAND_1 supplies
  `can_configure` (satisfies facsWrapper's route gate) AND `can_track` (satisfies the
  in-controller assertSource gate), both resolved purely from the state layer.
- a caller without can_track (`user`, which reaches the handler via the no-facs_permissions
  wrapper bypass) has the override DROPPED → stays `source: config`.
- can_track WITHOUT the body opt-in stays `config` (both are required).

Producer tag asserted by resolving the `source` root's children by name (roots via
`parentId=`, children via `parentId=<sourceRootId>`). Seed: a can_configure+can_track
brand binding for the brandManager persona on BRAND_1 (no can_manage_users, different
resource than the §8.3 fixtures, so the state-layer suite is unaffected).

NOTE: run against the Docker+ECR PostgreSQL + Semrush-mock harness (the it-postgres CI
job) — not runnable in this environment. eslint clean; wiring verified statically.

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

Address the round-N review nits on the assertSource IT:
- Replace the guarded `sourceChildId` +/- membership checks with a single unconditional
  `producerTagName(tagIds)` assertion — it resolves the one `source`-root child actually
  present on the created prompt and asserts its name (`semrush` / `config`). The producer
  is mutually exclusive (one source child per prompt), so this fully pins the outcome
  without conditional sub-assertions.
- Document that the JWT-carried `can_track` path is intentionally not re-exercised here
  (no IT persona carries it in the JWT; it's the same `hasFacsPermission` call the
  controller unit tests already pin) — this suite covers the admin + state-layer surfaces.

eslint clean.

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

Copy link
Copy Markdown
Collaborator Author

Closing: the can_track gate is superseded by shipped allowlist governance

This PR (PROTOTYPE, SITES-47870) gated the per-item producing source behind a can_track capability. After tracing what actually consumes source downstream, we're closing it — the gate duplicates-and-contradicts a governance model already shipped to main, on a path being deprecated, and protects no real trust boundary.

What reads source downstream

  • Prompt uniqueness key (brand_id, lower(text), sorted_regions, source) — identity/dedup.
  • LLMO usage report (get-prompt-usage.js, internal Slack) — one column per source; analytics/ops.
  • DRS / brand-presence (TRACKED_PROMPT_SOURCES) — which pipeline produced a prompt.
  • v2 prompt list (prompts-storage.js) — filter/sort + DTO display.

Nothing keys off source for billing, entitlement, quota, or auth. So source is consequential for identity + analytics, not a security/financial boundary — the condition that would justify a capability gate isn't met.

The governance already exists — and it isn't a capability gate

#2810 shipped assertPermittedSource (an allowlist write-chokepoint) on the go-forward v2 path (prompts-storage.js), and the serenity path already validates against SOURCE_VALUES. Both write paths already reject unregistered/garbage sources — which is the only thing that actually corrupts the identity key, the usage-report columns, or DRS routing.

A can_track capability adds nothing to that integrity: a holder can still pick a wrong-but-registered source; a non-holder just defaults to config. The gate blocks legitimate attribution, not corruption — and corruption is already blocked by the allowlist.

Why closing rather than merging

  1. Contradicts the shipped model. The product already chose allowlist-validated-but-ungated source (feat(prompts): source-aware upsertPrompts + reject unregistered sources #2810 on the go-forward path; feat(serenity): per-item source passthrough for SR Track flow (LLMO-6556) #2959 relaxed "server-owned" so elmo sets it). This PR re-imposes a capability gate on the legacy serenity path only, creating a two-path inconsistency for the same concept.
  2. The gated path is being deprecated ("slated for removal once every brand is migrated to sub-workspaces"; v2 is go-forward). A per-env-provisioned capability + a standing attribution-regression hazard is poor ROI on a path being deleted.
  3. The invariant it enforces is already settled the other way (feat(serenity): per-item source passthrough for SR Track flow (LLMO-6556) #2959 + feat(prompts): source-aware upsertPrompts + reject unregistered sources #2810 accept client-set registered sources).

Cleanup / no loose ends

Not a quality issue

The conflict resolution, the Option B state-layer union (callerHasStateLayerCapability), and the unit + CI-green integration tests were all sound — this is a product-scope decision, not a code problem. If a future requirement makes producer attribution a real trust boundary, callerHasStateLayerCapability (in src/support/facs-identity.js) is the reusable seam to revive.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants