Skip to content

feat(027): decompose Global Admin into 13 purpose-specific platform roles — Slice A (additive) - #6322

Draft
hero101 wants to merge 43 commits into
developfrom
feat/027-platform-role-redesign
Draft

feat(027): decompose Global Admin into 13 purpose-specific platform roles — Slice A (additive)#6322
hero101 wants to merge 43 commits into
developfrom
feat/027-platform-role-redesign

Conversation

@hero101

@hero101 hero101 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Implements the server slice of workspace#027-platform-role-redesign. Story #4764, Epic #6320, ADR 0007.

⚠️ Do not merge yet. Adversarial review ended at security-hold with 22 residual findings open, and four human gates are unstarted. See Status below. Opened as a draft so the work is reviewable and durable, not because it is ready.

What this does

Decomposes global-admin into 13 single-purpose global roles across two families — 10 Platform … (assignable only by Platform Roles Admin) and 3 Feature … (also assignable by Platform Users Admin) — with per-family privileges replacing the PLATFORM_ADMIN catch-all and the root CRUD+GRANT god mode.

Also lands: an assignment rule engine (6 rules, shared with bootstrap seeding), 4 new audit categories with per-category fail-open/fail-closed semantics, and a build-failing surface census (~76 administrative surfaces) whose declared intent must equal derived reachability.

Slice A is strictly additive

Legacy broad grants still work. Every new role reaches what its legacy counterpart reaches; nothing is removed. Removal is Slice B, a hard break shipped as one coordinated release across server + client-web + test-suites, and it is gated on the FR-013b break-glass recovery drill.

That means denial is not testable here — only positive reachability is meaningful at Slice A, by design.

Verification

Track Result
server unit (test:ci:no:coverage) ✅ 679/685, 0 failed
schema additivity ✅ 0 breaking changes
test-suites canonical role×action matrix 24 pass / 3 fail — zero product bugs (2 env: local service absent + Wingback disabled; 1 known test-helper defect)
test-suites test:roleset ✅ 165/168
acceptance US2 / US3 / US4 ✅ all pass, real UI evidence + screenshots, 3 durable Playwright specs emitted

Two integration specs that had never once reached their assertions (immediacy.it-spec.ts, flows/org-inheritance-demotion.it-spec.ts) now pass 3/3 — they were self-poisoning on fixture displayName collisions, fixed in the test-suites PR.

Status — why this is a draft

Five adversarial review rounds: 141 findings confirmed, 135 fixed. Round 5 left 22 residual (5 high, 20 medium) with no fix round remaining, and the security dimension returned fail for server and client-web.

Open human gates — none of these are automatable:

  • Security-owner sign-off on the god-mode surface inventory
  • Capability-withdrawal sign-off (some roles deliberately lose access they hold today)
  • FR-004 scope-narrowing sign-off
  • QA acceptance sign-off

Known and deliberate: R4 (migration safety) reports unmitigatedmigration:validate does snapshot/restore against a shared reference DB and was not run.

Companion PRs

  • client-web — admin UI: roles filtered by assigner capability, server rejections surfaced
  • test-suites — A-row × surface matrix, rule-engine specs, stateful flows

Separately: #6321 fixes a pre-existing develop crash this feature's verification surfaced.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HZSiMjuPXnj2Un3pd8WKC3

hero101 and others added 30 commits July 27, 2026 23:06
…le engine

Slice A [server], Phase 1-2 + start of Phase 4 (US3):
- role.name.ts / authorization.credential.ts / authorization.credential.global.ts:
  +12 target role identifiers (9 platform-*, 3 feature-*), identical strings
  per role (research D2).
- authorization.privilege.ts: +11 new privileges (T007).
- ROLE_CREDENTIAL_MAP (platform.roles.access.service.ts): single canonical
  role->credential map replacing the local switch; RoleSetService now
  resolves credential TYPE through it rather than trusting a stored row's
  credential.type verbatim, closing the C1 silent-void defect class
  structurally (global-spaces-reader / global-community-reader).
- role.credential.map.spec.ts: FR-011/SC-008 anti-drift guard, asserting the
  map REPAIRS the two known defect rows (diverges from their wrong stored
  value) and agrees with every healthy row.
- Shared seed-definitions module + seed migration (T012) + idempotent
  AddPlatformRolesRedesign migration (T013) for the 12 target role rows,
  correct per-role actor-policy triples (platform-* orgPolicy {0,0},
  feature-* {0,-1}, platform-roles-admin userPolicy {1,-1}).
- Audit foundation (T014-T026): subjectUserId nullable + subjectOrganizationId
  (AlterPlatformAuditEntrySubject, the one budgeted table change), 4 new
  audit categories/outcomes/initiator-roles (enum migrations + TS mirrors),
  4 typed details shapes, 4 new audit writer services (role-assignment
  fail-closed/fail-open dual semantics; user-record, configuration, resource
  fail-open), PlatformOperationsAuditService placeholder retirement (T025,
  edits already-delivered 032 code + its spec).
- PlatformRoleAssignmentRulesService: the five assignment rules (research D7),
  evaluated in order, first failure wins; contract error strings.
- resolveInitiatorRole (T058a, FR-025): shared attribution helper, empty
  intersection falls back to platform_admin/system per the eighth
  clarification pass's carve-out, throws on a genuine defect.
- platform.role.resolver.mutations.ts: wires the rule engine + fail-closed
  audit into assignPlatformRoleToUser/removePlatformRoleFromUser for the
  NEW role families only (legacy roles keep their exact prior gating —
  Slice A additive invariant); adds assignPlatformRoleToOrganization /
  removePlatformRoleFromOrganization (T032a).
- A19 site 1 (audit-log-analyze MCP tool) + sites 2/3 (email-change audit
  GraphQL fields) re-anchored PLATFORM_ADMIN -> PLATFORM_AUDIT_READ (T050/T050a).

typecheck:native clean; full test:ci:no:coverage green (7339 passed).

workspace#027-platform-role-redesign
Slice A [server], continuing Phase 4 (US3) from the resumed session:
- Root policy (T036): platform-content-full-access READ + its own
  privilege, cascading alongside (not replacing) the global-admin god-mode
  rule. CREATE/UPDATE/DELETE and UPDATE_NAMEID deliberately absent per the
  eleventh analyze pass (would satisfy the owner branch of every dual-path
  gate this feature adds).
- Platform policy (T034, T035): GRANT_GLOBAL_ADMINS widened to
  platform-roles-admin; new FEATURE_ROLE_ASSIGN / PLATFORM_ROLE_HOLDERS_READ
  / FEATURE_ROLE_HOLDERS_READ rules; PLATFORM_AUDIT_READ, SET_SERVICE_PROFILE,
  PLATFORM_FORUM_MANAGE added; PLATFORM_SETTINGS_ADMIN / CREATE_ORGANIZATION /
  ACCESS_VIRTUAL_ASSISTANT re-anchored additively.
- Organization policy (T039, T041): DELETE_ORGANIZATION dual-gated
  alongside plain DELETE at registration.resolver.mutations.ts — the A6
  create/delete split, keeping feature-organization-creator off the delete
  half.
- Account policy (T037): PLATFORM_SUPPORT_ORG_RESOURCES (new, A7),
  ACCOUNT_LICENSE_MANAGE + TRANSFER_RESOURCE_OFFER/_ACCEPT extended.
- Space platform-roles-access (T038, T049 partial): platform-spaces-reader
  (A16) and platform-support's in-space privilege set (A15), gated on the
  same allowPlatformSupportAsAdmin flag as legacy global-support.
- Callout/post/callout-contribution (T038): MOVE_POST/MOVE_CONTRIBUTION
  extended with platform-resource-admin; UPDATE_CALLOUT_PUBLISHER
  re-anchored onto platform-content-full-access.
- User policy (T060 partial): PLATFORM_USERS_ADMIN grant added additively
  (narrowing READ_USER_PII/READ_USER_SETTINGS deferred to Slice B per
  inline comment — narrowing a legacy grant is forbidden in Slice A).
- Licensing framework (T040, T040a): plan-definition CRUD re-anchored onto
  platform-settings-admin; beta/trial entitlement rule extended to
  feature-beta-tester so it isn't inert once Slice B drops beta-tester.
- Updated 5 stale spec expectations broken by the additive widening
  (post move rule now always fires for platform-resource-admin, account
  policy gained a 7th credential rule, dual-path deleteOrganization needs
  isAccessGranted mocked, licensing grant-set list gained an entry, and the
  L0 space platform-roles-access role count moved 7->9). Lint cleanup:
  unsafe-fixed unused imports/import ordering (biome), incl. one
  pre-existing unused import in
  role.set.resolver.mutations.membership.spec.ts unrelated to this feature.

pnpm lint, pnpm build and the full pnpm test:ci:no:coverage suite (7341
passed) all green.

workspace#027-platform-role-redesign
The four grant/revokeCredentialTo{User,Organization} mutations
(admin.authorization.resolver.mutations.ts) check against a resolver-local,
hardcoded IN_MEMORY authorization policy built once in the constructor from
a fixed one-element [GLOBAL_ADMIN] array — entirely decoupled from the
shared platform policy's GRANT_GLOBAL_ADMINS credential rule that T034
widens to platform-roles-admin. Widening that shared rule therefore cannot
reach these four mutations: no resolver-level pin needed to be added, the
existing hardcoded local policy object already is the pin.

Added a doc comment at the constructor recording this finding (so a future
"simplification" replacing it with the shared platform policy doesn't
silently reopen FR-022's hole), plus a real-instance test suite
(AuthorizationPolicyService + AuthorizationService, no mocked grant check)
proving a platform-roles-admin-only actor is denied all four and legacy
global-admin still succeeds.

workspace#027-platform-role-redesign
…, global-support-manager)

