SDODS
Guides

Role matrices

Declare what each role may do on each surface once, and generate the Scenario Outline examples from it.

What you'll learn

How roles.matrix.yaml describes an actor × surface grid, how a @matrix:<name> outline becomes one tagged Examples block per role, how to keep the generated rows in sync in CI, and why the matrix is only as trustworthy as the accounts behind its roles.

The problem

Permission suites grow as roles × surfaces. Four roles and thirty settings pages is 120 scenarios that differ only in two values, and a Scenario Outline with @user:<role> needs one Examples: block per role, because Gherkin tags attach to Examples blocks, not rows. A role matrix keeps the grid as data and generates those blocks.

Declare the matrix

projects/<slug>/roles.matrix.yaml
matrices:
  admin-surfaces:
    roles: [owner, admin, member, viewer] # must be in tags.roles
    default: denied # outcome for a role a row does not name
    outcomes: [allowed, denied] # optional: reject typos
    title: '<role> opens <surface> → <expect>' # optional
    rows:
      - surface: /settings/billing
        expect: { owner: allowed, admin: allowed } # member, viewer → denied
      - surface: /settings/profile
        expect: allowed # every role
      - surface: /settings/team
        default: allowed # row default overrides the matrix default
        expect: { viewer: denied }
FieldMeaning
matrices.<name>lowercase letters, digits and dashes; referenced as @matrix:<name>
rolesthe actors, in the order their Examples blocks are written; each must be declared under tags.roles
defaultoutcome for a role that a row's expect map does not name
outcomeswhen set, every resolved outcome must be one of these
titlethe # title-format: of each generated block; must use <role> and every row column, may use <expect>
descriptionfree text
rows[].expectone outcome for every role, or a role: outcome map
rows[].defaultoutcome for the roles this row's map leaves out
rows[].<column>any other key is an Examples column (surface → <surface>); every row has the same columns; role is reserved

Playwright refuses to load two tests with the same title, so a custom title must name <role> and every row column: rows are unique by their columns, so that makes every generated title unique. '<surface> → <expect>' is refused, because two roles that share an outcome would get the same title. Separate adjacent placeholders too; a title that still renders the same text twice is an error.

An outcome resolves as rows[].expect[role] → rows[].default → default; a role left without one is an error. Duplicate rows, unknown roles in an expect map and rows with different columns are errors too.

Write the template

Tag a Scenario Outline with @matrix:<name> and use <role>, <expect> and the row columns as placeholders. Do not add @user: to it: the generated blocks carry the role.

features/admin/surfaces.feature
@ui @admin
Feature: Admin surfaces

  @regression @matrix:admin-surfaces
  Scenario Outline: Surface access by role
    Given I navigate to "<surface>"
    Then access should be "<expect>"

Expand

sdods matrix expand -p demo-shop

The outline gains one block per role, between markers:

  @regression @matrix:admin-surfaces
  Scenario Outline: Surface access by role
    Given I navigate to "<surface>"
    Then access should be "<expect>"

    # sdods:matrix:begin admin-surfaces (generated by `sdods matrix expand` from roles.matrix.yaml; edit the matrix, not these rows)
    # title-format: <role> opens <surface> → <expect>
    @user:owner
    Examples: owner
      | role  | surface           | expect  |
      | owner | /settings/billing | allowed |
      | owner | /settings/profile | allowed |
      | owner | /settings/team    | allowed |

    # title-format: <role> opens <surface> → <expect>
    @user:viewer
    Examples: viewer
      | role   | surface           | expect  |
      | viewer | /settings/billing | denied  |
      | viewer | /settings/profile | allowed |
      | viewer | /settings/team    | denied  |
    # sdods:matrix:end admin-surfaces

Each row becomes one test titled from title (owner opens /settings/billing → allowed) and tagged @user:owner, so the pool leases an owner account and the cached storage state logs it in, exactly as for a hand-written @user:owner scenario. -t "@matrix:admin-surfaces and @user:viewer" runs one role's column.

  • Re-running with an unchanged matrix changes nothing.
  • Only the lines between the markers are rewritten. Hand-written Examples: blocks in the same outline are kept.
  • Gherkin fills a placeholder only from the Examples: block its row is in, so each block is checked on its own (matrix/placeholder): the generated blocks supply only <role>, <expect> and the matrix columns, and a hand-written block must have a column for every placeholder the outline uses. A column that only a hand-written block has would stay a literal <column> in every generated row.
  • Removing the @matrix: tag and expanding again removes the generated block.
  • The generated rows are committed like any other Gherkin, so reviews show the grid that will run.

Keep it in sync

sdods lint warns with matrix/stale when a feature's generated rows no longer match the matrix, and fails on an unknown @matrix:<name> or a matrix role missing from tags.roles. In CI, fail the build instead:

sdods matrix expand --check

An agent working through MCP calls matrix_expand, which stages the expanded features as a proposal for a person to accept rather than writing them.

The matrix does not create identities

Every generated row leases an account for its role. A role with no account in the user pool fails every one of its rows with a lease error that says nothing about the product. sdods lint and sdods matrix expand warn with matrix/unseeded-role for each matrix role that has no row in the data.userPool dataset for an environment in envs.available (file-backed datasets only; db datasets are not checked), and when the project has no user pool at all.

Seed the accounts first, then add the role to the matrix. Until a role can log in, leave it out of roles rather than generating rows that can only fail.

Using an AI coding agent? Install SDODS:

Install the SDODS plugin in Claude Code: skills, subagents, commands and the MCP server.

  1. /plugin marketplace add YarlisAISolutions/sdods-skills
  2. /plugin install sdods@sdods
Learn more about using SDODS with AI tools

On this page