Skip to content

persona Hierarchy and Scope Model

Technical Story: Defining persona (user role hierarchy) and how it interacts with store-scoped access across whisker-host and modules

WhiskerPlatform needs a documented persona model to gate pages, config, and tooling across whisker-host and modules. The working list of candidate types was:

developer, admin, corporate, globalOps, support, storeOps, coach, athlete

Two problems surfaced while trying to finalize this list:

  1. Role and scope are conflated. storeOps, coach, and athlete differ mainly by how many stores they can see (a scope question), while admin, globalOps, and corporate differ mainly by which config domains they can touch (a capability question). Encoding both in one flat enum creates pressure to mint a new type every time a scope variant appears (e.g. “storeOps for one region” vs. “storeOps for a district”).
  2. Not every type ranks cleanly against every other type. globalOps is a strict subset of admin (minus configs gated until an admin approves access). But support and corporate do not obviously outrank each other — they operate in different domains at a similar power level. Forcing one total order (developer > admin > globalOps > corporate > support > storeOps > coach > athlete) hides that support’s access is closer in kind to developer’s tooling access (logs, feature flags, incident tools) than to corporate’s store-content workflows — it’s just missing the configs that are deliberately hidden from non-engineering roles.

This ADR proposes a persona set, groups it into bands with an internal ladder per band, and separates scope (store breadth) and grants (named exceptions) as independent axes so the type list doesn’t grow every time a scope or exception variant appears.


  • Least privilege / blast radius — infra, secrets, and deploy access (today folded into developer) should not share an identity with day-to-day feature work.
  • Avoid role explosion — new store-subset variants or one-off approvals should not require a new persona.
  • Auditable exceptions — incident-driven or admin-approved access (support tooling, gated globalOps configs) should be visible, revocable grants, not silent differences between accounts of the same type.
  • Code simplicity — authorization checks in whisker-host and modules should test capabilities (e.g. "storeConfig:write"), not scattered persona === 'admin' string comparisons.
  • Org shape — the list should mirror how Nike retail actually organizes people (platform engineering vs. global business admin vs. store-local), not just accumulate types as edge cases appear.

Option A — Single flat enum (status quo proposal)

Section titled “Option A — Single flat enum (status quo proposal)”

Keep the original 8 types as one list with an implicit total order.

Option B — Role + scope as two independent fields

Section titled “Option B — Role + scope as two independent fields”

persona (capability tier) and scope (global | storeList | store) as separate fields on the user/session; no change to how many persona values exist.

Section titled “Option C — Role + scope + banded ladder + named grants (recommended)”

Same as B, plus:

  • Group persona values into bands (platform engineering / global business admin / store-local), each an internal ladder.
  • Add platformOwner as a new type, split out from developer.
  • Move support into the platform-engineering band (adjacent to developer), not ranked against corporate.
  • Represent one-off or approval-gated capabilities (globalOps’s gated configs, support’s incident-only tooling) as named grants[] / revokes[], not new types.

  • Good: simplest to reason about initially; matches the original list verbatim.
  • Bad: scope variants (region vs. district vs. single store) pressure new types; developer remains a single “god” identity covering both feature work and infra/secrets; no clean answer for where support ranks relative to corporate.
  • Good: removes most scope-driven pressure to add types (a storeOps user with 3 stores and one with 30 stores are the same persona).
  • Bad: does not resolve the developer blast-radius problem or the support/corporate/globalOps ranking ambiguity by itself.
Section titled “Option C — Banded ladder + grants (recommended)”
  • Good: resolves both problems above; keeps the type count stable (9 types) even as new scope shapes or approval workflows appear; gives incident/security review a clear, auditable place to look (grants[]) instead of hunting for role differences between two globalOps accounts.
  • Bad: more upfront modeling work than a flat enum; requires whisker-host (and any module doing its own authorization) to resolve capabilities from persona + scope + grants rather than checking a role string directly — a larger lift if authorization checks already exist as raw string comparisons.

Chosen option: Option C — banded ladder with scope and grants as separate axes.

