The four authority paths of docs/website/TEAMS.md §2.5, and the rule that they
stay four: four tables answering four questions, and no resolver reads another
path's table.
1. Is this account a member? module team_members
2. Does this account lead the Team? module team_members.is_leader + override
3. May it use the Team forum? CORE team_forum_grants OR path 1
4. May it get external access? CORE derived, nothing of its own
The temptation this resists is collapsing 1 and 3 into one boolean. They answer
different questions about different populations: a forum grant may name any
Runic Gateway account, including one with no game identity at all -- that is the
point of it, since letting an unlinked guildmate into a forum must not require a
staff ticket. Reading "has forum access" as "is a member" would put that person
on the public roster, into every membership count, and into the external-platform
grant, which is where a modelling preference becomes an impersonation risk.
Path 4 is deliberately blind to path 3, and the reason is written down so nobody
"fixes" it: an integration cannot verify that an unlinked, forum-granted account
corresponds to a real game member, so it must not hand that account a privilege
on a platform where impersonation has consequences. A forum is a room on the
operator's own site with a known moderator; a Discord role is an identity claim
in someone else's space.
Leadership overrides are applied ON TOP of the synced value at read time, never
written into the projection. The sync owns that column and rewrites it every
interval, so an override stored there would be undone fifteen minutes after
staff set it -- which is the whole reason §2.5.1 is a separate table. The roster
carries both the resolved answer and `is_leader_synced`, so an admin sees that a
decision was made rather than being shown it as fact.
Three tests are named INVARIANT rather than for behaviour, because what they
protect is structural and a reasonable-looking refactor destroys it silently: a
grant never writes the membership projection, a granted user is absent from the
roster, and a grant does not confer external eligibility. None of those failures
appears on a screen as a bug -- the first shows up as a stranger on a public
roster, the second as a Discord role handed to an account nobody can tie to a
real player.
Every unit test here stubs the db layer, so the SQL itself was verified
separately: all 44 statements across teams.db.js and teamAccess.db.js were run
against MariaDB 11 with a throwaway module id and cleaned up after. That run
also confirmed live what the reconciler's tests could only assert against a
stub -- an upsert does not overwrite is_leader, a revoked grant frees the unique
key for a new one while the ledger keeps both, and an archived team stays
resolvable at its old slug while its external_id is free for the successor row.
19 tests. Full suite 828 passed, 0 failed.
Refs docs/website/TEAMS.md §2.5, §2.5.1, §2.6, Part 12 phase 2
Co-Authored-By: Claude <noreply@anthropic.com>