Slice A [server], continuing Phase 4 (US3) explicit-gate re-anchoring:
- T044 (A9, space/VC move family): conversion.resolver.mutations.ts's six
  space convert/move mutations + convertVirtualContributorToUseKnowledgeBase
  share one resolver-local hardcoded policy scoped to global-admin only;
  extended additively to platform-resource-admin. Required extending
  AuthorizationPolicyService.createCredentialRuleGlobalAdmins' switch with
  the 12 target roles (each string-identical to its credential, research
  D2) — previously only 4 legacy AuthorizationRoleGlobal members were
  mapped, so any new role passed to createGlobalRolesAuthorizationPolicy
  threw ForbiddenException.
- T044/T066 (A9 + US4): callouts-set transfer-offer/accept rules extended
  with platform-resource-admin — this is global-support-manager's ONLY
  wiring anywhere in the codebase, so re-anchoring it here (additively)
  is what lets that near-inert legacy role be deleted with zero capability
  loss in Slice B.
- T045 (A10, platform settings family): consolidated 5 surfaces
  (updatePlatformSettings + 4 iframe/notification-blacklist mutations +
  the well-known-VC mapping mutation) onto PLATFORM_SETTINGS_ADMIN, off the
  PLATFORM_ADMIN catch-all. Its legacy grant set (GLOBAL_ADMIN,
  GLOBAL_SUPPORT, GLOBAL_LICENSE_MANAGER) differs from
  updatePlatformSettings' own (GLOBAL_ADMIN, GLOBAL_PLATFORM_MANAGER) — union
  of both preserved, nothing narrowed.
- T046 (A12 usage): assign/revoke license plan on Account/Space check GRANT
  directly on the licensing framework's authorization — added a dedicated
  GRANT-only rule for platform-license-manager, kept separate from the
  `licensings` CRUD/GRANT bundle so this role does not also acquire A13's
  plan-definition CRUD.
- T047 (A13) verified already satisfied by T040: create/update/deleteLicensePlan
  and adminLicensePolicy*CredentialRule check the licensing framework's own
  authorization (directly, or via inheritParentAuthorization cascade for
  LicensePolicy), which T040 already extended with platform-settings-admin.
  No further code change needed.
- T048 (A14): updateSpacePlatformSettings re-anchored off PLATFORM_ADMIN onto
  ACCOUNT_LICENSE_MANAGE (space.service.authorization.ts's
  spacePlatformSettingsAdmin rule extended with platform-license-manager,
  additively; legacy global-admin/global-support keep their prior reach).
  Slice-A member name confirmed still updateSpacePlatformSettings per T048's
  note (rename to adminUpdateSpaceVisibility is Slice B, T078).

Updated 3 stale spec expectations (conversion resolver mock indices,
space.resolver.mutations privilege assertion, licensing-framework rule
count/cascade assertions). Full pnpm test:ci:no:coverage green (7346
passed), pnpm lint clean.

workspace#027-platform-role-redesign
…mplates, content deletions)

Slice A [server], Phase 4 (US3) — the D5 dual-path shape (owner keeps
ordinary CRUD, the platform role reaches the same mutation via its own
privilege) applied to every A7/A8 surface named in tasks.md:

- T042 (A7, PLATFORM_SUPPORT_ORG_RESOURCES ∨ UPDATE/CREATE/DELETE):
  updateInnovationPack, updateInnovationHub, and the full template CRUD
  family inside an org-owned pack/hub (createTemplate/FromSpace/
  FromContentSpace, updateTemplate/FromSpace, deleteTemplate). Harmless
  no-op for space-owned templates, whose authorization tree never carries
  PLATFORM_SUPPORT_ORG_RESOURCES.
- T043 (A8, PLATFORM_CONTENT_FULL_ACCESS ∨ DELETE): deleteInnovationPack,
  deleteInnovationHub, deleteCallout, deleteContribution, deleteSpace.
  updateCalloutPublishInfo already gated on UPDATE_CALLOUT_PUBLISHER
  (verified, no change needed).

Both privileges cascade down from the root policy (T036) / account policy
(T037) via inheritParentAuthorization, so the resolver-level dual isAccessGranted
check is what actually exercises the cascade — a single-privilege
grantAccessOrFail call would have left platform-support/platform-content-
full-access unable to reach these mutations despite holding the privilege
on the entity's authorization.

Updated 8 spec files: added/mocked isAccessGranted (auto-mocked
AuthorizationService defaults to a truthy sentinel for unconfigured calls,
so the owner-check branch needed an explicit false to exercise the
fallback grantAccessOrFail path). Full pnpm test:ci:no:coverage green
(7346 passed), pnpm lint clean. (current-actor.decorator.spec.ts is a
known order-dependent flake, unrelated file, passes in isolation.)

workspace#027-platform-role-redesign
…AGE dual-path

Slice A [server], A15 completion (research D5, FR-007(e)): updateDiscussion
and deleteDiscussion (discussion.resolver.mutations.ts) now dual-gate on
ordinary UPDATE/DELETE (today's reach, including the GLOBAL_SUPPORT
platform-subtree cascade that Slice B's T073 removes) alongside
platform-support's own PLATFORM_FORUM_MANAGE privilege (T035's grant) —
so Support does not silently lose forum access at Slice B. Additive,
never narrows the existing reach.

pnpm test:ci:no:coverage green (7346 passed), pnpm lint clean.

workspace#027-platform-role-redesign
…hole

Slice A [server], A20/A20b (research D9, sixth clarification pass):
usersInRole/usersInRoles/organizationsInRole/organizationsInRoles
(role.set.resolver.fields.ts) no longer carry the static
@AuthorizationActorHasPrivilege(READ) decorator — the privilege now
depends on the requested ROLE, checked in-method via a new
checkHolderListAccessOrFail helper:

- an ordinary (non-target) role — every per-space/organization role, and
  the pre-existing legacy `platform-*` roles this feature does not touch
  (platform-operations-admin, platform-beta-tester, platform-vc-campaign,
  platform-assistant-access) — keeps plain READ, unchanged (FR-032).
- one of the 9 new `Platform …` target roles requires
  PLATFORM_ROLE_HOLDERS_READ.
- one of the 3 new `Feature …` target roles requires
  PLATFORM_ROLE_HOLDERS_READ **or** FEATURE_ROLE_HOLDERS_READ.

Target roles are matched against EXPLICIT sets (PLATFORM_TARGET_ROLES /
FEATURE_TARGET_ROLES), not a `platform-`/`feature-` string-prefix test —
a prefix test would also catch the 4 legacy `platform-*` roles above,
silently re-gating their holder-list reads onto a privilege they never
needed, which would be exactly the narrowing Slice A forbids.

T051a: the two list-taking fields (usersInRoles, organizationsInRoles)
evaluate every requested role BEFORE fetching any data and fail closed as
a whole on the first denied role — no partial/filtered result, which
would make a withheld role indistinguishable from an empty one (the
read-side twin of the C1 silent-void defect).

Added 6 new unit tests (per-role privilege selection both ways, the
feature-role OR-path, fail-closed-as-a-whole for the list variant) plus
updated 8 call sites in the existing spec for the new leading
actorContext parameter. Full pnpm test:ci:no:coverage green (7350
passed), pnpm lint clean.

workspace#027-platform-role-redesign
…_PROFILE

Slice A [server], A21 (FR-002, twelfth analyze pass, eighth clarification
pass): UserService.updateUser no longer applies the `serviceProfile`
field unconditionally. When present on the payload:
- SET_SERVICE_PROFILE is checked on the PLATFORM authorization policy
  (platform-roles-admin ∪ legacy, T035's grant) — the marker is a
  platform-wide precondition of a Platform Spaces Reader grant, not a
  per-user attribute the target's own admins control.
- Authorized: applies the change and writes a platform_role_assignment
  audit row (service_profile_changed, previous/newServiceProfile) via the
  already-built PlatformRoleAssignmentAuditService.recordServiceProfileChange.
- Denied: does NOT silently drop the field (a dropped field would be
  indistinguishable from a permitted no-op, leaving A21's denial cells
  nothing to assert against) — throws ForbiddenException naming the
  required capability, AND writes a role_grant_rejected audit row via
  recordServiceProfileRejected (same class of event as a rejected role
  assignment, no new outcome value).

The `serviceProfile` field STAYS on UpdateUserInput (removing it would be
a breaking input-field removal, forbidden in the additive slice) — only
the enforcement moved.

Wiring: UserService gained AuthorizationService, PlatformAuthorizationPolicyService
(already imported into UserModule) and PlatformRoleAssignmentAuditService
(newly imported) as dependencies; updateUser gained a required
actorContext parameter, threaded through from the one call site
(UserResolverMutations.updateUser, which already has it). No circular
imports — pnpm build and the full unit suite both confirm.

Added 2 new unit tests (authorized change + audit, denied change + audit
+ no side effect) and fixed 12 call sites across user.service.spec.ts and
user.resolver.mutations.spec.ts for the new parameter. Full pnpm
test:ci:no:coverage green (7351 passed), pnpm lint clean.

workspace#027-platform-role-redesign
…_USERS_ADMIN

Slice A [server], US2 (research D5): the four A4/A5 surfaces re-anchored
off their prior catch-all privileges onto PLATFORM_USERS_ADMIN:

- T061 (A4): adminUserEmailChange / adminUserEmailChangeDriftResolve
  (admin.user.email.change.resolver.mutations.ts), off PLATFORM_ADMIN.
- T062 (A5): adminIdentityDeleteKratosIdentity, off PLATFORM_SETTINGS_ADMIN;
  adminUserAccountDelete, off PLATFORM_ADMIN. Both check the privilege
  directly against the PLATFORM's own authorization policy.
- T062 (A5): deleteUser (registration.resolver.mutations.ts), off the
  DELETE-cascade — dual-gated DELETE ∨ PLATFORM_USERS_ADMIN, not a
  replacement: DELETE also covers a user's OWN self-service account
  deletion (USER_SELF_MANAGEMENT, resource-scoped to that user's ID),
  which must keep working.

Added a new PLATFORM_USERS_ADMIN grant on the PLATFORM's own authorization
tree (platform.service.authorization.ts) — distinct from, but kept in
sync with, T060's per-USER grant in user.service.authorization.ts. Three
of the four re-gated surfaces check the privilege against the platform
policy directly, not a per-user object, so they needed this second grant
site. Grant set is the union of A4's legacy reachers (today's
PLATFORM_ADMIN: GLOBAL_ADMIN, GLOBAL_SUPPORT, GLOBAL_LICENSE_MANAGER) and
A5's (today's PLATFORM_SETTINGS_ADMIN: adds GLOBAL_PLATFORM_MANAGER) — no
narrowing on any of the four surfaces.

