Feature: Support multiple groupsets in /featureflag requests.
Summary
Allow a client to request feature flag evaluations for multiple groups in a single call, each keyed by a client-supplied groupsetId, instead of requiring one request per group — while still resolving to a single "main" flag set for the common case of checking one flag for the user as a whole.
Use Case
As a client, I want to give UpGrade a list of schoolIds associated with my user for a given app-context, and get back which schools qualify for a feature — in the same request as checking a flag for the user overall, since both are typically needed together on page load.
The direct use-case comes from the requirements listed here: https://carnegielearning.atlassian.net/browse/RPT-8216
Which boil down to: "feature set may vary per school as user navigates....no additional fetch on each navigation" is where i read an assumption of this ability we don't yet support, multiple "feature-sets" w/o multiple fetches.
Problem
/featureflag responses currently return one set of flags for the entire request. The groups provided (groupsForSession) are run through inclusion/exclusion logic per active flag as a single set — not evaluated individually per group member.
Example
Suppose the ANALYTICS_HUB context contains two flags, SHOW_PANEL and SHOW_SCHOOL_LEVEL_FEATURE. Everyone should see the main panel, but the "school_level_feature" is only rolling out to schoolId_01 and schoolId_02.
| Flag |
Inclusion List |
Exclusion List |
SHOW_PANEL |
everyone |
nobody |
SHOW_SCHOOL_LEVEL_FEATURE |
[schoolId_01, schoolId_02] |
everyone else — user must be associated with schoolId_01 or schoolId_02 |
Request:
{
"context": "ANALYTICS_HUB",
"groupsForSession": { "schoolId": ["schoolId_01", "schoolId_02", "schoolId_03"] },
"includeStoredUserGroups": false
}
Response:
Because schoolId_03 is part of the group set, the whole set is evaluated as "excluded" for SHOW_SCHOOL_LEVEL_FEATURE — no flag string is returned, even though schoolId_01 and schoolId_02 should qualify.
Consequence: to get individual schoolId-level results, a client must currently send multiple separate requests — one per schoolId.
Proposed Solution
This was prototyped end-to-end (backend + JS client library + a small Angular playground app) and demoed in refinement, where this approach was agreed as the direction to build. The design settled on two explicit, mutually exclusive request shapes rather than one shape that's always a list — see Design Notes for why.
useSingleGroupSet — unchanged behavior, new name
The existing single-groupset behavior (today's groupsForSession/includeStoredUserGroups), just renamed for symmetry with the new multi-groupset shape below. Response is a flat string[], exactly as today.
{
"context": "ANALYTICS_HUB",
"useSingleGroupSet": {
"groups": { "schoolId": ["schoolId_01"] },
"includeStoredUserGroups": false
}
}
["SHOW_PANEL", "SHOW_SCHOOL_LEVEL_FEATURE"]
useMultipleGroupSets — the new capability
An optional mainGroupset (the "whole user" evaluation — everything the caller passed in, evaluated as one set, exactly like useSingleGroupSet would) plus one or more subGroupsets, each a fully independent evaluation keyed by a caller-supplied groupsetId. All of it is resolved server-side in one request/one DB round-trip per user.
Request:
{
"context": "ANALYTICS_HUB",
"useMultipleGroupSets": {
"mainGroupset": {
"groups": { "schoolId": ["schoolId_01", "schoolId_02", "schoolId_03"] }
},
"subGroupsets": [
{ "groupsetId": "schoolId_01", "groups": { "schoolId": ["schoolId_01"] } },
{ "groupsetId": "schoolId_02", "groups": { "schoolId": ["schoolId_02"] } },
{ "groupsetId": "schoolId_03", "groups": { "schoolId": ["schoolId_03"] } }
]
}
}
Response (shape changes only when useMultipleGroupSets is used — see below):
{
"mainGroupset": ["SHOW_PANEL"],
"subGroupsets": {
"schoolId_01": ["SHOW_PANEL", "SHOW_SCHOOL_LEVEL_FEATURE"],
"schoolId_02": ["SHOW_PANEL", "SHOW_SCHOOL_LEVEL_FEATURE"],
"schoolId_03": ["SHOW_PANEL"]
}
}
mainGroupset is optional — a request can supply only subGroupsets if there's nothing meaningful to evaluate for "the user as a whole."
Response contract
- No
useMultipleGroupSets in the request → flat string[], same as today. This includes useSingleGroupSet requests and the deprecated groupsForSession shape.
useMultipleGroupSets in the request → { mainGroupset?: string[]; subGroupsets: Record<string, string[]> }.
Validation rules
useSingleGroupSet and useMultipleGroupSets are mutually exclusive on a single request.
useMultipleGroupSets.subGroupsets must contain at least one entry; each entry requires an explicit groupsetId (no auto-generation) and its own groups; groupsetIds must be unique within the request; a capped max entry count applies.
Client Library
interface ISingleGroupSetOptions {
groups: Record<string, string[]>;
includeStoredUserGroups?: boolean; // optional, defaults to false
}
interface ISubGroupSetOptions extends ISingleGroupSetOptions {
groupsetId: string; // required — caller always supplies it, backend doesn't do anything with this, it is purely for the caller to know how to retrieve the feature flags for this specific groupset. recommended to just use the groupId or a composite of concatenated groupIds themselves as the keys.
}
interface IMultipleGroupSetsOptions {
mainGroupset?: ISingleGroupSetOptions; // optional — the hasFeatureFlag(key)-with-no-id default
subGroupsets: ISubGroupSetOptions[]; // required, at least one entry
}
Configure (optional — set up ahead of time, or pass ad-hoc per call)
new UpgradeClient(userId, hostUrl, context, {
featureFlagGroupOptions: {
useMultipleGroupSets: {
mainGroupset: { groups: { schoolId: ['schoolId_01', 'schoolId_02', 'schoolId_03'] } },
subGroupsets: [
{ groupsetId: 'schoolId_01', groups: { schoolId: ['schoolId_01'] } },
{ groupsetId: 'schoolId_02', groups: { schoolId: ['schoolId_02'] } },
{ groupsetId: 'schoolId_03', groups: { schoolId: ['schoolId_03'] } },
],
},
},
});
Fetch — one call, one network request, regardless of how many groupsets are configured:
await upgradeClient.getAllFeatureFlags();
Retrieve — reads straight from the cache the call above populated, no further network calls:
await upgradeClient.hasFeatureFlag('SHOW_PANEL); // resolves to mainGroupset
await upgradeClient.hasFeatureFlag('SHOW_SCHOOL_LEVEL_FEATURE', 'schoolId_01'); // resolves to that named subGroupset
If useMultipleGroupSets is configured without a mainGroupset, hasFeatureFlag(key) with no id throws (nothing to default to) — pass a groupsetId explicitly instead. This matches today's behavior for an ambiguous/unconfigured lookup.
Design Notes: why two shapes
An earlier pass at this considered always accepting a list of groupsets (no special-cased "main" one). That fails a very common real case: checking one flag "for the user as a whole" alongside other flags scoped to specific groups. With no privileged entry, hasFeatureFlag(key) with no id becomes ambiguous, which either forces every caller to invent an id for "the whole user" case or forces a second network round trip just to resolve it. Making mainGroupset a first-class, optional part of useMultipleGroupSets solves this directly, and keeping useSingleGroupSet as a separate, simpler shape (rather than "a list with one item") keeps the common case as simple as it is today.
Backward Compatibility
The deprecated request/library surface (groupsForSession + includeStoredUserGroups at the request's top level; featureFlagUserGroupsForSession / setFeatureFlagUserGroupsForSession in the JS client library) continues to work unchanged, translating internally to useSingleGroupSet. Existing integrations require no changes.
Out of Scope
The Java and Python client libraries are not part of this iteration — they only ever send the deprecated flat request shape today, which is unaffected by this change (the response for that shape stays a flat string[]). Extending useSingleGroupSet/useMultipleGroupSets support to those libraries can be tracked as separate follow-up work if/when needed.
Acceptance Criteria
Feature: Support multiple groupsets in
/featureflagrequests.Summary
Allow a client to request feature flag evaluations for multiple groups in a single call, each keyed by a client-supplied
groupsetId, instead of requiring one request per group — while still resolving to a single "main" flag set for the common case of checking one flag for the user as a whole.Use Case
As a client, I want to give UpGrade a list of
schoolIds associated with my user for a given app-context, and get back which schools qualify for a feature — in the same request as checking a flag for the user overall, since both are typically needed together on page load.The direct use-case comes from the requirements listed here: https://carnegielearning.atlassian.net/browse/RPT-8216
Which boil down to: "feature set may vary per school as user navigates....no additional fetch on each navigation" is where i read an assumption of this ability we don't yet support, multiple "feature-sets" w/o multiple fetches.
Problem
/featureflagresponses currently return one set of flags for the entire request. The groups provided (groupsForSession) are run through inclusion/exclusion logic per active flag as a single set — not evaluated individually per group member.Example
Suppose the
ANALYTICS_HUBcontext contains two flags,SHOW_PANELandSHOW_SCHOOL_LEVEL_FEATURE. Everyone should see the main panel, but the "school_level_feature" is only rolling out toschoolId_01andschoolId_02.SHOW_PANELSHOW_SCHOOL_LEVEL_FEATURE[schoolId_01, schoolId_02]schoolId_01orschoolId_02Request:
{ "context": "ANALYTICS_HUB", "groupsForSession": { "schoolId": ["schoolId_01", "schoolId_02", "schoolId_03"] }, "includeStoredUserGroups": false }Response:
["SHOW_PANEL"]Because
schoolId_03is part of the group set, the whole set is evaluated as "excluded" forSHOW_SCHOOL_LEVEL_FEATURE— no flag string is returned, even thoughschoolId_01andschoolId_02should qualify.Consequence: to get individual
schoolId-level results, a client must currently send multiple separate requests — one perschoolId.Proposed Solution
This was prototyped end-to-end (backend + JS client library + a small Angular playground app) and demoed in refinement, where this approach was agreed as the direction to build. The design settled on two explicit, mutually exclusive request shapes rather than one shape that's always a list — see Design Notes for why.
useSingleGroupSet— unchanged behavior, new nameThe existing single-groupset behavior (today's
groupsForSession/includeStoredUserGroups), just renamed for symmetry with the new multi-groupset shape below. Response is a flatstring[], exactly as today.{ "context": "ANALYTICS_HUB", "useSingleGroupSet": { "groups": { "schoolId": ["schoolId_01"] }, "includeStoredUserGroups": false } }useMultipleGroupSets— the new capabilityAn optional
mainGroupset(the "whole user" evaluation — everything the caller passed in, evaluated as one set, exactly likeuseSingleGroupSetwould) plus one or moresubGroupsets, each a fully independent evaluation keyed by a caller-suppliedgroupsetId. All of it is resolved server-side in one request/one DB round-trip per user.Request:
{ "context": "ANALYTICS_HUB", "useMultipleGroupSets": { "mainGroupset": { "groups": { "schoolId": ["schoolId_01", "schoolId_02", "schoolId_03"] } }, "subGroupsets": [ { "groupsetId": "schoolId_01", "groups": { "schoolId": ["schoolId_01"] } }, { "groupsetId": "schoolId_02", "groups": { "schoolId": ["schoolId_02"] } }, { "groupsetId": "schoolId_03", "groups": { "schoolId": ["schoolId_03"] } } ] } }Response (shape changes only when
useMultipleGroupSetsis used — see below):{ "mainGroupset": ["SHOW_PANEL"], "subGroupsets": { "schoolId_01": ["SHOW_PANEL", "SHOW_SCHOOL_LEVEL_FEATURE"], "schoolId_02": ["SHOW_PANEL", "SHOW_SCHOOL_LEVEL_FEATURE"], "schoolId_03": ["SHOW_PANEL"] } }mainGroupsetis optional — a request can supply onlysubGroupsetsif there's nothing meaningful to evaluate for "the user as a whole."Response contract
useMultipleGroupSetsin the request → flatstring[], same as today. This includesuseSingleGroupSetrequests and the deprecatedgroupsForSessionshape.useMultipleGroupSetsin the request →{ mainGroupset?: string[]; subGroupsets: Record<string, string[]> }.Validation rules
useSingleGroupSetanduseMultipleGroupSetsare mutually exclusive on a single request.useMultipleGroupSets.subGroupsetsmust contain at least one entry; each entry requires an explicitgroupsetId(no auto-generation) and its owngroups;groupsetIds must be unique within the request; a capped max entry count applies.Client Library
Configure (optional — set up ahead of time, or pass ad-hoc per call)
Fetch — one call, one network request, regardless of how many groupsets are configured:
Retrieve — reads straight from the cache the call above populated, no further network calls:
If
useMultipleGroupSetsis configured without amainGroupset,hasFeatureFlag(key)with no id throws (nothing to default to) — pass agroupsetIdexplicitly instead. This matches today's behavior for an ambiguous/unconfigured lookup.Design Notes: why two shapes
An earlier pass at this considered always accepting a list of groupsets (no special-cased "main" one). That fails a very common real case: checking one flag "for the user as a whole" alongside other flags scoped to specific groups. With no privileged entry,
hasFeatureFlag(key)with no id becomes ambiguous, which either forces every caller to invent an id for "the whole user" case or forces a second network round trip just to resolve it. MakingmainGroupseta first-class, optional part ofuseMultipleGroupSetssolves this directly, and keepinguseSingleGroupSetas a separate, simpler shape (rather than "a list with one item") keeps the common case as simple as it is today.Backward Compatibility
The deprecated request/library surface (
groupsForSession+includeStoredUserGroupsat the request's top level;featureFlagUserGroupsForSession/setFeatureFlagUserGroupsForSessionin the JS client library) continues to work unchanged, translating internally touseSingleGroupSet. Existing integrations require no changes.Out of Scope
The Java and Python client libraries are not part of this iteration — they only ever send the deprecated flat request shape today, which is unaffected by this change (the response for that shape stays a flat
string[]). ExtendinguseSingleGroupSet/useMultipleGroupSetssupport to those libraries can be tracked as separate follow-up work if/when needed.Acceptance Criteria
/featureflagacceptsuseSingleGroupSetanduseMultipleGroupSets(mutually exclusive), alongside the still-supported deprecated top-level fields.useMultipleGroupSets.subGroupsetsrequires ≥1 entry, each with a required uniquegroupsetId.string[]unlessuseMultipleGroupSetswas used, in which case{ mainGroupset?: string[]; subGroupsets: Record<string, string[]> }.useMultipleGroupSetsrequest are resolved in a single request/DB round-trip per user (not N sequential lookups).featureFlagGroupOptions.useSingleGroupSet/.useMultipleGroupSets,getAllFeatureFlags(), andhasFeatureFlag(key, groupsetId?)behave per the rules above.groupsForSession/includeStoredUserGroups/featureFlagUserGroupsForSessioncontinue to work unchanged.