feat(serenity): Track producer-source passthrough + can_track gate (PROTOTYPE, SITES-47870) - #2910
feat(serenity): Track producer-source passthrough + can_track gate (PROTOTYPE, SITES-47870)#2910aliciadriani wants to merge 17 commits into
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
This PR will trigger a minor release when merged. |
…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>
… 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
left a comment
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
| body, | ||
| log, | ||
| classifyPromptType, | ||
| { allowAssertSource: assertSourceAllowed }, |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
Fixed (should-fix: layer split). Subworkspace twin of the flat-handler gate — requires BOTH the trusted controller's allowAssertSource option AND the body flag.
…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>
c8a14b0 to
b9a78bb
Compare
…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>
…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>
51cca38 to
ac2c48c
Compare
…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>
ac2c48c to
b8134a3
Compare
|
The capability gate is defence-in-depth: the controller drops (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
left a comment
There was a problem hiding this comment.
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 () => { |
There was a problem hiding this comment.
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.
|
|
7c01f84 to
0a1c669
Compare
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>
|
…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>
Resolved: implemented Option B (
|
…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>
Closing: the
|
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 fromsemrush/gsc/citation-attempt/synthetic-personascards — so they collapse tosource = config. And/serenity/promptshas nopromptsrow for the SITES-47870 read-side treatment to derive from, so it needs a fix at the write site.Commits (3)
makePromptTagInjectorhonours a per-iteminput.sourceoverride (canonicalized,configfallback, memoized per(project, source)).POST /serenity/promptsaccepts a top-levelassertSource: trueopt-in; when set, each item'ssourceis threaded through. Without it, a bodysourceis ignored (default surface stays closed, §1 item 6).assertSourceis gated on a FACS producer capability<product>/can_track(e.g.llmo/can_track), distinct fromllmo/can_configure. Enforced in the controller viaauthInfo.hasFacsPermission; non-FACS sessions (admin/internal/s2s) bypass, mirroring the ReBAC pattern inbrands.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 +assertSourcewiring (twins).controllers/serenity.js— the capability gate (body sanitized before dispatch).docs/openapi/schemas.yaml—assertSource+ per-itemsource+ the capability note.(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)
Where does the Track flow supply the source?— per-itemsourceunderassertSource.Which principals may set— gated onassertSource?<product>/can_track. Sub-decisions remain: (a) registercan_trackin the MAC capability catalog; (b) reject-vs-ignore an unpermitted flag (this prototype ignores); (c) confirmcan_trackis the right capability name/granularity./promptsstrip vs SITES-47870 — this should match so Serenity-mode and brandalf orgs behave the same.sourcealso accept a tag id, not just a bare value?Not to be merged until the
prompts.sourcecontract (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.jsis updated in this PR. The authoritative catalog lives inmysticat-architecture/platform/decisions/mac-state-layer.md§"Capability catalog" (not in this repo) — here is the entry to register there:llmo/can_trackbrand(same as thePOST .../serenity/promptsroute)sourceon a Serenity "Track" prompt write — setassertSource: true+ a per-itemsourceonPOST /v2/orgs/:spaceCatId/brands/:brandId/serenity/prompts.can_configurecan_configure(still the route requirement) lets a caller create/edit prompts; it does not permit attributing a producing system.can_trackis checked in addition, at runtime.authInfo.hasFacsPermission('llmo/can_track')— not a route entry inPRODUCTS_ROUTES. Non-FACS sessions (admin / internal / s2s) bypass.assertSourceset without the grant is dropped (source →config) and logged; no 403.Once registered + granted,
context.attributes.authInfo.hasFacsPermission('llmo/can_track')returnstruefor Track callers and the prototype gate lights up end-to-end.Drafted state-layer binding grant (
llmo/can_track)can_trackis now listed inPRODUCTS_CAPABILITIES.LLMO(this PR) so the grant validates throughcreateMapping/patchMappingand appears inGET /product/capabilities. The binding grantscan_trackto the SR "Track" feature identity, scoped tobrand. Baselinellmo/can_viewis auto-added byensureBaselineCanView. The grant must be performed by a caller holdingllmo/can_manage_usersfor the resource.A. Via api-service (preferred) — new binding
Add to an EXISTING binding instead (set-semantics):
PATCH /state/access-mappings/:idwith{ "grantedCapabilities": ["llmo/can_configure", "llmo/can_track"] }.B. Via the set-capabilities RPC (existing mapping id)
C. Raw row (reference only — bypasses controller validation)
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.