Updated 1 stale integration-test assertion (admin-user-account-delete.spec.ts)
and 1 unit test needing the dual-path isAccessGranted mock
(registration.resolver.mutations.spec.ts). Full pnpm test:ci:no:coverage
green (7351 passed), pnpm lint clean.

workspace#027-platform-role-redesign
The 12 new role/credential enum values (T004-T006), 11 new privilege
enum values (T007), and the two organization-target platform-role
mutations (T032a) were committed to source across the earlier commits in
this slice but schema.graphql was never regenerated to match — this was
the first time pnpm schema:print/sort ran since. Verified against
schema-baseline.graphql via schema:diff: 56 additive entries, 3 info, ZERO
breaking changes; schema:validate passes on both change-report.json and
deprecations.json.

workspace#027-platform-role-redesign
…ft detector

Creates src/platform/platform-role/verification/ — the model half of the
FR-033/FR-034 verification layer, deliberately scoped to model-only this
wave (reachability.spec.ts / T070m, the check, is a later wave):

- gate.model.ts: GateExpr, a closed union of four gate shapes
  (requires/anyOf/credential/condition) plus type guards and a privilege
  extractor.
- cascade.model.ts: ROOT_CASCADE and the two Slice-A-only LEGACY_CASCADES
  (global-admin root CRUD+GRANT, global-support platform-subtree CRUD),
  keyed on the seven root-inheriting trees (research C3, verified by
  grepping every inheritRootAuthorizationPolicy() call site).
- privilege.grants.ts: PRIVILEGE_GRANTS, a hand-maintained mirror of the
  grant sets T034-T040a write, including the pre-existing GRANT_GLOBAL_ADMINS
  (re-scoped, not new — the fourteenth/fifteenth-pass finding).
- a.row.surfaces.ts: A_ROW_SURFACES, the executable census for all 21 live
  A-rows (A18 retired) plus A17's two Slice-B-deferred surfaces. Several
  `~`-pinned counts in contracts/privilege-map.md were corrected by
  grepping the tree per T040b's own instruction: A3 7->10, A7 5->8, A9
  MOVE_POST resolved as ungated (dead privilege, no resolver consumes it —
  A9 stays 9), A10 2->6, A11 10->13, A12 2->6 (added createWingbackAccount
  and split assign/revoke into account+space variants), A13 4->5, A15 2->3
  (deleteDiscussion joins updateDiscussion). This is a real, evidence-based
  shift in the ~76/~988-cell scale figures the workspace spec's Scale
  section and test-suites' matrix depend on — flagged for the orchestrator,
  not silently absorbed.
- reachability.ts: reachers(surface, slice), pure and derivation-only for
  the two privilege-shaped gates; the two non-privilege shapes return the
  surface's own declared fields by design (surface.drift.spec.ts's rule 3
  is what checks those against the code instead).
- scanned.privileges.ts + surface.drift.spec.ts: the T052a drift detector.
  SCANNED_PRIVILEGES is derived from the census (never hand-listed) but
  deliberately excludes the five baseline CRUD verbs and the retiring
  PLATFORM_ADMIN catch-all — scanning those by literal privilege name was
  verified empirically to flag 28+ unrelated files having nothing to do
  with this feature's eight admin families. Documented as a third stated
  limit alongside FR-024's "wholly ungated surface" blind spot. 76 assertions
  green (rules 1-3 across every censused file, plus a shape-exhaustiveness
  check).

Deviation from the task text: A1/A2's SurfaceRef.file points at
platform.role.assignment.rules.service.ts (the rule engine, where
GRANT_GLOBAL_ADMINS/FEATURE_ROLE_ASSIGN are literally checked for the
target-role-model path) rather than the resolver — SurfaceRef.file is
documented to mean "where the gate is enforced". The resolver's own
literal else-branch check (legacy global-* roles only) and the unrelated
generic role.set.resolver.mutations.ts are both listed in
INDIRECT_ENFORCEMENT_FILES with inline rationale.

