Skip to content

feat: promote external collaborators to first-class role#582

Merged
spashii merged 2 commits into
mainfrom
new-role-external
May 21, 2026
Merged

feat: promote external collaborators to first-class role#582
spashii merged 2 commits into
mainfrom
new-role-external

Conversation

@ussaama
Copy link
Copy Markdown
Contributor

@ussaama ussaama commented May 21, 2026

Replaces the two-field representation (workspace_membership.is_external
bool + role='member') with a single role='external' value across the
schema, API, and frontend. Externals share the unified seat pool with
members per the PRD — no separate cap, policy-only differences.

  • Drop workspace_membership.is_external and workspace_invite.include_org_membership; add 'external' to both role enums (snapshot updated)
  • Centralise role checks via ROLE_HIERARCHY; remove effective_workspace_role()
  • Unify seat counting through compute_effective_seat_state (covers derived org admins, so admin billing rollup, /usage, and enforcement all agree)
  • Enforce the role='external' ⟺ no org_membership invariant at every write path (invites, onboarding, accept-by-id/hash, heal)
  • workspace_settings role dropdown rejects both directions of the external boundary instead of only one
  • Frontend: rename guest → external in roles.ts, invite wizards, member lists, and badges; filter org-member picker by role string instead of the optional is_external field
  • Admin usage-and-billing: Seats column and footer now show members + externals (the unified pool the overage charges against)

Refs: docs/adr/0003-external-as-role.md,
docs/prds/external-role-and-unified-seat-counting.md

Summary by CodeRabbit

Release Notes

  • New Features

    • Introduced "External" as a workspace role to manage outside collaborators
    • Unified seat capacity model combining members and externals
    • Enhanced usage breakdown showing separate counts for Members, Externals, and Pending invites
  • Documentation

    • Added ADR-0003 defining the external role and unified seat model
    • Added PRD documenting product changes and implementation details

Review Change Stack

  Replaces the two-field representation (workspace_membership.is_external
  bool + role='member') with a single role='external' value across the
  schema, API, and frontend. Externals share the unified seat pool with
  members per the PRD — no separate cap, policy-only differences.

  - Drop workspace_membership.is_external and workspace_invite.include_org_membership;
    add 'external' to both role enums (snapshot updated)
  - Centralise role checks via ROLE_HIERARCHY; remove effective_workspace_role()
  - Unify seat counting through compute_effective_seat_state (covers derived
    org admins, so admin billing rollup, /usage, and enforcement all agree)
  - Enforce the role='external' ⟺ no org_membership invariant at every
    write path (invites, onboarding, accept-by-id/hash, heal)
  - workspace_settings role dropdown rejects both directions of the
    external boundary instead of only one
  - Frontend: rename guest → external in roles.ts, invite wizards, member
    lists, and badges; filter org-member picker by role string instead
    of the optional is_external field
  - Admin usage-and-billing: Seats column and footer now show
    members + externals (the unified pool the overage charges against)

  Refs: docs/adr/0003-external-as-role.md,
        docs/prds/external-role-and-unified-seat-counting.md
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 21, 2026

Warning

Rate limit exceeded

@ussaama has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 38 minutes and 39 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 43616f01-2c5f-4d50-bc85-8351e4b99aef

📥 Commits

Reviewing files that changed from the base of the PR and between 3db5e1c and 7ae3bd7.

📒 Files selected for processing (4)
  • echo/frontend/src/routes/organisation/OrganisationRoute.tsx
  • echo/scripts/seed_dev.py
  • echo/server/dembrane/api/v2/onboarding.py
  • echo/server/dembrane/api/v2/orgs.py

Walkthrough

This PR migrates workspace collaboration access from a is_external boolean flag with guest-based seat tracking to an explicit external workspace role and unified seat capacity model. The role='external' invariant replaces include_org_membership; external members are denied org membership at write-time. Seats, usage responses, and policies are unified across member/external tiers. Frontend UI terminology and type definitions updated accordingly.

Changes

External Role Unification

