Role matrices
Declare what each role may do on each surface once, and generate the Scenario Outline examples from it.
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
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 }| Field | Meaning |
|---|---|
matrices.<name> | lowercase letters, digits and dashes; referenced as @matrix:<name> |
roles | the actors, in the order their Examples blocks are written; each must be declared under tags.roles |
default | outcome for a role that a row's expect map does not name |
outcomes | when set, every resolved outcome must be one of these |
title | the # title-format: of each generated block; must use <role> and every row column, may use <expect> |
description | free text |
rows[].expect | one outcome for every role, or a role: outcome map |
rows[].default | outcome 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.
@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-shopThe 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-surfacesEach 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 --checkAn 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.