Gates green: pnpm lint, pnpm build, pnpm test:ci:no:coverage (7427 tests).
migration:validate deliberately not run (destructive against an ambient
DB of unknown provenance; deferred to Forge Phase V's isolated stack).

workspace#027-platform-role-redesign

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… T058 A8/A9/A14 audit wiring

T053/T054/T055 (bootstrap.service.ts, users.json): the seeded credential
grant loop now runs on EVERY start for EVERY configured account, granting
only what is missing (idempotent, FR-013b out-of-band-lockout repair).
Every target-role-vocabulary credential routes through a new
evaluateSeedOrFail() on PlatformRoleAssignmentRulesService (rules 2-5;
rule 1/assigner-capability does not apply to a seed with no assigner) —
a violation is a fatal BootstrapException naming the account and rule,
never silently skipped. Seeded audit writes are fail-open via the
existing PlatformRoleAssignmentAuditService. admin@alkem.io now seeds as
platform-roles-admin (break-glass, FR-013b) instead of global-admin; a
new spaces-reader@alkem.io service account seeds platform-spaces-reader
with serviceProfile:true set directly on the entity before its grant is
evaluated (rule 3 keys on it). notifications@alkem.io's global-community-read
is left as-is — it is a live, functional grant (READ cascade + PII/settings
read in user.service.authorization.ts), not a silent-void row; recorded as
evidence rather than replaced.

T056 (actor.context.service.ts): populateFromActorID now expands an
ORGANIZATION_ADMIN/OWNER's credential list with that organization's own
feature-* credentials (Feature roles are grantable to organizations,
T032a) — platform-* credentials are never expanded (D2's prefix filter).

T057 (role.set.service.ts): explicit ActorContextCacheService.deleteByActorID
on both the assign and remove ORGANIZATION branches of
assignActorToRole/removeActorFromRole, so a promotion/demotion/departure
denies or grants the org-inherited feature-* credentials on the NEXT
request rather than surviving the 60s TTL — on top of (not instead of)
the invalidation ActorService already performs for the actor's own
credential change.

T058 (FR-018a write-boundary discriminator): wired PlatformResourceAuditService
into A8's five dual-path content deletions (deleteCallout, deleteContribution,
deleteSpace, deleteInnovationPack, deleteInnovationHub) plus
updateCalloutPublishInfo, A9's moveContributionToCallout, and A14's space
visibility change — every write takes its authorizing-branch flag from the
authorization RESULT already computed at the gate (canDeleteAsContentFullAccess
etc.), never re-derived from the actor's roles. Added recordEventForActor /
recordChangeForActor convenience wrappers to PlatformResourceAuditService /
PlatformConfigurationAuditService that resolve FR-025 attribution via
resolveInitiatorRole and fail open on an attribution error too (never let a
defect in attribution fail an already-applied mutation).

Updated bootstrap.service.spec.ts for the new existence+credentials-in-one-call
flow (userService.getUserByEmail replaces isRegisteredUser as the gate) and
added a case proving only MISSING credentials are (re-)granted.

Remaining T058 wiring (A9's other 6 mutations, A10, A12, A13) and T063-T070m
continue in follow-up commits.

workspace#027-platform-role-redesign
Wires PlatformResourceAuditService into A9's remaining 7 mutations
(transferCallout, the four transfer*ToAccount mutations, and the three
cross-L0 conversion moves using the census's declared
intendedOwners/legacyReachers per the known modelling gap around their
resolver-local synthetic PLATFORM_ADMIN policy) and A12
(assignLicensePlanToAccount/Space, revokeLicensePlanFromAccount/Space,
updateBaselineLicensePlanOnAccount).

Wires PlatformConfigurationAuditService into A10 (updatePlatformSettings,
add/removeIframeAllowedURL, add/removeNotificationEmailFromBlacklist,
setPlatformWellKnownVirtualContributor) and A13 (deleteLicensePlan,
updateLicensePlan, adminLicensePolicy{Create,Update,Delete}CredentialRule).

All single-path surfaces (no ordinary-owner branch) per the census, so
every successful call is audited unconditionally.

workspace#027-platform-role-redesign
…rage

T063: wires PlatformUserRecordAuditService into A5's three surfaces —
deleteUser (dual-path: audited only on the PLATFORM_USERS_ADMIN branch,
never the self-service DELETE branch), adminIdentityDeleteKratosIdentity
(resolves the target user via getUserByAuthenticationID BEFORE deletion,
since the Kratos<->user binding is gone after), and adminUserAccountDelete.
Added a recordActionForActor convenience wrapper to
PlatformUserRecordAuditService mirroring the pattern used for the other
two audit services (FR-025 attribution via resolveInitiatorRole, fail-open
on an attribution error).

T057/T070g test coverage: added org-inheritance expansion specs to
actor.context.service.spec.ts (feature-* credentials inherited from an
administered organization, platform-* never expanded, plain ASSOCIATE
unaffected, a dangling ORGANIZATION_ADMIN resourceID doesn't fail the
whole build) and cache-invalidation assertions to role.set.service.spec.ts
for both the ORGANIZATION assign and remove paths.

Updated the pre-existing admin-user-account-delete.spec.ts integration
test for AdminUsersMutations' new constructor parameter.

workspace#027-platform-role-redesign
…ate specs

T070c: platform.role.assignment.rules.service.spec.ts — the five rules in
both outcomes, the evaluation-order case (first failure wins, exact
message asserted), evaluateOrFail/evaluateSeedOrFail, and T064's FR-003
one-way-separation proof (platform-users-admin reaches FEATURE_ROLE_ASSIGN,
never GRANT_GLOBAL_ADMINS alone).

T070e gap-fills (permitted + denied path per SC-018) for the four A-row
surfaces that had no co-located resolver spec yet:
- admin.identity.resolver.mutations.spec.ts (A5)
- admin.user.email.change.resolver.fields.spec.ts (A19 sites 2+3)
- callout.contribution.move.resolver.mutations.spec.ts (A8 dual-path +
  A9 single-path, in one file)
- callout.transfer.resolver.mutations.spec.ts (A9)

workspace#027-platform-role-redesign
T070d: succeeds/fails specs for all four new audit writers
(PlatformRoleAssignmentAuditService — fail-closed operator-initiated vs
fail-open seeded, plus the FR-025 legacy-broad-credential attribution
fallback and a rejection write; PlatformUserRecordAuditService,
PlatformConfigurationAuditService, PlatformResourceAuditService — all
fail-open) including each service's recordXForActor convenience wrapper,
both the happy path and the fail-open-on-attribution-defect path.

T070e gap-fill: license.plan.resolver.mutations.spec.ts and
license.policy.resolver.mutations.spec.ts (A13) — permitted + denied path
for all five mutations.

workspace#027-platform-role-redesign
Adds the two FR-024 stateful-flow cases bootstrap.service.spec.ts was
missing: a seeded credential that violates the shared rule engine raises a
FATAL BootstrapException naming the violated rule and never forces the
grant through (flow 3), and a seeded grant applies before the audit write
which is marked seeded:true so the writer's own fail-open branch — proven
separately in platform.role.assignment.audit.service.spec.ts — applies
regardless of audit-store health (flow 4).

workspace#027-platform-role-redesign
…and reachability inventories

T070a: unit.coverage.inventory.ts — the five exhaustive Records (D21),
each keyed on a union derived from the runtime enumeration it inventories
(ASSIGNMENT_RULE_COVERAGE off the rule engine's own ruleId type,
AUDIT_WRITER_COVERAGE off PlatformAuditCategory, A_ROW_GATE_COVERAGE off
ARowId, PRIVILEGE_COVERAGE off keyof typeof PRIVILEGE_GRANTS plus an
explicit UPDATE_NAMEID:{deferred:'B'} extra key, STATEFUL_FLOW_COVERAGE
off the two FR-024 flows this layer owns) so adding a rule/category/
A-row/privilege/flow without an entry is a tsc error.

T070b: unit.coverage.spec.ts walks every declared path in all five Records
and asserts the file exists, is non-empty and ends .spec.ts.

T070m: reachability.spec.ts — the FR-034/SC-019 equality (derived reacher
set via reachers() ≡ declared intent) for every one of the census's 99
surfaces, in both slices. Running it surfaced SIX real model/census
defects, fixed rather than the assertion relaxed (per the task's explicit
instruction):

- privilege.grants.ts: AUTHORIZATION_RESET/LICENSE_RESET/PLATFORM_OPERATIONS_ADMIN
  (A3/A11, pre-existing 032 privileges) and bare READ (A16, T038's
  spaces-reader grant) had no ManagedPrivilege entry at all, so every A3/A11
  surface and A16 derived to an empty set.
- New TREE_SCOPED_PRIVILEGE_GRANTS mechanism for the three rows whose
  literal gate is a baseline CRUD verb reused too promiscuously elsewhere to
  manage globally (A9's three cross-L0 moves via a resolver-local synthetic
  PLATFORM_ADMIN policy, A12's GRANT and A13's CREATE/UPDATE/DELETE on the
  licensing-framework tree) — scoped so it cannot leak into A6/A7/A8's
  unrelated anyOf gates over the same literal privileges.
- Census fixes (a.row.surfaces.ts): A4 was missing global-platform-manager
  (the single PLATFORM_USERS_ADMIN credential rule grants it to A4's AND
  A5's legacy reachers undifferentiated); A7 was missing global-admin (still
  holds ordinary UPDATE on the account tree via the Slice-A-only legacy
  cascade); A14 was missing global-license-manager (already held
  ACCOUNT_LICENSE_MANAGE pre-T037); A16 was missing global-admin/
  global-support (both hold plain READ on the space tree via the root
  cascade today, same as the already-declared global-spaces-reader).

reachability.ts extended to consult TREE_SCOPED_PRIVILEGE_GRANTS for the
surface's own tree, on top of the existing global ManagedPrivilege check.

Full suite green: 7763 tests, typecheck clean.

workspace#027-platform-role-redesign
… the SC-001 sign-off gate

Adds docs/evidence/027-platform-role-redesign.md, the human-readable
evidence a security owner needs to sign off Slice A:

- T028: the god-mode evidence table — every A-row's reach through the root
  cascade / GLOBAL_SUPPORT cascade before this feature, read off the
  census. Flags A7 as the one row where T037 closes a previously
  inheritance-only capability (research C2) rather than merely re-anchoring
  an existing one.
- T029: the four structural findings (near-inert global-support-manager,
  redundant global-community-reader, the two C1 silent-void seeded rows,
  overlapping Support/License Manager grants) with remediation status.
- T065: FR-005 negative-space table — platform-operations-admin absent from
  all six boundary privileges.
- T067: the 13-row FR-009 table proving every target role grants >=1
  enforced capability, including feature-beta-tester's licensing-policy-only
  entitlement (the row most likely to look empty).
- T068: the FR-010 consistency sweep read directly off A_ROW_SURFACES, with
  A16's accepted exception and A15's own-privilege resolution called out.
- T069: verified research C1 against the current tree (both void seed rows
  quoted from platform.role.seed.definitions.ts) and confirmed no repair
  migration ships (D1).
- T070/T070l: mutation-test evidence — the anti-drift guard and
  reachability.spec.ts each caught a live, applied-then-reverted
  perturbation (a skewed ROLE_CREDENTIAL_MAP entry, DELETE restored to
  ROOT_CASCADE reproducing the eleventh-pass finding on A5/A6, A16's
  acceptedExtraReachers removed, A14's intendedOwners emptied) — all four
  reverted, suite confirmed green after each.

Also records T070m's six reachability-model findings (see the prior
commit) as an explicit review item.

Exit gates re-run clean this session: pnpm lint, pnpm build,
pnpm test:ci:no:coverage (7763 tests), pnpm schema:print/sort/diff/validate
(zero breaking changes).

workspace#027-platform-role-redesign
… service-profile extraction coverage

T070h: extend organizationsInRole/organizationsInRoles with the same
platform-*/feature-*/ordinary-role coverage usersInRole/usersInRoles
already had, plus an explicit non-target-role READ assertion on
usersInRole. Closes the gap where the shared checkHolderListAccessOrFail
was only exercised from one of its two call sites.

T070i: add a typed (non-`as any`) UpdateUserInput construction so the
"serviceProfile stays on the DTO" invariant is a real tsc guard, not
just an untyped literal that would still compile if the field were
removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…Slice A grant-set widenings

Direct literal-array guards for the credential rules T034-T040a wrote,
complementing reachability.spec.ts's indirect set-equality coverage:

- platform.authorization.policy.service.spec.ts: the root cascade grants
  platform-content-full-access EXACTLY [READ, PLATFORM_CONTENT_FULL_ACCESS],
  length 2 — never CREATE/UPDATE/DELETE/UPDATE_NAMEID (T036).
- platform.service.authorization.spec.ts: GRANT_GLOBAL_ADMINS widening
  (+platform-roles-admin), FEATURE_ROLE_ASSIGN, PLATFORM_ROLE_HOLDERS_READ,
  FEATURE_ROLE_HOLDERS_READ (D9 subsumption), PLATFORM_AUDIT_READ,
  SET_SERVICE_PROFILE, PLATFORM_USERS_ADMIN, PLATFORM_FORUM_MANAGE
  (asserting platform-content-full-access is NOT a reacher — the
  FR-007(e) distinguishing assertion), PLATFORM_SETTINGS_ADMIN,
  CREATE_ORGANIZATION.
- account.service.authorization.spec.ts: TRANSFER_RESOURCE_OFFER/_ACCEPT,
  ACCOUNT_LICENSE_MANAGE, PLATFORM_SUPPORT_ORG_RESOURCES.
- organization.service.authorization.spec.ts: DELETE_ORGANIZATION, kept
  separate from the ordinary owner DELETE rule and never merged with
  CREATE_ORGANIZATION.
- user.service.authorization.spec.ts: PLATFORM_USERS_ADMIN on the
  per-user tree, READ_USER_SETTINGS and READ_USER_PII extensions.
- space.service.authorization.spec.ts: ACCOUNT_LICENSE_MANAGE on the
  space tree (A14).
- platform.role.resolver.mutations.spec.ts: FEATURE_BETA_TESTER carries
  the same beta/trial license entitlement as legacy PLATFORM_BETA_TESTER
  (T040a).

licensing.framework.service.authorization.spec.ts already had equivalent
exact-args coverage for A12/A13 — verified sufficient, left unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
grantability.it-spec.ts caught that usersInRole(PLATFORM_OPERATIONS_ADMIN)
was denied for Platform Roles Admin with FORBIDDEN_POLICY: the role was
missing from PLATFORM_TARGET_ROLES in role.set.resolver.fields.ts, so its
holder-list read fell through to the ordinary-role branch requiring plain
READ instead of PLATFORM_ROLE_HOLDERS_READ (A20).

platform.role.assignment.rules.service.ts already carried all 10 `Platform …`
roles including this one in its own PLATFORM_FAMILY_ROLES set, confirming
platform-operations-admin is one of the 10 target roles this feature
repurposes, not a pre-existing legacy holdover — the resolver's set (and its
explanatory comment) was simply out of sync.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…E_PROFILE

role-action-matrix.it-spec.ts caught that ALLOW: PLATFORM_ROLES_ADMIN x A21
(updateUser) was denied: UserResolverMutations.updateUser required plain
UPDATE on the target user's own authorization for EVERY call, before ever
reaching UserService.updateUser's SET_SERVICE_PROFILE check (T052).
PLATFORM_ROLES_ADMIN holds no path to that outer UPDATE gate — by design,
per FR-003 it holds no user-record CRUD — so A21, its sole-owned surface,
was structurally unreachable.

Rejected the fix_direction floated by the live-verification track (granting
PLATFORM_ROLES_ADMIN a path into CREDENTIAL_RULE_USER_READ /
PRIVILEGE_RULE_READ_USER_SETTINGS): that rule grants ordinary READ/UPDATE
across the WHOLE user record (nameID, firstName, lastName, phone,
profileData), which is exactly the "user-record actions" the role table
(row 1) says Platform Roles Admin "Cannot" do — it would have closed F-2 by
reopening FR-003.

Instead, the resolver now skips the blanket UPDATE gate only when the
input's sole substantive field is `serviceProfile` (ID is addressing, not a
change) and defers entirely to the already-correct SET_SERVICE_PROFILE check
against the PLATFORM authorization policy. Any input that also touches
another field still goes through the ordinary UPDATE gate, so this cannot be
used to smuggle a general user-record edit through — A21's declared surface
(user.service.ts#updateUser, gated on SET_SERVICE_PROFILE per
a.row.surfaces.ts) is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…schema