Layer / File(s) Summary
Schema Changes & Data Models
echo/directus/sync/snapshot/fields/workspace_invite/role.json, echo/directus/sync/snapshot/fields/workspace_membership/role.json, echo/scripts/create_schema.py, echo/scripts/seed_dev.py
Directus snapshots and schema generation add external role option, remove is_external boolean field, and drop include_org_membership from workspace invites.
Policy Layer & Role Hierarchy
echo/server/dembrane/policies.py
Policy presets renamed from guest to external; effective_workspace_role() helper removed; new ROLE_HIERARCHY constant added for role-escalation validation.
Unified Seat Capacity Model
echo/server/dembrane/seat_capacity.py, echo/server/dembrane/inheritance.py
Seat counting refactored to treat external roles as unified pool members; is_external field removed from member records; pending invites bucketed by role; legacy assert_can_add_member/assert_can_add_guest aliases removed.
Member Inheritance & Access Control
echo/server/dembrane/inheritance.py, echo/server/dembrane/api/v2/bff/_access.py, echo/server/dembrane/api/v2/middleware.py
get_effective_members() and workspace context middleware updated to remove is_external handling; BFF access layer simplified to use role directly without guest-clamp behavior.
Workspace Usage & Billing Response Models
echo/server/dembrane/api/v2/workspaces.py, echo/server/dembrane/api/v2/admin.py
WorkspaceUsageResponse schema includes member_count, external_count, pending_count replacing guest_count; seat bucketing uses role-based member detection; usage authorization guard for externals removed.
API Schema & Request/Response Definitions
echo/server/dembrane/api/v2/schemas.py
WorkspaceInviteRequest role constrained to Literal[...]; is_org_member field removed; workspace schema drops is_external field.
Invite & Accept API Endpoints
echo/server/dembrane/api/v2/invites.py, echo/server/dembrane/api/v2/me.py, echo/server/dembrane/api/v2/onboarding.py
Invite endpoint uses role-based external detection and enforces role hierarchy; org-membership follows external invariant; accept flows derive external-ness from invite role; notification copy changed from guest to external.
Project & Template Authorization
echo/server/dembrane/api/project.py, echo/server/dembrane/api/template.py, echo/server/dembrane/api/v2/projects.py
Project pin/visibility and template CRUD endpoints use role == "external" for external detection instead of is_external flag; error messaging updated.
Workspace Settings & Member Management
echo/server/dembrane/api/v2/workspace_settings.py
WorkspaceMember response model removes is_external field; role change endpoint adds cross-boundary guard preventing external/non-external flips.
Organization & Access Request Endpoints
echo/server/dembrane/api/v2/orgs.py, echo/server/dembrane/api/v2/access_requests.py
Org member listing queries role == "external"; org usage models update from guest_count to external_count; access request approval uses unified assert_can_add_seat.
Frontend Type Definitions & Workspace Context
echo/frontend/src/hooks/useWorkspace.ts, echo/frontend/src/hooks/useWorkspaceUsage.ts, echo/frontend/src/lib/roles.ts
WorkspaceSummary removes is_external field; WorkspaceUsageData updated with member/external/pending_count replacing guest_count; role display updated for external roles.
Frontend: Workspace Invite Wizard
echo/frontend/src/components/workspace/WorkspaceInviteWizard.tsx
Invite wizard prop renamed from guestInviteBlocked to externalInviteBlocked; request payload changed to { email, role }; external rows treated as role: "external"; step labels and copy updated.
Frontend: Usage & Seat Display Cards
echo/frontend/src/components/workspace/UsageCard.tsx, echo/frontend/src/components/workspace/SeatCapBanner.tsx
Seat breakdown changed to show member_count, external_count, pending_count with conditional row display; terminology changed from guests to externals.
Frontend: Organization & Project Routes
echo/frontend/src/routes/organisation/OrganisationRoute.tsx, echo/frontend/src/routes/project/ProjectsHome.tsx, echo/frontend/src/routes/onboarding/OnboardingRoute.tsx
Organization route updated with externals filter; add-to-workspace payload changed to { email, role }; project/onboarding routes use role === "external" for external detection.
Frontend: Workspace Settings & Selector
echo/frontend/src/routes/workspaces/WorkspaceSettingsRoute.tsx, echo/frontend/src/routes/workspaces/WorkspaceSelectorRoute.tsx
Workspace settings detects iAmExternal via role with locked external badge and promotion tooltip; selector uses role === "external" for workspace categorization; section heading updated to "As an external".
Frontend: Project Usage & My Access
echo/frontend/src/components/project/ProjectUsageAndSharing.tsx, echo/frontend/src/components/settings/MyAccessCard.tsx
ProjectUsageAndSharing derives is_external from role === "external"; MyAccessCard removes is_external conditional and uses role directly for badge.
Frontend: Display Utilities & Admin Settings
echo/frontend/src/lib/roles.ts, echo/frontend/src/routes/admin/AdminSettingsRoute.tsx, echo/frontend/src/components/workspace/OrganisationUsageRollup.tsx
displayRole() renders external as "External"; admin billing route adds external_count field and includes externals in seat computation; organization usage rollup updates from guest_count to external_count.
Development & Seeding Scripts
echo/scripts/backfill_direct_memberships.py, echo/scripts/matrix_smoke.py
Seed script removes is_external from membership creation; backfill script removes is_external from payloads; smoke test checks unified external-based model.
Architecture & Product Documentation
echo/docs/adr/0003-external-as-role.md, echo/docs/prds/external-role-and-unified-seat-counting.md
ADR-0003 documents external role design, role hierarchy, and invite/accept invariants; PRD documents full scope including UI, API, i18n, and analytics changes.
Policy & Seat Capacity Tests
echo/server/tests/test_policies.py, echo/server/tests/test_seat_capacity.py
New test suite validates external preset allowlist/denylist and role hierarchy; seat capacity tests refactored with role-based helpers and unified member/external cap tests.
Onboarding & Gateway Tests
echo/server/tests/test_onboarding.py, echo/server/tests/test_usage_gates_api.py, echo/server/tests/test_tier_capacity.py
Tests updated to mock assert_can_add_seat, remove include_org_membership, and use new seat response shape with member/external/pending_count.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • Dembrane/echo#577: Updates seat-capacity enforcement logic and related invite/accept flows; code-level dependency on unified seat counting refactor.

