ADR-090: Governance Identity Model — one source for role-holding, and the layer glossary
Date: 2026-06-29
Status: Accepted (built + live — 2026-06-30)
Context
Section titled “Context”The workforce-governance model has grown several first-class git-native objects — Team, WorkforceRole,
Person, Activation (ADR-063/067/084/088) — alongside the tenant/app model (Product, Environment,
Release, AccessGrant). A coherence review surfaced two rough edges:
-
A real redundancy. Governance roles —
team-adminandrelease-approver— are modeled in two places: as aWorkforceRoleheld via aPersongrant, and as a hand-authored GitHub-login list onTeam.roles.{teamAdmin, releaseApprover}(andProduct.spec.roles.releaseApprover). The same fact — “who administers / can approve for team X” — is authored twice, in two formats, that can drift. This violates the north-star principle (decide once, project everywhere). Notably, ADR-068 already intendedrelease-approverto be “a generator-managed role (git/Keycloak = source of truth), projected into CODEOWNERS / a required-reviewer set” — but #501 implemented it as the hand-authoredTeam.rolesfield, so the implementation drifted from the intent. -
Naming overload. “role” names two things (
WorkforceRolevsTeam.roles); “grant” names two things (Person.grantsvs theAccessGrantCRD). The things are genuinely distinct, but the shared words make the model harder to hold in one’s head.
The redundancy is currently latent: Team.roles is populated and drives the live PR gate, but no one holds
team-admin/release-approver via a Person grant yet. So we can set the single home before there is
double-authored data to reconcile — much cheaper now than later.
Decision
Section titled “Decision”D1 — Name the four layers. The platform’s git-native objects fall into four distinct layers; conflating them is the root of the confusion:
| Layer | Objects | Nature |
|---|---|---|
| Governance / identity registry | Team, WorkforceRole, Person |
slow, platform-authored — “who, and what’s allowed” |
| Runtime / operational | Activation |
controller-owned — “what’s happening now” |
| Tenant / app model | Product, Environment (XEnvironment), Release |
the deployment units |
| Machine / service access | AccessGrant, XAgent |
a separate plane — workload-to-workload, not human |
D2 — The Person is the single source of truth for who-holds-which-role-at-which-reach. ALL role-holding —
including governance roles (team-admin, release-approver) — is authored once, as a Person grant
(role × reach). Governance roles are WorkforceRoles like any other; they merely project to more surfaces (a
governance role projects to both IC/Keycloak access and the PR-approval gate).
D3 — Team/Product approver designations are DERIVED, not authored. Team.roles.{teamAdmin, releaseApprover} and Product.spec.roles.releaseApprover become a generated projection of Person grants
— the People holding that role for the team/product → their spec.handles.github → the CODEOWNERS /
required-reviewer set the gated-prod gate enforces. This realizes ADR-068’s stated “generator-managed role,
projected” intent and revises ADR-063’s hand-authored Team.roles.
Retiring the authored field removes the redundancy and the “role” naming overload — with Team.roles gone,
“role” means WorkforceRole, full stop.
D4 — Keep AccessGrant; resolve “grant” with the glossary, not a rename. Person.grants (human → role) and
the AccessGrant CRD (service → cross-team product) sit on different planes (workforce vs machine, per
ADR-074 / the identity strategy) — genuinely distinct, not redundant. AccessGrant is a built, live CRD
(ADR-068); renaming it is not worth the churn. The distinction is carried by the glossary (D5). (If the clash
keeps biting, the eventual clean rename is AccessGrant → a service-flavored name, since it is the outlier — it
is about products, not workforce — but that is deferred.)
D5 — Glossary (the precise meaning of each term).
| Term | Means | Not to be confused with |
|---|---|---|
| WorkforceRole | a workforce access role a person can hold (developer, break-glass, team-admin, …) | Team.roles (retired) |
| Person grant | one (WorkforceRole × reach) a person holds; the single source for role-holding |
AccessGrant |
| reach | a grant’s scope — exactly one of a Team or platform-wide |
— |
| Activation | a live, time-boxed borrow of a person’s on-demand role grant | a standing grant |
| AccessGrant | cross-team service/product access (team A’s workload → team B’s product) | a Person grant |
| Team envelope | the limits a team may deploy within (Team.spec.envelope) |
a person’s access |
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- One source for role-holding — no silent drift between
Team.rolesand Person grants. - Aligns the implementation with ADR-068’s intent (release-approver as a projected, generator-managed role).
- Clearer model: “role” means exactly one thing; the four layers are named; the glossary settles “grant.”
- Fixing it now (while latent) avoids a later reconcile of double-authored data.
Negative / Risks
Section titled “Negative / Risks”- Touches the live gated-prod approval gate (#501 / ADR-068). The migration must be sequenced (below); a careless change could block or mis-authorize a prod promotion.
- The derived approver set is eventually consistent (a generation/sync step) rather than a flat hand-edited list — a small added moving part, mitigated by the generator + a validation that the projection is fresh.
Migration (sequence — do not big-bang the live gate)
Section titled “Migration (sequence — do not big-bang the live gate)”- Author the Person grants for whoever is in
Team.roles/Product.rolestoday (e.g. give the platform team-lead arelease-approvergrant on the platform team). - Switch the gated-prod gate to DERIVE approvers: People holding
release-approverfor the team/product →handles.github→ the required-reviewer set (the gate already reads the gitops registries). - Drop the hand-authored
Team.roles/Product.spec.roles.releaseApproverfields.
Alternatives considered
Section titled “Alternatives considered”- Keep both homes + a CI drift-check. Rejected — still two sources of truth; a drift-check papers over the incoherence rather than removing it.
- Make
Team.rolesthe source; removeteam-admin/release-approverfrom theWorkforceRolecatalog. Rejected — those are access roles (they project to IC/Keycloak), so they belong in the catalog; and “some roles live on the Team, others on the Person” splits the model in a worse way. - Rename
AccessGrantnow. Deferred — it is a live CRD (ADR-068); the glossary resolves the clarity cost without the rename churn.
Implementation notes (as-built)
Section titled “Implementation notes (as-built)”Built + live (2026-06-30) as the migration sequence prescribed, in three reviewed PRs — no big-bang of the live prod-promotion gate:
- #1031 (step 1a) — added
release-approverto theWorkforceRolecatalog (gitops/roles). It’s the first gate-only role:reach: team, noidentityCenter/keycloakprojection (those blocks are optional in the schema). The build confirmedteam-adminrole-holding was already Person-only, so the redundancy was specificallyreleaseApprover. - #1032 (step 1b) — authored josh’s
release-approvergrants for platform/alpha/bravo, mirroring exactly the hand-authoredTeam.roles.releaseApprover(=gangster) so the derived set is identical. - #1033 (step 2) — rewired the gitops-gate’s
require_release_approverto derive the approver set from Person grants (role: release-approverat the team’s reach →spec.handles.github, case-insensitive, author-excluded, fail-closed, pci/hipaa ⇒ ≥2). - #1034 (step 3, this) — dropped
spec.rolesfrom the 3 Team CRs, the field validation (teams-gate + validate-products), the CRD schema field (Team/Productspec.roles), and the tests. With the field gone, “role” meansWorkforceRole, full stop.
Build-forced refinements / gotchas (worth carrying forward):
- The people-gate validates each grant’s role against the BASE catalog, so a role and a grant of it cannot land in one PR — step 1 had to be split (role first, then grants).
SOLO_MAINTAINERself-attest is the path that lets the single admin merge these governance PRs (the gate waives separation-of-duties when the admin author self-attests — GitHub won’t let you approve your own PR).- The per-Product approver override was retired, not migrated — Person-grant
reachis team-or-platform, with no product reach, and no Product used the override. A product-reach extension is deferred. - The CRD schema field removal ships via the crossplane module (a
terragrunt applyofcrossplane+policy), separate from the gitops data sync; the field is optional so existing CRs prune it on next write.
Related
Section titled “Related”- ADR-063 (revises its hand-authored
Team.roles), ADR-068 (realizes its release-approver projection intent), ADR-067 (the domain model), ADR-074 (workforce vs machine plane), ADR-088 (Activation = the runtime borrow), ADR-089 (where these registries are projected). docs/architecture/identity-and-access-strategy.md(the north-star this makes coherent).