Found by the gql-live verification track (SC-006 reachability): the census
grants MOVE-adjacent admin whiteboard surfaces, but
AdminWhiteboardResolverMutations.adminUploadFilesFromContentToStorageBucket
never registered with Apollo — AdminWhiteboardModule was declared but never
imported by any parent module, so the resolver was dead code and the mutation
was absent from the live schema entirely.

Wired the module into app.module.ts and the schema-bootstrap module. The
mutation is now present on the printed schema (live-confirmed: 221 mutations,
name present via __schema introspection). Additive only — schema.graphql gains
the operation, nothing is removed, so slice A stays additive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… guard)

countActorsWithCredentials filtered the to-many `credentials` relation through
a plain FindOptionsWhere, which TypeORM compiles to an INNER JOIN — the exact
row-duplication hazard findMentionableContributors already documents and avoids
with an EXISTS subquery. An actor holding more than one credential row of the
same type+resourceID (nothing forbids it; Credential carries no unique
constraint) was therefore counted once PER ROW, not once per actor.

platform.role.assignment.rules.service.ts rule 5 — the last-Platform-Roles-Admin
guard — depends on that number being an exact actor headcount. An inflated count
silently defeats the guard and lets the platform's last Platform Roles Admin
revoke themselves, locking the platform out of its own role administration.

Switched to the same EXISTS-subquery pattern so the result is always a distinct
actor count, immune to relation-join fan-out.

Also adds a real-engine integration block to the resolver spec: the existing
suite auto-mocks PlatformRoleAssignmentRulesService, so it structurally cannot
catch a wiring defect between the resolver and the actual rule engine. The new
block wires the real rules service (stubbing only AuthorizationService) and
exercises rule 5's revoke path end to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both call sites invoke notifyPlatformGlobalRoleChange WITHOUT awaiting
(pre-existing, bd8b9d8 / bd8314b, also present on develop) — deliberately,
so a notification outage cannot fail a role change. But a floating promise that
REJECTS is an unhandled rejection, and Node's default --unhandled-rejections=throw
turns that into a hard process exit.

Observed live TWICE during 027 verification: revoking a platform role from a user
whose `profile` relation resolves null crashes the entire server with
`TypeError: Cannot read properties of null (reading 'displayName')` in
NotificationExternalAdapter.getUserPayloadOrFail. Both Phase V attempts collapsed
to env-degraded because the test-suites fixtures killed the server they were
testing — 13 roles revoked across many users reproduces it every run.

027 did not introduce this, but it makes the path hot: this feature's entire
subject is platform role grant/revoke. Containing the rejection here restores the
intended fire-and-forget semantics — failure logged, mutation still succeeds,
process survives.

The null-profile cause itself is a SEPARATE defect in the notification adapter
(getUserPayloadOrFail requests relations: {profile: true}, still gets null, then
dereferences it unguarded; createUserPayloadFromUser has the identical unguarded
dereference). Reported, not absorbed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… space-admin

REGRESSION INTRODUCED BY THIS BRANCH. On develop, getCredentialForRole returned
`roleDefinition.credential` — the stored, per-role-set credential. 027 replaced
that with `ROLE_CREDENTIAL_MAP[roleName]` to close the C1 silent-void defect
(research C1/D3), but that map is `Record<RoleName, AuthorizationCredential>`,
keyed by role name ALONE. It cannot carry the role-set dimension, and
`[RoleName.ADMIN]` is `SPACE_ADMIN` unconditionally.

`RoleName.ADMIN` is the one real collision in the model: SPACE -> space-admin,
ORGANIZATION -> organization-admin (organization.role.definitions.ts). Every
other shared name is unambiguous (MEMBER/LEAD space-only, ASSOCIATE/OWNER
organization-only), which is why only ADMIN broke.

Impact: promoting an organization ADMIN wrote a `space-admin` credential row
against the org's actor id. That mis-credentials EVERY organization-admin
promotion, including the implicit one in OrganizationService.createOrganization,
and it breaks FR-002/FR-031 feature-role inheritance —
ActorContextService.expandWithOrganizationInheritedFeatureCredentials filters
strictly for organization-admin/organization-owner, so it never matched and the
org's feature-* credentials were never expanded onto the admin.

Live-confirmed by DB query during Phase V (immediacy.it-spec.ts and
flows/org-inheritance-demotion.it-spec.ts, both FORBIDDEN_POLICY on
createOrganization for a promoted org admin). Independently, this is the same
cross-role-set credential confusion the earlier review panel rated critical on
both spec-compliance and security — that claim was recorded as unverified
because its verifiers never ran. It was real.

Fix: ROLE_CREDENTIAL_OVERRIDES_BY_ROLE_SET_TYPE + resolveRoleCredential(roleName,
roleSetType), applied at BOTH call sites — getCredentialForRole and its
pre-loaded sync twin getCredentialForRoleSync, which carried the identical
defect. bootstrap.service.ts keeps the flat map: it seeds the platform role-set,
where names are globally unique and no override exists.

Adds 4 regression tests to the FR-011 anti-drift spec, which passed throughout
because it only ever asserted the map against PLATFORM seed definitions — the
collapse is invisible unless the role-set dimension is asserted explicitly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ent-access cascade

Addresses confirmed review findings corr-server-1, sec-server-2,
corr-server-5, spec-server-2, sec-server-1, corr-server-2, sec-server-3,
spec-server-1, corr-server-6 and the sec-server-4 identity/account-delete
surfaces:

- Legacy `global-*` role assign/revoke (assign/removePlatformRoleFromUser)
  is pinned to a resolver-local, hardcoded [GLOBAL_ADMIN] policy instead of
  the shared roleSet.authorization, which T034 widened to also admit
  platform-roles-admin (corr-server-1/sec-server-2). Success audit writes
  now happen AFTER assignActorToRole/removeActorFromRole actually completes,
  not before, so a failed grant no longer leaves a phantom "granted" audit
  row (corr-server-5).
- Assignment rule engine gains rule 6 (self-assignment, FR-015): a Platform
  Roles Admin can no longer grant or revoke any Platform/Feature role on
  itself, on both grant and revoke, excluded from bootstrap seeding
  (spec-server-2/sec-server-1).
- The root content-access rule is reversed to full CREATE/READ/UPDATE/DELETE
  per the ninth clarification pass (FR-004/SC-004), but with GLOBAL_SUPPORT
  correctly excluded — it never held blanket CRUD across the seven
  root-inheriting trees and would otherwise bypass the per-space
  allowPlatformSupportAsAdmin consent gate (corr-server-2/sec-server-3,
  spec-server-1). A6/A7 declare the accepted SC-004 overlap explicitly.
  The reversal's ripple onto A5's deleteUser (Content Full Access gaining
  arbitrary user deletion via the now-cascaded DELETE) is closed by pinning
  deleteUser's legacy-admin branch to actor-identity + a [GLOBAL_ADMIN]-only
  policy.