Suggested labels

Feature, improvement

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch new-role-external

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
echo/frontend/src/routes/organisation/OrganisationRoute.tsx (1)

837-843: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Stale terminology: "guests" should be "externals"

yo this copy still says "guests" but we just shipped the whole external role migration. ship it consistent fam.

 <Text size="xs" c="dimmed">
   <Trans>
     Admins can reach every workspace in this organisation. Members
-    and guests only see the workspaces they've been given access
+    and externals only see the workspaces they've been given access
     to.
   </Trans>
 </Text>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@echo/frontend/src/routes/organisation/OrganisationRoute.tsx` around lines 837
- 843, Update the stale UI copy in OrganisationRoute by replacing the word
"guests" with "externals" inside the Trans-wrapped text (found in
OrganisationRoute.tsx where the Text component renders that description). Ensure
the Trans string is updated exactly to "Admins can reach every workspace in this
organisation. Members and externals only see the workspaces they've been given
access to." and adjust any related translation keys/translations if your i18n
backend requires updating.
echo/server/dembrane/api/v2/onboarding.py (1)

107-112: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

invited_by is still used later but no longer fetched.

Both the cap-blocked path and the invite-accepted path read invite.get("invited_by"). After this change those notifications lose their recipient and silently stop firing. Add invited_by back to the fields list or remove the downstream dependency.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@echo/server/dembrane/api/v2/onboarding.py` around lines 107 - 112, The patch
removed "invited_by" from the fields list but downstream code still calls
invite.get("invited_by") (used in the cap-blocked and invite-accepted
notification paths), causing missing recipients; restore "invited_by" to the
fields array in onboarding.py (the same dict that currently contains "id",
"workspace_id", "role", "expires_at") so those notification paths can read
invite.get("invited_by"), or alternatively remove/replace the
invite.get("invited_by") usages in the cap-blocked and invite-accepted logic if
you intend to stop relying on that field.
echo/server/dembrane/api/v2/orgs.py (1)

202-250: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Subtract internal org members from this external count.

