Implements the `GroupRoleMappings` contract against Spatie, so SCIM/directory groups map onto Spatie roles and stay in sync as membership changes. In v0.1.0 this contract stayed deny-by-default and group-mapped provisioning failed loud.
The safety property
Spatie records no grant origin, so naive reconciliation would strip roles an admin granted by hand. This release ships a ledger of the grants the bridge itself made (`id_spatie_pushed_assignments`), with a `pre_existing` flag for a role the subject already held when a push landed:
| Situation | Result |
|---|---|
| Pushed by the bridge, subject leaves the group | Revoked |
| Granted by an admin, subject leaves the group | Kept |
| Admin grant later duplicated by a push, push revoked | Kept |
| Admin revokes their own manual grant | Revoked |
All four are covered by tests.
Added
- `SpatieGroupRoleMappings` — `map`/`unmap`/`forOrganization`/`reconcileUser`/`reconcileGroup`, reading directory membership from the platform's tables (inactive directory users are non-members). Refuses an unknown role before writing the mapping, so a bad id cannot wedge later syncs.
- `ReconcileGroupRoles` listener on `directory.group.membership_changed` — the platform's own listener is builtin-driver only, so the bridge supplies its own.
- Own migration for the mapping + ledger tables (the platform's equivalents are gated off under the external driver).
Changed
- Real grant-source fidelity. `assignmentsForSubject()`/`assignmentsInOrganization()` now report `pushed` vs `manual` instead of always `manual` — what access-governance campaigns display.
- Docs: new `core-concepts/group-role-mapping.md`; the extension point rewritten from "not shipped" to how to replace it; README and threat-model scope corrected.
28 tests. Full gate green — Pint, PHPStan (level max), Pest, license-check, composer audit, SBOM.