# stapel-workspaces 0.22.1

Team workspaces and tenancy: personal and work workspaces with a role/capability mandate model (builtin owner/admin/member/viewer plus product roles declared in settings, namespaced capability strings with wildcard grants), email invitations with single-use tokens (public preview / decline / claim flow: an unregistered invitee gets a verified account via auth's login-grant seam), last-owner protection, per-workspace settings and storage quotas, member lifecycle events for business services (kick, role change), a billing entitlement seam that degrades to allow without billing installed, plus internal service-to-service membership and capability checks — the foundational scope other modules attach their workspace_id to.

Contract: axes 1 · surface 40 · extension points 5 · operations 26 · error codes 67.
Generated from docs/capabilities.json by `stapel-llms-txt` — do not edit; drift-gated by `make contract-check`.

## Configuration axes — what a product switches on
Settings keys; `default` is what you get by saying nothing. Turning an axis off unmounts the operations it gates.
- STREET_LANDING_MODE [enum, default "personal"] — What an un-invited signup lands with
  Controls what a freshly registered account gets when it showed up on its own (no invitation) — read by resolve_landing_workspace(user, origin="street"). "personal" (default) gives it its own Personal workspace and makes it that workspace's owner — the open self-serve / demo-cloud shape, unchanged from every release before this axis existed. "none" gives it no workspace at all: the account is a guest (permissions.is_guest) until an existing organization invites it — the closed-organization shape. Does not affect invited registrations, which always land through the separate accept-invitation path.

## Usage surface — call these before writing your own
This is the answer to "does Stapel already have something for X?". `instead of` names the outside symbol this one displaces.
### gate_function
- apply_first_login_policies — stapel_workspaces.services.apply_first_login_policies
  Demand an org's first-login steps of an account, failing closed.
- check_display_name — stapel_workspaces.services.check_display_name
  Ask stapel-profiles' own name canon by name (profiles.validate_display_name) what a display name may contain; returns that module's error.400.display_name_* key or None. Anything here that accepts or writes a name goes through it, and this module keeps no second, differently-strict copy — that drift is what it exists to stop. Best-effort: no provider means no canon applied here, never a locally invented rule.
- enforce_require_mfa — stapel_workspaces.services.enforce_require_mfa
  Sweep a workspace's members via auth.mfa_status, suspend the unenrolled, fail-open on an auth hiccup.
- require_capability — stapel_workspaces.permissions.require_capability
  Membership if the user holds a capability, else None — capability-gate for a view.
- require_role — stapel_workspaces.permissions.require_role
  Membership if the user holds at least a minimum role, else None — this module's own role-gate.
- suspend_member — stapel_workspaces.services.suspend_member
  Suspend a membership without removing it (idempotent) — instead of writing suspended_at directly.
- unsuspend_member — stapel_workspaces.services.unsuspend_member
  Lift a membership suspension (idempotent) — counterpart of suspend_member.
### predicate
- capability_matches — stapel_workspaces.capabilities.capability_matches
  Match one granted capability string (wildcards "*" and "prefix.*") against a requested one — reach for this instead of writing your own prefix/wildcard comparison; stapel-core's cross-service consumer helper mirrors this exact semantics.
- has_active_mandate — stapel_workspaces.permissions.has_active_mandate
  True if the user holds an active (accepted, non-suspended) membership ANYWHERE, workspace-agnostic — the primitive behind is_guest; use it when the question is "has this account joined anything at all", not "is it a member of workspace W".
- has_capability — stapel_workspaces.permissions.has_capability
  True if the user's active membership grants a capability — the everyday authorization check.
- is_guest — stapel_workspaces.permissions.is_guest
  THE canonical guest predicate (mandate-model vardict 2026-08-03): true for an authenticated user with no active mandate anywhere — an anonymous session qualifies by construction. Branch dashboards/onboarding on this, never on request.user.is_authenticated.
- role_at_least — stapel_workspaces.permissions.role_at_least
  Role-ordering check — instead of hardcoding a role order.
- role_exceeds_rank — stapel_workspaces.capabilities.role_exceeds_rank
  The invite/role-grant rank-guard (mandate-model vardict 2026-08-03): true if a role outranks the acting role, so a product-defined role below admin that still carries members.invite/members.role.change/members.provision cannot hand out a rank above its own. Call this before creating any membership, invitation or provision row that assigns a role; unknown ranks fail closed.
- role_has_capability — stapel_workspaces.capabilities.role_has_capability
  Does this role grant this capability, no membership needed — for role definitions/invite pickers.
### factory
- accept_invitation — stapel_workspaces.services.accept_invitation
  Resolve a pending invitation into a membership — entitlement check, first-login policies, emits together.
- capabilities_for — stapel_workspaces.capabilities.capabilities_for
  Granted capability strings of a role — primitive behind role_has_capability.
- capability_level — stapel_workspaces.capabilities.capability_level
  "standard"/"high" step-up level of a capability — check before gating on step-up.
- create_invitation — stapel_workspaces.services.create_invitation
  Create a single-use, TTL'd invitation and best-effort deliver it.
- create_workspace — stapel_workspaces.services.create_workspace
  Create a workspace and atomically seed its owner membership — instead of a bare, ownerless row.
- decline_invitation — stapel_workspaces.services.decline_invitation
  The invitee's terminal "no" (≠ revoke), same row-lock as accept.
- effective_capability_levels — stapel_workspaces.capabilities.effective_capability_levels
  Builtin capability step-up levels merged with the CAPABILITY_LEVELS override.
- effective_roles — stapel_workspaces.capabilities.effective_roles
  Builtin roles merged with STAPEL_WORKSPACES["ROLES"] — read instead of merging the setting yourself.
- ensure_personal_workspace — stapel_workspaces.services.ensure_personal_workspace
  Idempotent get-or-create of a user's Personal workspace; prefer resolve_landing_workspace.
- get_membership — stapel_workspaces.permissions.get_membership
  Fetch a user's WorkspaceMember row (active by default; include_suspended=True also returns a suspended one, e.g. to render error.403.membership_suspended instead of a bare not-a-member 403) — defines what "active membership" means.
- issue_invitation_login_grant — stapel_workspaces.services.issue_invitation_login_grant
  Mint an auth-backed login grant for a not-yet-registered invitee's claim step (0.7).
- lift_deactivation_suspensions_for_user — stapel_workspaces.services.lift_deactivation_suspensions_for_user
  Reverse deactivation suspensions on reactivation, leaving no_mfa ones alone.
- lift_no_mfa_suspensions — stapel_workspaces.services.lift_no_mfa_suspensions
  Lift every no_mfa suspension in a workspace — call when require_mfa turns off.
- lift_no_mfa_suspensions_for_user — stapel_workspaces.services.lift_no_mfa_suspensions_for_user
  Lift a user's no_mfa suspensions only, wired to auth's user.mfa_enabled.
- preferred_workspace_id_for — stapel_workspaces.services.preferred_workspace_id_for
  The caller's stated home workspace id, or '' — active-membership filtered, so it goes quiet during a suspension.
- provision_member — stapel_workspaces.services.provision_member
  Create an org-provisioned member end to end (debit, auth.provision_user, membership, credentials).
- resend_invitation — stapel_workspaces.services.resend_invitation
  Rotate an invitation's token, extend its TTL and re-deliver — instead of editing fields by hand.
- reset_member_password — stapel_workspaces.services.reset_member_password
  Reset a member's password on the org's order, with the audit emit and security-signal letter.
- resolve_landing_workspace — stapel_workspaces.services.resolve_landing_workspace
  The canon landing-mandate policy (mandate-model vardict 2026-08-03) for a freshly (re)appearing account — call this from a user.registered subscriber instead of unconditionally minting a personal workspace; reads STREET_LANDING_MODE and is a deliberate no-op for an invited origin, so a subscriber cannot double-mint or race the invite's own membership.
- revoke_invitation — stapel_workspaces.services.revoke_invitation
  Withdraw a live invitation under the same compare-and-set lock as accept/decline.
- role_rank — stapel_workspaces.capabilities.role_rank
  Numeric rank of a role, or None — prefer role_at_least/role_exceeds_rank for yes/no.
- security_settings_for — stapel_workspaces.services.security_settings_for
  Typed, defaulted view of Workspace.settings["security"] — instead of indexing the JSON bag directly.
- set_preferred_workspace — stapel_workspaces.services.set_preferred_workspace
  Record (or clear) the person's explicit home workspace — the choice DEFAULT_WORKSPACE_ID yields to. Validated against active membership.
- set_profile_display_name — stapel_workspaces.services.set_profile_display_name
  Write a user's CANONICAL display name through profiles.set_display_name — the named comm write stapel-profiles publishes, which owns the canon, the swappable model, get-or-create and the profile.changed emission. Returns None on success or the error key to answer with (profiles' own 400 key, error.503.profiles_not_configured when nothing is wired, error.503.profiles_unavailable when the call failed). Topology-independent; WorkspaceMember.display_name_hint is only a pre-profile placeholder, not this.