_count_external_in_organisation() says it counts users with role="external" and no org_membership, but the implementation only filters workspace_membership.role == "external". If a stale row survives during migration/heal, this header count will over-report while list_org_members() hides the same user via internal_set, so the two org screens drift.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@echo/server/dembrane/api/v2/orgs.py` around lines 202 - 250,
_count_external_in_organisation currently only counts workspace_membership rows
with role="external" and doesn't exclude users who still have an org_membership;
fetch org membership user IDs for this org (via async_directus.get_items on
"org_membership" with filter org_id=_eq org_id and deleted_at _null and fields
["user_id"]), build a set of internal org user_ids, and subtract that set from
the external workspace user_id set before returning the count; update references
in the function (_count_external_in_organisation, async_directus.get_items,
workspace_membership, org_membership) and ensure you guard against non-list
returns and missing user_id keys as the existing code does.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@echo/scripts/seed_dev.py`:
- Line 715: The log message prints a misleading seeded email; update the print
call that currently outputs "grace@external" to the actual seeded account
"grace@seed.dembrane.dev" (i.e., modify the print(...) invocation that logs
"external grace@external on Acme/Default" so it shows "external
grace@seed.dembrane.dev on Acme/Default") to ensure seed output matches the real
seeded account.

In `@echo/server/dembrane/api/v2/onboarding.py`:
- Around line 137-143: Move the seat-cap check so it only runs when a new
membership will actually be created: check for an existing workspace_membership
(and its accepted_at state) before calling assert_can_add_seat() and only call
assert_can_add_seat() when no accepted membership exists and you are about to
create one; apply the same change to the duplicate section around the 229-252
logic. Ensure you reference and use the existing workspace_membership
variable/lookup and the accepted_at field to decide whether to skip the cap
check so retries or partial writes don’t 402 an idempotent invite.
- Around line 133-135: The current invite-processing loop sets
invite_role/is_external and then writes external workspace rows even if an
earlier invite already created org_membership; before creating or inserting an
external membership row, re-check whether org_membership already exists for that
user+org (e.g., query the same org_membership lookup used when creating internal
rows) and skip or reject the invite if org_membership is present so you never
create role="external" for users who already have org_membership; apply the same
guard to the other invite handling block that mirrors this logic (the later
block referenced in the comment).

---

Outside diff comments:
In `@echo/frontend/src/routes/organisation/OrganisationRoute.tsx`:
- Around line 837-843: Update the stale UI copy in OrganisationRoute by
replacing the word "guests" with "externals" inside the Trans-wrapped text
(found in OrganisationRoute.tsx where the Text component renders that
description). Ensure the Trans string is updated exactly to "Admins can reach
every workspace in this organisation. Members and externals only see the
workspaces they've been given access to." and adjust any related translation
keys/translations if your i18n backend requires updating.

In `@echo/server/dembrane/api/v2/onboarding.py`:
- Around line 107-112: The patch removed "invited_by" from the fields list but
downstream code still calls invite.get("invited_by") (used in the cap-blocked
and invite-accepted notification paths), causing missing recipients; restore
"invited_by" to the fields array in onboarding.py (the same dict that currently
contains "id", "workspace_id", "role", "expires_at") so those notification paths
can read invite.get("invited_by"), or alternatively remove/replace the
invite.get("invited_by") usages in the cap-blocked and invite-accepted logic if
you intend to stop relying on that field.

In `@echo/server/dembrane/api/v2/orgs.py`:
- Around line 202-250: _count_external_in_organisation currently only counts
workspace_membership rows with role="external" and doesn't exclude users who
still have an org_membership; fetch org membership user IDs for this org (via
async_directus.get_items on "org_membership" with filter org_id=_eq org_id and
deleted_at _null and fields ["user_id"]), build a set of internal org user_ids,
and subtract that set from the external workspace user_id set before returning
the count; update references in the function (_count_external_in_organisation,
async_directus.get_items, workspace_membership, org_membership) and ensure you
guard against non-list returns and missing user_id keys as the existing code
does.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: fbed86b1-d7c5-4d99-a336-53deacb257d8

📥 Commits

Reviewing files that changed from the base of the PR and between b5a11bd and 3db5e1c.

📒 Files selected for processing (48)
  • echo/directus/sync/snapshot/fields/workspace_invite/include_org_membership.json
  • echo/directus/sync/snapshot/fields/workspace_invite/role.json
  • echo/directus/sync/snapshot/fields/workspace_membership/is_external.json
  • echo/directus/sync/snapshot/fields/workspace_membership/role.json
  • echo/docs/adr/0003-external-as-role.md
  • echo/docs/prds/external-role-and-unified-seat-counting.md
  • echo/frontend/src/components/organisation/OrganisationInviteWizard.tsx
  • echo/frontend/src/components/project/ProjectUsageAndSharing.tsx
  • echo/frontend/src/components/settings/MyAccessCard.tsx
  • echo/frontend/src/components/workspace/OrganisationUsageRollup.tsx
  • echo/frontend/src/components/workspace/SeatCapBanner.tsx
  • echo/frontend/src/components/workspace/UsageCard.tsx
  • echo/frontend/src/components/workspace/WorkspaceInviteWizard.tsx
  • echo/frontend/src/hooks/useWorkspace.ts
  • echo/frontend/src/hooks/useWorkspaceUsage.ts
  • echo/frontend/src/lib/roles.ts
  • echo/frontend/src/routes/admin/AdminSettingsRoute.tsx
  • echo/frontend/src/routes/onboarding/OnboardingRoute.tsx
  • echo/frontend/src/routes/organisation/OrganisationRoute.tsx
  • echo/frontend/src/routes/project/ProjectsHome.tsx
  • echo/frontend/src/routes/workspaces/WorkspaceSelectorRoute.tsx
  • echo/frontend/src/routes/workspaces/WorkspaceSettingsRoute.tsx
  • echo/scripts/backfill_direct_memberships.py
  • echo/scripts/create_schema.py
  • echo/scripts/matrix_smoke.py
  • echo/scripts/seed_dev.py
  • echo/server/dembrane/api/project.py
  • echo/server/dembrane/api/template.py
  • echo/server/dembrane/api/v2/access_requests.py
  • echo/server/dembrane/api/v2/admin.py
  • echo/server/dembrane/api/v2/bff/_access.py
  • echo/server/dembrane/api/v2/invites.py
  • echo/server/dembrane/api/v2/me.py
  • echo/server/dembrane/api/v2/middleware.py
  • echo/server/dembrane/api/v2/onboarding.py
  • echo/server/dembrane/api/v2/orgs.py
  • echo/server/dembrane/api/v2/projects.py
  • echo/server/dembrane/api/v2/schemas.py
  • echo/server/dembrane/api/v2/workspace_settings.py
  • echo/server/dembrane/api/v2/workspaces.py
  • echo/server/dembrane/inheritance.py
  • echo/server/dembrane/policies.py
  • echo/server/dembrane/seat_capacity.py
  • echo/server/tests/test_onboarding.py
  • echo/server/tests/test_policies.py
  • echo/server/tests/test_seat_capacity.py
  • echo/server/tests/test_tier_capacity.py
  • echo/server/tests/test_usage_gates_api.py
💤 Files with no reviewable changes (4)
  • echo/directus/sync/snapshot/fields/workspace_invite/include_org_membership.json
  • echo/frontend/src/hooks/useWorkspace.ts
  • echo/directus/sync/snapshot/fields/workspace_membership/is_external.json
  • echo/scripts/backfill_direct_memberships.py

Comment thread echo/scripts/seed_dev.py Outdated
Comment thread echo/server/dembrane/api/v2/onboarding.py Outdated
Comment thread echo/server/dembrane/api/v2/onboarding.py Outdated
  - onboarding: gate seat cap only on new memberships so retries don't 402
    existing members; promote external→member when user already has
    org_membership to preserve the ADR-0003 invariant; restore invited_by
    in invite fields so cap-blocked/accepted notifications have a recipient
  - orgs: subtract internal org members from _count_external_in_organisation
    so the header count stays in sync with list_org_members during heal
  - ui: "guests" → "externals" in OrganisationRoute access-rules copy
  - seed: fix misleading grace@external log line to match seeded email
@spashii spashii added this pull request to the merge queue May 21, 2026
Merged via the queue into main with commit f0f355a May 21, 2026
11 checks passed
github-merge-queue Bot pushed a commit that referenced this pull request May 25, 2026
fix: repair regressions from sidebar PR (#585)

The sidebar PR overlapped with #577, #581, #582, and #583 and introduced
  a few cross-cutting issues this commit addresses:

- Restore the "hide Project defaults for external-only users" gate in
the
new sidebar's UserSettingsView. PR #582/#583 added this filter to the
    old UserSettingsRoute; when the sidebar moved navigation into a new
view component, the NavItem rendered unconditionally and external-only
    users could click through to an empty pane.
- Drop schema-step tests that imported the deleted
scripts/create_schema.py
    one-shot seeding script. The committed snapshot under
directus/sync/snapshot/ is the source of truth now, so those structural
    guards were checking a script that no longer exists.
- Update AGENTS.md to remove the create_schema.py reference and point at
    the snapshot directory.
- Populate ref_workspace_id on PROJECT_SHARE_ROLE_CHANGED
(project_sharing.py),
    REPORT_READY, and REPORT_FAILED (tasks.py). The new useNotifications
    href builder requires both ref_workspace_id and ref_project_id to
    produce /w/{ws}/projects/... links; without it those notifications
    rendered as dead clicks.
- Strip the per-request org_membership lookup in get_workspace_context.
    PR #582 enforces role='external' ⟺ no org_membership at every write
path, and on_organisation_member_removed cascades workspace_membership
    soft-deletes correctly, so the read-side normalisation was redundant
    per-request DB overhead.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* User settings view now fetches and displays workspace access
information, conditionally showing relevant configuration options based
on access level.
* Notifications for project sharing and report status now include
workspace context information for better organization.

* **Documentation**
* Updated deployment guide with clarified schema snapshot and migration
script procedures.

* **Tests**
  * Removed legacy schema step validation tests from test suite.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/Dembrane/echo/pull/586?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
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