Repository navigation
Organisation and permissions
Everything the Hub knows about who may do what lives in one table. This page is the reference for that table — authority_records — and for the engine that turns its rows into permissions. If you are changing anything that touches access control, read this first.
An account is a row in accounts: a person, or the technical identity of an organisation. It holds sign-in credentials, membership status and profile details — but conspicuously little authority. The role column on an account distinguishes entity kind and technical administration only; it grants no constituency power on its own. A title without a record is decoration.
The things authority can apply to are the scopes:
| Scope | Examples |
|---|---|
platform |
The constituency itself — global focal points, safeguarding officers |
body |
Governance bodies — the Council, the Secretariat, the steering group |
working_group |
Thematic working groups |
organisation |
Member and partner organisations |
negotiation_track |
UNFCCC negotiation tracks |
team |
Operational teams |
A scope is what keeps authority local. "Contact point" means nothing until you know which group, and the same person can hold entirely different records in different scopes simultaneously — contact point of one working group, ordinary member of another, liaison to a body, representative of their organisation.
An authority record is a mandate or a participation, expressed as one row:
authority_records
─────────────────────────────────────────────
id bigint
account_id bigint → accounts
kind enum 'mandate' | 'participation'
role text e.g. 'wg.contact_point', 'body.liaison'
scope_type enum 'platform' | 'body' | 'working_group' | …
scope_id bigint the row the role applies to
status enum 'active' | 'ended' | …
council_seat text resolved seat key, when the role carries one
starts_at timestamptz
ends_at timestamptz null while current
substitute boolean covering a seat held by someone else
migrated_from text provenance for backfilled rowsTwo fields carry most of the meaning. kind separates a mandate — a responsibility conferred by a governance process (election, selection, appointment) — from participation — a grant of access (joining a working group, a team enrolment). The distinction matters downstream: mandates derive from S-series policy, and several checks ask "is this a mandate" rather than "does this role exist". role is a key into a controlled vocabulary — AUTHORITY_ROLES in src/lib/authority.ts — that declares what the role is allowed to look like:
focal_point: {
label: 'Global Focal Point',
scopeTypes: ['platform'],
councilSeat: 'focal_point',
capabilities: [
'council.vote',
'constituency.coordinate',
'points.award',
'intelligence.contacts.read',
],
requiresCw: true, // requires active Constituency Work membership (S17)
},Every role in the registry cites the policy it encodes — S13 for bodies and Council, S25 for contact-point mandates, S15 for liaisons, S10 for elections. When you add a role, you add the citation with it.
Nothing in the code checks authority_records directly for a permission decision. The single entry point is deriveAuthority() in src/lib/authority.ts, which every endpoint and collection hook reaches through src/lib/access.ts. Given an account, it produces an AccessProfile:
interface AccessProfile {
teamRoles: string[] // legacy team ids
wgAssignments: { wgSlug: string; role: string }[]
negotiationAssignments: { scopeType: string; scopeId: string; role: string }[]
councilSeats: string[] // 'wg:finance', 'org:12', …
bodyScopes: string[] // platform-body scopes the account sits in
records: { … } // resolved current rows, for audit and display
unmappedRecords: number // rows whose role resolved to nothing
}Two rules in deriveAuthority are worth memorising because they define the system's temperament:
-
Deny, never widen. A row whose
rolestring matches no registry entry — say a legacyoverlordvalue migrated from an older system — resolves to nothing. It is counted inunmappedRecords, visible to operators, and grants zero capabilities. Unknown data is a signal, never a loophole. -
Scopes gate roles.
resolveRecordRoleonly resolves a registry key when the record'sscope_typeis inside that role's declaredscopeTypes. Afocal_pointrecord scoped to a working group is invalid on its face, even thoughfocal_pointis a real role — the registry says it only exists atplatformscope.
Council authority is the subtlest part of the model because the seat belongs to the scope, not the person. A working group holds a Council seat; the person holding wg.contact_point there occupies it. Two consequences follow.
First, seats are the electorate — councilElectorate in src/lib/decisionRuntime.ts counts distinct seats with an eligible holder, not accounts. A substitute covering a vacant seat does not inflate the electorate, and a holder whose Constituency Work membership has lapsed does not count.
flowchart TB
WG["Working group: Finance"] -->|holds| Seat["Council seat<br/>'wg:finance'"]
CP["Contact point<br/>authority record"] -->|occupies| Seat
Sub["Substitute record<br/>substitute = true"] -.->|covers while vacant| Seat
Seat --> Electorate["councilElectorate<br/>(counts seat once)"]
Second, substitutes are rows, not hacks. A substitute record carries substitute = true against the same seat, scoped so it can only confer the covered seat's vote — the role registry keeps substitute grants confined to what the principal's record would have provided. Granting a substitute requires locking both accounts in a fixed order, which brings us to concurrency.
All writes go through src/lib/authorityService.ts — grantAuthority, endRecord, revokeAuthority. Direct inserts into authority_records outside that module are a bug by definition. The write path serialises concurrent operations with two layers:
-
Advisory locks on every account involved, acquired ascending and deduplicated (
withAuthorityLockinsrc/lib/authorityLock.ts). The deterministic ordering is what makes deadlock impossible. - Row locks inside the transaction, then the write.
sequenceDiagram
participant A as grantAuthority()
participant L as advisory lock
participant T as transaction
participant DB
A->>L: lock accounts [7, 42] (sorted)
L->>DB: pg_advisory_xact_lock(7)
L->>DB: pg_advisory_xact_lock(42)
A->>T: begin
T->>DB: re-read + re-validate principal row
T->>DB: insert authority_records
T->>DB: commit (locks release)
The re-read inside the lock is load-bearing. The endpoint checked the record was valid before acquiring the lock; between check and lock, another request may have ended it. Re-validating inside the transaction is what makes the check meaningful — the same pattern appears in the membership-record end endpoint and in substitute grants.
A partial unique index on (account_id, role, scope_type, scope_id) WHERE status = 'active' is the database's own guarantee that an account cannot hold the same active record twice, even if two racing requests both pass validation.
Records carry starts_at / ends_at because authority in YOUNGO is genuinely time-bounded: elected mandates have terms, project roles end with the project, participation lapses. deriveAuthority only sees records inside their window, and grants that start in the future are honoured when their time comes — a record dated for next month grants nothing today.
-
Adding a role = an entry in
AUTHORITY_ROLESwith label, allowedscopeTypes, capabilities, and a policy citation. Then grant it through the service. -
Checking authority in an endpoint = call
accessProfile(req)(or the hook-level equivalent) and test capabilities/seats — never hand-joinauthority_records. -
Granting or ending =
grantAuthority/endRecordinauthorityService.ts. Let it lock; don't lock around it differently. -
Debugging "why can't X do Y" = look at their rows (
member/admin/accounts/:idshows records), then checkunmappedRecordsfor strings the registry doesn't know, then check the window dates.