- suspend_memberships_for_deactivated_user — stapel_workspaces.services.suspend_memberships_for_deactivated_user
  Suspend every membership of a deactivated account, wired to auth's user.deactivated.
- suspend_memberships_without_mfa — stapel_workspaces.services.suspend_memberships_without_mfa
  Per-user no_mfa suspension sweep wired to auth's user.mfa_disabled — call to force it on demand.

## Extension points — what a product replaces, fork-free
- AUTH_USER_MODEL [swappable_model]
  Standard Django user swap — owner/member/inviter FKs target settings.AUTH_USER_MODEL; the module resolves users via get_user_model(), never a concrete class.
- Workspace.settings [json_settings]
  A JSON bag on every workspace, PATCHable through the workspace API — the sanctioned place for app-level per-workspace preferences without schema changes.
- capability_levels [merge_registry]
  Raise individual capabilities to the high step-up level via STAPEL_WORKSPACES["CAPABILITY_LEVELS"] (merged over the builtin members.provision / workspace.security.manage); enforcement arrives with the provisioning wave.
- role_capabilities [merge_registry]
  Declare product roles and their capability strings in STAPEL_WORKSPACES["ROLES"] (last-wins over the builtin four; owner is system-protected). Check them via has_capability/require_capability (in-service) or the workspaces.check_capability / check_membership comm Functions (cross-service, no import); frontends read GET /roles and my_capabilities.