- Three A4/A5 surfaces consolidated onto one PLATFORM_USERS_ADMIN privilege
  (adminIdentityDeleteKratosIdentity, adminUserAccountDelete, deleteUser)
  are each pinned to their own pre-feature reacher set instead of the
  shared, additively-widened grant set, so global-support/
  global-license-manager/global-platform-manager cannot reach identity or
  account deletion they never held (sec-server-4).
- updateSpacePlatformSettings's nameID (URL rename) field requires an
  additional pin to [GLOBAL_ADMIN, GLOBAL_SUPPORT] so platform-license-manager
  cannot rename spaces via the widened ACCOUNT_LICENSE_MANAGE gate
  (corr-server-6).

Verification model (a.row.surfaces.ts, cascade.model.ts, privilege.grants.ts,
unit.coverage.inventory.ts) updated to match: new credential-pin census
entries for the legacy-role and deleteUser pins, GLOBAL_SUPPORT removed
from the root cascade's declared legacy reachers, and the self-assignment
rule added to the FR-033 coverage inventory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ution/contract fixes

Addresses confirmed review findings corr-server-4, spec-server-3,
qual-server-2, spec-server-4, corr-server-3, qual-server-1 and
spec-server-6:

- Bootstrap seeding reconciles the seeded `serviceProfile` marker for BOTH
  a freshly-created user and a pre-existing one missing it, on every
  restart — not only at creation time — so a pre-existing spaces-reader
  account without the marker no longer makes the server refuse to start
  permanently (corr-server-4).
- `grantSeededCredentials` now tracks the target's held `Platform …` roles
  across the seed loop and passes them to `evaluateSeedOrFail`, so rule 4
  (Audit Reader mutual exclusion) is no longer inert on the bootstrap seed
  path (spec-server-3/qual-server-2).
- Granting or revoking a `Feature …` role on an ORGANIZATION (via the
  PLATFORM role-set) now invalidates the cached ActorContext of every
  ORGANIZATION_ADMIN/OWNER of that org, not only the organization's own
  cache — closing the up-to-60s stale-credential window for a standing
  operator (spec-server-4).
- The A21 service-profile rejection path now uses a shared, extracted
  `resolveInitiatorRoleBestEffort` (resolve.initiator.role.ts) instead of
  calling the strict, throwing `resolveInitiatorRole` raw, so a realistic
  denied actor (holding neither the owning role nor a legacy credential)
  gets the intended ForbiddenException and its rejection audit row, rather
  than an internal error with the audit write silently skipped
  (corr-server-3/qual-server-1). The platform-role resolver's own
  best-effort wrapper is refactored to delegate to the same shared helper.
- Two error strings now match `contracts/graphql-contract.md` verbatim: the
  A21 service-profile denial, and the A20/A20b holder-list read denial
  (both the platform-target and feature-target branches), which previously
  delegated to `grantAccessOrFail`'s generic internal-id-leaking message
  (spec-server-6).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ascade leaks, audit-write integrity

Security:
- sec-server-6 (critical): assignPlatformRoleToOrganization/removePlatformRoleFromOrganization
  accepted any RoleName (incl. legacy global-*) and never verified the target
  actor resolved to an ORGANIZATION, letting a platform-roles-admin mint
  global-admin via the organization surface. Added
  assertOrganizationSurfaceOrFail: rejects non-FEATURE_FAMILY_ROLES and
  verifies ActorType.ORGANIZATION before any rule evaluation or write.
- sec-server-7: adminUserEmailChange/adminUserEmailChangeDriftResolve checked
  the shared, Slice-A-widened PLATFORM_USERS_ADMIN policy, newly admitting
  global-platform-manager to a one-call account-takeover primitive. Pinned to
  a resolver-local policy carrying A4's own pre-feature reacher set
  {PLATFORM_USERS_ADMIN, GA, GS, GLM}, mirroring the sec-server-4 pattern.
- sec-server-8: resolved by sec-server-6's ActorType verification — targetKind
  can no longer diverge from the actual resolved actor type on the two
  organization mutations.
- sec-server-5: escalated (not fixed) — the FR-034 reachability check is
  self-certifying against declared `legacyReachers` rather than a derived
  pre-feature baseline. A full merge-base-derived baseline is a structural
  redesign this pass cannot safely complete blind; the two concrete misses
  it named (sec-server-6, sec-server-7) are fixed above.

Correctness:
- corr-server-7/corr-server-10: the root `platform-content-full-access` CRUD
  cascade (T036a) reached A15 (forum) and A13 (license-plan/policy
  definitions) beyond the accepted A6/A7 exception. A15 now gates solely on
  PLATFORM_FORUM_MANAGE (its own grant set already covers every legacy
  reacher). A13's five mutations now check a resolver-local synthetic policy
  {platform-settings-admin, global-admin, global-license-manager,
  global-platform-manager} instead of the entity's root-cascade-inheriting
  authorization; global-admin is now an explicit, declared reacher instead of
  an accidental cascade one.
- corr-server-9: transferCallout's audit call and census declared
  GLOBAL_SUPPORT as a legacy reacher; the callouts-set's own credential rule
  actually grants GLOBAL_SUPPORT_MANAGER (GLOBAL_SUPPORT never reaches it —
  the account-tree rules that grant it are cascade:false). Fixed the audit
  call and split TRANSFER_RESOURCE_OFFER/_ACCEPT out of the flat
  PRIVILEGE_GRANTS entry into per-tree (`account` vs `callouts-set`) grants
  so the two independent credential rules are no longer conflated.
- corr-server-8: moveContributionToCallout and the four account resource
  transfers (transferSpaceToAccount et al.) were declared single-path but are
  reachable by ordinary space admins / any ACCOUNT_ADMIN respectively.
  Audit writes now fire only when the actor holds one of the surface's own
  platform-level credentials (FR-018a), not merely whoever the shared
  privilege happens to admit.
- corr-server-11/spec-server-8: the fail-closed role-assignment and
  service-profile audit writes ran AFTER the grant/revoke had already landed,
  with no rollback — an audit-store failure left the change applied while
  telling the caller it was not. Added compensation: a failed audit write
  now reverts the just-applied grant/revoke/service-profile change before
  re-throwing.
- spec-server-7: A15's census declared a single-privilege gate while the code
  ran a UPDATE/DELETE ∨ PLATFORM_FORUM_MANAGE dual path; closed by the
  corr-server-7 fix (now a genuine single-privilege gate matching the
  declaration).
- spec-server-10: the conversion resolver's three promote/demote mutations
  and convertVirtualContributorToUseKnowledgeBase shared the same
  platform-resource-admin-reachable synthetic policy as the three declared
  cross-L0 moves but had no census entry. Declared all four.
- spec-server-9: several `gateSpecs` entries pointed at specs asserting
  nothing about the named privilege (A15, A7 x3, A19, A3/A9/A12's shared
  account.resolver.mutations.spec.ts). Added real gate assertions
  (owning-privilege-checked / dual-path-branch) to each.
- spec-server-13: notifications@alkem.io's seeded `global-community-read`
  credential is NOT the same identifier as the retired
  RoleName.GLOBAL_COMMUNITY_READER (off-by-"ER"), so it is not automatically
  in Slice B's removal set. Recorded the decision and the required Slice B
  verification step in docs/evidence rather than silently keeping or
  dropping it.

Quality:
- qual-server-3: added PLATFORM_SUPPORT's twin coverage to
  space.service.platform.roles.access.spec.ts (L0 flag-gated CRUD, PUBLIC_SHARE,
  L1/L2 parent-inheritance keyed on the role's OWN cell, PLATFORM_ADMIN
  absence) — previously only GLOBAL_SUPPORT had this coverage despite
  PLATFORM_SUPPORT being copy-adapted from it.

Also fixes two real estate DI wiring gaps this pass's own changes would have
introduced (ActorLookupModule missing from PlatformRoleModule,
AuthorizationPolicyModule missing from LicensePlanModule/
AdminUserEmailChangeModule) — caught by actually running `pnpm schema:print`
(full Nest bootstrap), not by the mocked unit-test gate.

Full gate pass green: lint, build, 7868 unit tests, schema:print/sort/diff/validate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
hero101 and others added 12 commits July 30, 2026 14:01
…cade

V-prime live verification (2026-07-30) found the A16 cell denying
`platform-spaces-reader` plain READ on a private space's collaboration. The
role IS registered with `[READ]` in `space.service.platform.roles.access.ts`
(T038), but `space.service.authorization.ts` built its "Global Spaces Read"
credential rule wired ONLY to `AuthorizationCredential.GLOBAL_SPACES_READER` —
so the new role had no path into the space-tree READ cascade at all. That
breaks the Slice A additive invariant: every new role must reach what its
legacy counterpart already reaches.

- space.service.authorization.ts: derive one rule per spaces-reader role, each
  from its OWN declared privileges and its OWN credential via
  `resolveRoleCredential`, so the two roles can never silently inherit each
  other's grants.
- account.service.authorization.ts: the same gap on the account tree, which
  V-prime did not flag. Wiring only the space side would leave the role able
  to read a space but not its account-level contents.

Verified: `ALLOW: PLATFORM_SPACES_READER x A16 -> allow` now passes in the
canonical role-action matrix (24 passed, up from 23), after
authorizationPolicyResetAll + authorizationPolicyResetOnPlatform.