type Scope =
| { kind: 'global' }
| { kind: 'storeList'; storeIds: string[] } // region, district, etc.
| { kind: 'store'; storeId: string };
interface UserProfile {
persona: Persona; // capability tier — fixed, small enum
scope: Scope; // how far that capability reaches
grants?: string[]; // named exceptions, e.g. "globalConfig:view"
revokes?: string[]; // narrow removals from the default bundle
}

Authorization checks (pages, config sections, tooling) should resolve a capability string (e.g. "storeConfig:write", "incidentTools:access") from persona’s default bundle, filtered by scope, adjusted by grants/revokes — never compare persona directly outside of that resolution step.

Band Types (highest → lowest) Internal relationship Default scope
Platform engineering platformOwnerdevelopersupport Each is the one above minus a gated slice, via revokes/default-off grants global
Global business admin adminglobalOpscorporate Each is the one above minus a gated slice global (nothing store-list-bound by default)
Store-local storeOpscoachathlete True nesting by scope breadth storeListstorestore

Bands are not ranked against each other — a support user is not “below” a corporate user in some global ladder; they operate in different domains. Only the ladder within a band is a strict order.

Platform engineering band (technical tooling, scope always global):

  • platformOwner — new type. Infra, secrets, deploy/release pipeline, manifest-publisher controls, kill switches. Small number of people/service identities. Superset of developer.
  • developer — full application/feature access: build, debug, feature flags, non-prod environments. No infra/secrets/deploy access — that’s platformOwner-only.
  • support — incident-response tooling: logs, diagnostics, feature-flag toggles, remediation actions needed during an incident. Explicitly not given the developer-only configs meant to stay hidden from non-engineering-owned surfaces (e.g. internal debug panels tied to active development, not incident response). That boundary is a revokes list off the developer bundle, not a separate rank versus corporate. Incident-triggered elevation beyond the default support bundle should be a time-boxed, audited grants entry — not a permanent account change.

Global business admin band (business config, scope typically global):

  • admin — full business/product configuration authority.
  • globalOps — identical to admin minus a specific set of gated global configs; unlocked per-config via an admin-issued grants entry (e.g. "globalConfig:viewBillingRules"), not by changing type.
  • corporate — cross-store visibility and upload/content workflows; cannot mutate store-facing configuration. Least-privileged in this band.

Store-local band (scope narrows with each step down):

  • storeOps — operational permissions across an assigned subset of stores (scope.kind === "storeList").
  • coach — elevated permissions, but scoped to their one store (scope.kind === "store").
  • athlete — baseline permissions to log in and view what’s needed for their store (scope.kind === "store", mostly read).

Total: 9 persona values, in 3 bands of 3.


  • developer’s blast radius shrinks — infra/secrets/deploy moves to a small platformOwner set, matching least-privilege expectations.
  • support no longer has to be squeezed into a rank versus corporate; it’s modeled as a restricted developer sibling, matching how the team actually thinks about it (tooling access, minus dev-only configs).
  • Store-subset variants (region, district, single store) never require a new persona — they’re a scope value.
  • Admin-gated config approval (globalOps) and incident-driven tooling elevation (support) both have one consistent, auditable mechanism (grants[]) instead of ad hoc type variants.
  • Authorization logic in whisker-host/modules can be tested against capability strings, decoupling page/config gating from the exact persona list — future type additions don’t require touching every call site.
  • Larger upfront implementation than a flat enum: needs a capability resolver (persona + scope + grants/revokes → capability set) rather than direct role checks.
  • Any existing prototype/code that already checks persona strings directly needs to migrate to capability checks — not yet audited as part of this ADR.
  • grants/revokes need an owner and an audit trail (who granted what, when, why) or they become as opaque as ad hoc role variants would have been — this ADR does not yet specify that storage/audit mechanism.

  1. Adopt the 9-type, 3-band list above as persona; add platformOwner as a new type distinct from developer.
  2. Implement scope as a field independent of persona, with global / storeList / store variants.
  3. Model globalOps’s gated configs and support’s incident-elevation tooling as named, auditable grants[] entries — not new types or permanent account changes.
  4. Design the grants/revokes audit trail (who approved, when, scope of grant, expiry for incident-driven grants) as a follow-up decision before this ships to production.
  5. Audit any existing whisker-host authorization code for direct persona string comparisons and migrate to capability-string checks resolved from persona + scope + grants.