- serializer_seams [class_override]
  Every public view declares request/response serializer seams (SerializerSeamsMixin) — subclass the view, override the attribute, remount the URL.

## Fits with — fleet dependencies
- stapel-auth (optional) — emits the user.registered event that triggers personal-workspace creation and issues the authenticated sessions the workspace endpoints require; answers auth.issue_login_grant for the invitation claim step and auth.provision_user / auth.mfa_status for org provisioning and the require_mfa sweep; answers auth.admin_reset_password (>= 0.18) for the administrative password reset — which is where the credential work, the session revocation, the audit row and the refusal to touch a staff account live, because the caller knows who administers a workspace and nothing about who administers the deployment; answers auth.apply_first_login_policies (>= 0.17) when an organization has CONFIGURED first-login policies — that seam alone is fail-closed: an acceptance that cannot raise a demanded policy is refused (503) rather than admitting an unhardened member, while an organization that configured nothing never calls it at all; every other auth seam either degrades to an honest 503 (claim) or is never reached; decoupled via the comm bus
- stapel-billing (optional) — answers billing.check_entitlement for org creation and member-seat ceilings; without it every entitlement check degrades to allow (OSS default), decoupled via the comm bus
- stapel-core (required) — comm bus (workspace.* emits, workspaces.check_membership Function, user.registered consumer), JWT authentication, GDPR provider registry
- stapel-notifications (optional) — delivers the letters this module requests via request_notification: workspace.invitation (create), workspace.invitation.reminder (admin resend, >= 0.6.1), workspace.member_password_reset (>= 0.6.1) and the workspace.provisioned_account / workspace.mfa_* family; every one is best-effort — the workspace operation succeeds regardless, and an older notifications catalog drops the unknown type with a logged error
- stapel-profiles (optional) — the canonical source of a member's display name — this module stores only a name HINT typed at invite/provision time (WorkspaceMember.display_name_hint), never a second copy of the real field. Reads: MemberResponse.display_name resolves through the profiles seam (in-process Profile lookup first, then that module's public POST /batch over PROFILES_SERVICE_URL), falling back to the hint when profiles is absent, unreachable, or has no name yet. Writes (0.19): the roster's PATCH members/{id}/name sets Profile.display_name and publishes profile.changed, and both name-edit endpoints hold the value to that module's validate_display_name canon rather than a second rule of their own — profiles registers no comm Function and publishes no write-somebody-else's-name operation, so the write only exists in a process where profiles is mounted and answers error.503.profiles_unavailable where it is not. Never a hard import either way

## HTTP operations (26) — call by operationId, never by a typed path
Paths are relative to `/workspaces/api/v1/`.
### Internal
- GET /internal/{workspace_id}/members/{user_id} — workspaces_api_v1_internal_members_retrieve
- POST /internal/users/{user_id}/personal — workspaces_api_v1_internal_users_personal_create
### Members
- POST /invitations/accept — workspaces_api_v1_invitations_accept_create
- POST /invitations/{token}/claim — workspaces_api_v1_invitations_claim_create
- POST /invitations/{token}/decline — workspaces_api_v1_invitations_decline_create
- GET /{workspace_id}/invitations — workspaces_api_v1_invitations_list
- PATCH /{workspace_id}/invitations/{invitation_id}/name — workspaces_api_v1_invitations_name_partial_update
- POST /{workspace_id}/invitations/{invitation_id}/resend — workspaces_api_v1_invitations_resend_create
- GET /invitations/{token} — workspaces_api_v1_invitations_retrieve
- POST /{workspace_id}/invitations/{invitation_id}/revoke — workspaces_api_v1_invitations_revoke_create
- DELETE /{workspace_id}/members/{user_id} — workspaces_api_v1_members_destroy
- POST /{workspace_id}/members/invite — workspaces_api_v1_members_invite_create
- GET /{workspace_id}/members — workspaces_api_v1_members_list
- PATCH /{workspace_id}/members/{user_id}/name — workspaces_api_v1_members_name_partial_update
- PATCH /{workspace_id}/members/{user_id} — workspaces_api_v1_members_partial_update
- POST /{workspace_id}/members/{user_id}/password/reset — workspaces_api_v1_members_password_reset_create
- POST /{workspace_id}/members/provision — workspaces_api_v1_members_provision_create
### Workspaces
- POST / — workspaces_api_v1_create
- DELETE /{workspace_id} — workspaces_api_v1_destroy
- GET /instance — workspaces_api_v1_instance_retrieve
- DELETE /me/preferred-workspace — workspaces_api_v1_me_preferred_workspace_destroy
- PUT /me/preferred-workspace — workspaces_api_v1_me_preferred_workspace_update
- PATCH /{workspace_id} — workspaces_api_v1_partial_update
- GET / — workspaces_api_v1_retrieve
- GET /{workspace_id} — workspaces_api_v1_retrieve_2
- GET /roles — workspaces_api_v1_roles_retrieve

## Error codes (67) — the StapelError envelope
Render `t(code, params)`; branch UX on the remediation. Localized text lives in docs/errors.<lang>.md, not here.
- error.400.already_workspace_member [400] fix_input
- error.400.bad_request [400] fix_input
- error.400.captcha_invalid [400] retry
- error.400.captcha_required [400] retry
- error.400.display_name_emoji [400] fix_input
- error.400.display_name_forbidden_chars [400] fix_input
- error.400.display_name_invisible_chars [400] fix_input
- error.400.display_name_too_short [400] fix_input
- error.400.expected_list [400] fix_input
- error.400.field.blank [400] fix_input {field}
- error.400.field.does_not_exist [400] fix_input {field}
- error.400.field.invalid [400] fix_input {field}
- error.400.field.invalid_choice [400] fix_input {field}
- error.400.field.max_length [400] fix_input {field,max_length}
- error.400.field.max_value [400] fix_input {field,max_value}
- error.400.field.min_length [400] fix_input {field,min_length}
- error.400.field.min_value [400] fix_input {field,min_value}
- error.400.field.null [400] fix_input {field}
- error.400.field.required [400] fix_input {field}
- error.400.field.unique [400] fix_input {field}
- error.400.invalid_ad_id [400] fix_input
- error.400.invalid_provision_username [400] fix_input
- error.400.invalid_role [400] fix_input
- error.400.invitation_already_used [400] fix_input
- error.400.invitation_declined [400] contact_support
- error.400.invitation_expired [400] contact_support
- error.400.invitation_revoked [400] contact_support
- error.400.validation_error [400] fix_input
- error.400.verification_failed [400] verify
- error.400.verification_invalid_factor [400] verify
- error.400.workspace_slug_taken [400] fix_input
- error.401.unauthorized [401] reauthenticate
- error.402.entitlement_required [402] fix_input
- error.402.member_limit_reached [402] fix_input {limit}
- error.402.payment_required [402] retry
- error.403.forbidden [403] retry
- error.403.forbidden_workspace [403] contact_support
- error.403.last_owner_cannot_be_removed [403] fix_input
- error.403.membership_suspended [403] fix_input {reason}
- error.403.missing_capability [403] contact_support {capability}
- error.403.network_blocked [403] contact_support
- error.403.role_exceeds_inviter_rank [403] fix_input {role}
- error.403.verification_enrollment_required [403] verify
- error.403.verification_required [403] verify
- error.404.ad_not_found [404] retry
- error.404.invitation_not_found [404] fix_input
- error.404.member_not_found [404] fix_input
- error.404.not_found [404] retry
- error.404.verification_challenge_not_found [404] verify
- error.404.workspace_not_found [404] fix_input
- error.405.method_not_allowed [405] retry
- error.406.not_acceptable [406] retry
- error.408.request_timeout [408] retry
- error.409.conflict [409] fix_input
- error.409.email_already_registered [409] reauthenticate
- error.410.gone [410] retry
- error.413.payload_too_large [413] retry
- error.415.unsupported_media_type [415] retry
- error.422.unprocessable_entity [422] wait_and_retry
- error.423.locked [423] wait_and_retry
- error.423.verification_locked [423] wait_and_retry
- error.429.rate_limit [429] wait_and_retry {retry_after_minutes}
- error.429.too_many_requests [429] wait_and_retry
- error.500.internal [500] contact_support
- error.503.auth_unavailable [503] wait_and_retry
- error.503.profiles_not_configured [503] contact_support
- error.503.profiles_unavailable [503] wait_and_retry