Also guards `rolesUser.spaces` against incomplete rows: `about.profile` IS
requested by the query, so a null means the row itself is broken (a credential
resolving to a space whose about/profile no longer exists).
`RolesResultSpace`'s constructor dereferenced it unguarded, so ONE such row
threw and took the whole query down instead of omitting a single entry — found
by the gql-live track. Skipped with a warning, matching the file's existing
`!space.authorization` handling. Same treatment for subspaces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…server-12/13)

- corr-server-12: license-plan/license-policy definition mutations now
  include global-support in their resolver-local synthetic policy — it
  reached these surfaces pre-feature via globalSupportPlatformAdmin's
  cascade off platform.authorization into licensingFramework.authorization,
  an implicit reach the corr-server-7 fix's synthetic policy dropped.
- corr-server-13: split the licensing-framework's `licensings` CRUD rule so
  it no longer also carries GRANT — that leaked A12's assign/revoke-license-
  plan privilege to platform-settings-admin, a holder A12's own declared
  owner/legacy-reacher lists never named, silently voiding the audit row on
  every such call. GLOBAL_LICENSE_MANAGER/GLOBAL_PLATFORM_MANAGER's legacy
  A12 reach moves to the dedicated licensePlanUsage rule instead.
…le-1 DoS precheck (corr-server-14/15, sec-server-11, qual-server-4)

- corr-server-14: recordGrantSuccess/recordRevokeSuccess now take a
  wasNoOp flag (captured via isInRole before the assign/remove call) and
  skip compensation when the grant/revoke was an idempotent no-op —
  compensating a no-op grant previously stripped a role the target
  legitimately held before the call; compensating a no-op revoke granted a
  role nobody asked for.
- corr-server-15: assertOrganizationSurfaceOrFail's two rejection branches
  now write a role_grant_rejected audit row before throwing, matching the
  engine's own rule-2 rejection path — this guard runs ahead of
  evaluateGrantOrFail/evaluateRevokeOrFail, so it was the one place a
  holder-kind rejection left no trace.
- sec-server-11: evaluateGrantOrFail/evaluateRevokeOrFail now probe rule 1
  (assigner capability) via the new PlatformRoleAssignmentRulesService
  .hasAssignerCapability() BEFORE getHeldPlatformRoles' ~10 isInRole round
  trips and before any rejection-audit write — an actor with no assignment
  capability at all is a probe, not an auditable rejected attempt.
  updateUser's serviceProfile-only branch now gates SET_SERVICE_PROFILE in
  the resolver itself before delegating, so an anonymous/unprivileged
  caller is rejected without the redundant DB lookup whose fail-closed
  audit writer previously errored on an empty-string actorID.
- qual-server-4: adds the previously-missing negative-path coverage for
  assertOrganizationSurfaceOrFail (legacy-role rejection, non-organization
  target rejection, and the corr-server-15 audit-write assertion).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… accounts (spec-server-14)

extendAuthorizationPolicy now takes the account's accountType and only
pushes the platformSupportOrgResources credential rule when it is
ORGANIZATION — the rule was previously appended to every account
regardless of host kind, letting Platform Support edit/CRUD a USER-hosted
account's innovation packs/hubs/templates too. A7 (spec row 7) and
FR-008(b) grant this right only over resources "belonging to an
organization".
…-list bypasses; seed break-glass global-admin (sec-server-9/10/15)

- sec-server-9: grantCredentialToActor/revokeCredentialFromActor
  (actor.resolver.mutations.ts) reject any of the 13 platform-*/feature-*
  role credentials outright, before the PLATFORM_ADMIN check — those
  credentials were grantable through this generic, un-censused surface
  the moment they joined the shared AuthorizationCredential/CredentialType
  enums, bypassing every assignment rule and the audit trail.
- sec-server-10: extracts role.set.resolver.fields.ts's holder-list
  predicate into the shared platform.role.holder.list.access.ts and
  applies it to actorsWithCredential/usersWithAuthorizationCredential
  (admin.authorization.resolver.queries.ts) whenever the requested
  credential is a platform-*/feature-* target role — previously gated
  only on READ_USERS, which every registered user holds, a complete
  bypass of the A20/A20b holder-list gate.
- sec-server-15: seeds admin@alkem.io with global-admin alongside
  platform-roles-admin so a freshly-bootstrapped environment always has
  at least one holder of the several [GLOBAL_ADMIN]-pinned policies this
  feature's round-1/round-2 fixes hardcoded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t coverage, and refresh the stale evidence doc (sec-server-5, qual-server-9, spec-server-17)

- sec-server-5: adds a per-file PLATFORM_ADMIN_SCAN_ALLOWLIST (rule 1b) so
  the two credential-admin mutations sec-server-9 fixed are scanned for a
  PLATFORM_ADMIN gate hit without flooding the ~24 unrelated files that
  reference the same privilege — plus an inverse completeness check
  (rule 4) that flags any @Mutation/@query resolver writing a credential
  directly or accepting a CredentialType/AuthorizationCredential/RoleName-
  typed argument that the census doesn't yet know about, independent of
  which privilege literal (if any) is scannable at the call site.
- qual-server-9: adds the missing subject-column-discriminator (subjectUserId
  XOR subjectOrganizationId) and outcome-mapping assertions to the audit
  writer's spec, plus dedicated coverage for resolveInitiatorRoleBestEffort's
  SELF fallback — none of the four had any assertion anywhere in the repo,
  so a swap in the writer or the fallback logic would have passed every
  existing test.
- spec-server-17: regenerates the evidence doc's census counts (112
  entries / 39 files / 101 distinct surfaces, with the 11-entry gap fully
  accounted for) and re-runs the T070l(b) root-cascade mutation-test
  perturbation against the post-reversal model (UPDATE_NAMEID, the one
  privilege the root rule still withholds by design) since the original
  "add DELETE back to ROOT_CASCADE" experiment is no longer performable —
  DELETE has been there permanently since the ninth analyze pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…olicy (corr-server-16)

actorsWithCredential/usersWithAuthorizationCredential checked the new
PLATFORM_ROLE_HOLDERS_READ/FEATURE_ROLE_HOLDERS_READ privileges against
getPlatformAuthorizationPolicy() — but those two privileges are granted
only on the platform role-set's authorization
(createAdditionalRoleSetCredentialRules), never on the platform entity's
own policy. The gate therefore denied every actor unconditionally,
including both declared owners, while the RoleName route
(roleSet.usersInRole) worked correctly through the same predicate.

Fetch the role-set via platformService.getRoleSetOrFail() and check the
holder-list predicate against roleSet.authorization, mirroring
platform.role.resolver.mutations.ts. AdminAuthorizationModule needed
PlatformModule added to its imports to resolve the new PlatformService
dependency — missing this broke real Nest bootstrap (schema:print) even
though every unit test passed, since Test.createTestingModule's
useMocker auto-supplies any missing token and can't catch a real DI
wiring gap.

Added a real-policy regression pin (two distinct hand-built
IAuthorizationPolicy objects — one mirroring the platform entity policy
with no holder-list rule, one mirroring the role-set policy that
actually grants it) so a regression back to checking platformAuthorization
fails the build instead of only a mocked isAccessGranted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…9 surfaces (corr-server-17, spec-server-18, corr-server-18)

The rule-1 (assigner-capability) DoS precheck in evaluateGrantOrFail/
evaluateRevokeOrFail used a role-specific hasAssignerCapability check —
so an actor holding ONE of the two assigner privileges (e.g. a Platform
Users Admin holding only FEATURE_ROLE_ASSIGN) attempting to grant a role
outside its family (a platform-* role, requiring GRANT_GLOBAL_ADMINS)
was misclassified as a fully unprivileged probe: the rejection-audit
write was skipped and the self-assignment-first rule ordering was
bypassed. Replaced with hasAnyAssignerCapability, which only exempts an
actor holding NEITHER assigner privilege at all — a genuine probe. A
privileged actor's cross-family escalation attempt now falls through to
evaluateOrFail + recordGrantRejected as intended, restoring both the
audit row and the self-assignment-first ordering. Added real-rules-
engine tests distinguishing the two cases.

Also: conversion.resolver.mutations.ts's recordCrossL0MoveAudit only
fired for the three cross-L0 space moves, though the resolver's own
constructor comment (and the A9 census) declares all seven mutations on
this file — the three convertSpace* conversions and
convertVirtualContributorToUseKnowledgeBase too — as sharing the SAME
synthetic platform-resource-admin policy. Renamed to
recordResourceMoveAudit(actorContext, resourceKind, resourceId) and wired
it into the remaining four surfaces, with tests asserting each writes its
platform_resource audit row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t re-grant to break-glass role (spec-server-19, sec-server-18, spec-server-22)

sec-server-15 (round-1/round-2 hardening) had seeded admin@alkem.io with
global-admin alongside platform-roles-admin, reasoning that several
[GLOBAL_ADMIN]-pinned legacy policies would otherwise be unreachable on a
fresh environment. That directly contradicted FR-013 ("no god-mode role
may be reintroduced... the seeded account holds Platform Roles Admin
only") and T055's identical requirement (spec-server-19), and combined
with the always-on restart re-grant loop, made every seeded credential of
every seeded account non-deprovisionable — a standing, cross-space-read-
capable principal an operator's revocation could never make durable
(sec-server-18).

Reverted rather than merely documented as an exception: the FR-013b
break-glass drill (T071) exercises platform-roles-admin recovery
specifically and never requires global-admin; the pinned legacy surfaces
this seed was propping open are themselves Slice-B-retiring god-mode
paths this feature exists to remove. admin@alkem.io now holds
platform-roles-admin only, matching T055.

Narrowed the restart-time credential re-grant (T053-T055's "grant loop
runs on every start" design) to the FR-013b break-glass recovery
credential (platform-roles-admin) alone — every other seeded credential
(platform-spaces-reader, any legacy role) is granted only when the
account is first created; a missing one on a pre-existing account is
treated as a deliberate, durable revocation, not silently reinstated.

Added a real-rules-engine bootstrap test (T054a/spec-server-22) proving
rule 4 (Audit Reader mutual exclusion) actually fires on the seed path
when two mutually exclusive Platform roles are configured for one
account, rather than only through a mocked evaluateSeedOrFail.

Residual risk recorded in the evidence doc: the seeded-account list
remains compiled-in (not environment-configurable) and the identity-
resolve legacy email-link flow is unchanged — both pre-existing,
orthogonal hardening items for follow-up work, not regressions this
feature introduces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…VERAGE gap (qual-server-10, spec-server-20)

Five PRIVILEGE_COVERAGE entries (USER_POLICY_SPEC, ORGANIZATION_POLICY_SPEC,
MOVE_CONTRIBUTION, UPDATE_CALLOUT_PUBLISHER, READ/A16) declared spec files
that never once mention the privilege they claim to cover — deleting the
real covering spec, or the real grant-set widening, failed no build.
Repointed at the specs that actually own each grant set
(user.service.authorization.spec.ts, organization.service.authorization.
spec.ts, callout.contribution/callout.service.authorization.spec.ts,
space.service.platform.roles.access.spec.ts) and added the two genuinely
missing exact-credential-array assertions (MOVE_CONTRIBUTION's
platform-resource-admin widening, UPDATE_CALLOUT_PUBLISHER's
platform-content-full-access widening) that had no coverage anywhere in
the repo.

Strengthened assertRealSpec (as assertRealSpecCovers) for the
PRIVILEGE_COVERAGE loop to also scan the declared spec's file content for
the privilege's enum key name, via a runtime value->key reverse map
(PRIVILEGE_KEY_BY_VALUE) — so a mis-pointed entry fails the build instead
of reporting false coverage.

Added a sixth inventory Record, MECHANISM_COVERAGE, closing the FR-033
gap for five load-bearing mechanisms that fit none of the prior five
Records: the role->credential map, the organization-inheritance prefix
filter and its cache invalidation, and two of the four bootstrap seeding
paths (rule evaluation, fatal conflict). All five specs already existed;
only the inventory entry and its build-time closure were missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…olation (spec-server-22)

T054a's runtime fix (seeding heldPlatformRoles from the target's existing
credentials, updated as the seed loop progresses) landed, but neither
mandated test case existed: no rules-service case exercising rule 4
(Audit Reader mutual exclusion) through evaluateSeedOrFail in either
direction. Added both — granting platform-audit-reader to a holder of
platform-support, and the reverse — asserting each throws naming the
mutual-exclusion violation. The paired real-rules-engine bootstrap
integration test landed in the previous commit alongside the seed data
fix it was written to prove.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s before skipping

Two defects the round-4 sec-server-18 narrowing introduced, both confirmed
against the spec rather than inferred.

spec-server-23 — FR-012's first clarification pass is explicit: "service
accounts are re-seeded automatically. Existing seeding grants their target
roles on every server start; only HUMAN holders fall under manual re-grant."
sec-server-18 scoped restart-eligibility to the break-glass
`platform-roles-admin` alone, so a revoked `platform-spaces-reader` on the
seeded service account stayed revoked across restarts. The correct
discriminator is the ACCOUNT KIND, not the credential: a revoked credential on
a service account is a broken integration, not an operator decision to
respect. sec-server-18's actual concern — durable revocation for humans — is
preserved unchanged.

spec-server-24 — `evaluateSeedOrFail` ran AFTER the restart-eligibility
`continue`, so on a pre-existing account a rule-violating users.json entry was
silently skipped instead of failing startup. That made the FR-028 Audit Reader
mutual exclusion, and every other seed rule, unenforceable on any environment
already bootstrapped once — i.e. every real one. Validation now runs before
the skip: a misconfigured seed is fatal wherever it is read, whether or not
this particular start would have acted on it.

lint clean (3123 files, tsc + biome).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 31, 2026 09:47
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 179c785d-1b77-4c60-90fa-9f77db830e69

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of lines (20,000). Try reducing the number of changed lines and requesting a review from Copilot again.

@github-actions

Copy link
Copy Markdown

📊 PR Metrics Summary

Title: feat(027): decompose Global Admin into 13 purpose-specific platform roles — Slice A (additive)
Total LOC Changed: 17372
Files Changed: 172
Proposed Review Type: HUMAN_AUGMENTED_LLM
Rationale:

  • high_risk_keyword
  • critical_path_change
  • LOC>200
  • files>10

Flags

  • High Risk Keyword
  • Critical Path Change
  • Composite High Risk Trigger

Thresholds

{
  "critical_loc": 200,
  "simple_loc": 100,
  "file_count": 10
}

@github-actions

Copy link
Copy Markdown

Schema Diff Summary: No blocking changes

Category Count
breaking 0
prematureRemoval 0
invalidDeprecation 0
deprecated 0
additive 51
info 2

Baseline branch: develop
Current schema MD5: 16ab44d7f1c7c8235158e4b6dc597426 (size 321285)
Previous schema MD5: 0d4505323913e76f67c7567f3e3e180a

…rver-25/27 + audit-write coverage

Six review findings, each with the test that would have caught it.

PRODUCT FIXES

- corr-server-19: `assertOrganizationSurfaceOrFail` wrote a
  `role_grant_rejected` audit row BEFORE any assigner-capability check, so any
  authenticated user could drive one attacker-chosen `platform_audit_entry`
  INSERT per request. It re-opened on the organization surface exactly what
  sec-server-11 closed on the user surface — the guard never got the probe
  because it sat upstream of `getRoleSetOrFail()` and had no policy to probe
  against. Both call sites now resolve the role set first, and the guard opens
  with the same NARROWED probe: it fires only for an actor holding neither
  assigner privilege, so a privileged cross-family attempt still falls through
  and is audited.

- sec-server-20: `removePlatformRoleFromUser` asserted `targetActorType:
  'user'` without verifying it. An organization id produced a real credential
  revocation, an audit row filed under `subjectUserId`, and an EntityNotFound
  telling the caller nothing happened — the first thing that checked the claim
  was the lookup at the END of the method. Now resolved up front, mirroring
  the grant surface.

- sec-server-23: the A10 consolidation onto one PLATFORM_SETTINGS_ADMIN
  privilege handed `setPlatformWellKnownVirtualContributor` the family's UNION
  of legacy reachers, and GLOBAL_PLATFORM_MANAGER gained a mutation it never
  held (this surface was on the PLATFORM_ADMIN catch-all: {GA,GS,GLM}; its
  siblings on PLATFORM_SETTINGS_ADMIN: {GA,GPM}). Pinned to a resolver-local
  policy carrying this surface's own pre-feature set plus the owning role —
  same shape as sec-server-7's emailChangePolicy. The audit call's
  legacyReachers list drops GPM too, so attribution cannot describe a caller
  the gate rejects.

- spec-server-27: `deleteUser`'s audit row was written only on the
  `platform-users-admin` branch, so a deletion by legacy `global-admin` — the
  normal path for the whole of Slice A, since FR-012 migrates no assignments —
  was recorded nowhere, contradicting FR-018. The writer could always
  attribute it (A5_LEGACY_REACHERS exists for exactly this case); only the
  condition was wrong. Now audited iff administrative, i.e. not self-service.

- spec-server-25: new `lifecycle: {declarationOnly: true}` marker. A4's
  `deleteUser` legacy pin and A5's entry both declared the same member, so the
  matrix generated `PLATFORM_USERS_ADMIN x A4 -> deny` alongside
  `x A5 -> allow`: one mutation, two contradictory expectations, and the DENY
  cell invoked a real destructive mutation against the shared fixture user.
  corr-ts-16's DENY-before-ALLOW ordering could not help — it orders cells
  WITHIN a surface and these are two. Reachability and pin-drift still walk
  the entry; only cell generation is suppressed.

TEST COVERAGE

- qual-server-11: FR-027's fail-closed compensation had ZERO coverage in this
  repo — deleting either the compensation or corr-server-14's no-op guard left
  the suite green. 8 tests pin the full truth table on both sides.

- qual-server-12: all 15 `recordEventForActor` sites now asserted (callout,
  innovation-hub, innovation-pack, account, space, admin-licensing), plus 31
  previously-unasserted `recordOperation`/`recordChangeForActor` sites in
  platform, ai-server, admin-authorization and admin-communication —
  including every FAILURE-outcome row, which is the half an operator actually
  needs after something half-ran and threw.

- qual-server-13: four A8 dual-path surfaces (`deleteSpace`, `deleteCallout`,
  `deleteInnovationHub`, `deleteInnovationPack`) were exercised only in the
  DENIED direction — every spec stubbed `isAccessGranted` to false, so the
  PLATFORM branch and its FR-018a audit write never executed. Both directions
  now asserted, via a single-privilege `grantOnly()` helper so which branch
  authorized the call is unambiguous.

Mutation-tested: reverting the spec-server-27 condition, the qual-server-11
no-op guard, the revoke-side compensation, or the corr-server-19 probe each
fails exactly its own tests and nothing else.

Verified: 8000 passed / 0 failed (687 files, up from 7932/686). Lint + tsc
clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HZSiMjuPXnj2Un3pd8WKC3
Copilot AI review requested due to automatic review settings July 31, 2026 15:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of lines (20,000). Try reducing the number of changed lines and requesting a review from Copilot again.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants