diff --git a/echo/directus/sync/snapshot/fields/workspace_invite/include_org_membership.json b/echo/directus/sync/snapshot/fields/workspace_invite/include_org_membership.json
deleted file mode 100644
index 749534560..000000000
--- a/echo/directus/sync/snapshot/fields/workspace_invite/include_org_membership.json
+++ /dev/null
@@ -1,44 +0,0 @@
-{
- "collection": "workspace_invite",
- "field": "include_org_membership",
- "type": "boolean",
- "meta": {
- "collection": "workspace_invite",
- "conditions": null,
- "display": null,
- "display_options": null,
- "field": "include_org_membership",
- "group": null,
- "hidden": false,
- "interface": "boolean",
- "note": "True = add to org as member. False = external workspace access only.",
- "options": null,
- "readonly": false,
- "required": false,
- "searchable": true,
- "sort": 10,
- "special": null,
- "translations": null,
- "validation": null,
- "validation_message": null,
- "width": "full"
- },
- "schema": {
- "name": "include_org_membership",
- "table": "workspace_invite",
- "data_type": "boolean",
- "default_value": false,
- "max_length": null,
- "numeric_precision": null,
- "numeric_scale": null,
- "is_nullable": false,
- "is_unique": false,
- "is_indexed": false,
- "is_primary_key": false,
- "is_generated": false,
- "generation_expression": null,
- "has_auto_increment": false,
- "foreign_key_table": null,
- "foreign_key_column": null
- }
-}
diff --git a/echo/directus/sync/snapshot/fields/workspace_invite/role.json b/echo/directus/sync/snapshot/fields/workspace_invite/role.json
index 7a9c14836..7149f2cfc 100644
--- a/echo/directus/sync/snapshot/fields/workspace_invite/role.json
+++ b/echo/directus/sync/snapshot/fields/workspace_invite/role.json
@@ -25,6 +25,10 @@
{
"text": "Viewer",
"value": "viewer"
+ },
+ {
+ "text": "External",
+ "value": "external"
}
]
},
diff --git a/echo/directus/sync/snapshot/fields/workspace_membership/is_external.json b/echo/directus/sync/snapshot/fields/workspace_membership/is_external.json
deleted file mode 100644
index f119264e9..000000000
--- a/echo/directus/sync/snapshot/fields/workspace_membership/is_external.json
+++ /dev/null
@@ -1,44 +0,0 @@
-{
- "collection": "workspace_membership",
- "field": "is_external",
- "type": "boolean",
- "meta": {
- "collection": "workspace_membership",
- "conditions": null,
- "display": null,
- "display_options": null,
- "field": "is_external",
- "group": null,
- "hidden": false,
- "interface": "boolean",
- "note": "True if user's primary org != workspace's org",
- "options": null,
- "readonly": false,
- "required": false,
- "searchable": true,
- "sort": 7,
- "special": null,
- "translations": null,
- "validation": null,
- "validation_message": null,
- "width": "full"
- },
- "schema": {
- "name": "is_external",
- "table": "workspace_membership",
- "data_type": "boolean",
- "default_value": false,
- "max_length": null,
- "numeric_precision": null,
- "numeric_scale": null,
- "is_nullable": false,
- "is_unique": false,
- "is_indexed": false,
- "is_primary_key": false,
- "is_generated": false,
- "generation_expression": null,
- "has_auto_increment": false,
- "foreign_key_table": null,
- "foreign_key_column": null
- }
-}
diff --git a/echo/directus/sync/snapshot/fields/workspace_membership/role.json b/echo/directus/sync/snapshot/fields/workspace_membership/role.json
index 63df3d2b2..0fada0628 100644
--- a/echo/directus/sync/snapshot/fields/workspace_membership/role.json
+++ b/echo/directus/sync/snapshot/fields/workspace_membership/role.json
@@ -29,6 +29,10 @@
{
"text": "Viewer",
"value": "viewer"
+ },
+ {
+ "text": "External",
+ "value": "external"
}
]
},
diff --git a/echo/docs/adr/0003-external-as-role.md b/echo/docs/adr/0003-external-as-role.md
new file mode 100644
index 000000000..38ff2b8e7
--- /dev/null
+++ b/echo/docs/adr/0003-external-as-role.md
@@ -0,0 +1,58 @@
+# External as a first-class workspace role
+
+## Status
+accepted (2026-05-20)
+
+## Context
+Workspace membership has historically modelled two orthogonal axes on every `workspace_membership` row: `role` (`owner`/`admin`/`member`/`billing`) and `is_external` (boolean). A "guest" was the pair `is_external=true` + `role='member'`, with policy lookups routed through an `effective_workspace_role()` helper that swapped `is_external=true` to a dedicated `guest` preset at read time. The flag was a denormalised statement of "this user has no `org_membership` row in this org."
+
+That shape produced four real defects:
+
+1. **Display drift.** The members page rendered the disk role (`member`) next to a separate `Guest` badge, so admins saw "Member" as the role for someone the system was treating as a guest in every policy check.
+2. **Seat-count misreporting.** The `/v2/workspaces/{id}/usage` response field `seat_count` was computed as members-only (excluded `is_external=true` rows). The progress bar numerator therefore underreported by `guest_count`. A repro with admin + member + guest on Pioneer showed `2 of 5` instead of `3 of 5`, and a parenthetical "1 member + 1 guest" string that implied the buckets were unrelated. The unified-seat-pool work shipped in May 2026 had fixed the *enforcement* path (`assert_can_add_seat`) but not the *reporting* path.
+3. **Role-clamp logic scattered.** The invite endpoint had a literal `if not body.is_org_member and role in ("admin","owner","billing"): 400` branch with a string-formatted error. Every future read site had to remember to consult `effective_workspace_role()` or risk leaking member-level capabilities to guests.
+4. **API ambiguity.** `POST /v2/workspaces/{id}/invite` accepted both `role` and `is_org_member`, which could disagree. The endpoint had to coerce one to match the other and surface a domain error for combinations the schema couldn't catch.
+
+We considered three reshape options:
+
+- **Path B — Relabel only.** Keep the flag, change every "Guest" string to "External," lock the dropdown, fix the usage-endpoint count. Smallest blast radius, no schema migration. Rejected because the root cause (two fields encoding the same fact) stays in place — every future feature is one more chance to re-introduce the drift.
+- **Path A with strict coupling.** Promote `external` to a stored role and treat `org_membership` presence as the source of truth (or vice versa), with triggers/hooks keeping the two in sync. Rejected because Directus has no native hook mechanism for this and the cascade would let a workspace-page dropdown delete an `org_membership` row affecting all of that user's other workspaces in the org — a footgun disproportionate to the rarity of the transition.
+- **Path A with loose coupling.** Promote `external` to a stored role; drop `is_external` entirely; maintain the invariant (`role='external'` ⟺ no `org_membership` row) only at write-time at each explicit endpoint; no read-time derivation, no reconciler. Chosen.
+
+The repo is pre-production for this surface area, so the migration cost of dropping a column and renaming response fields is acceptable.
+
+## Decision
+
+- **`workspace_membership.role` enum gains `external`.** Final set: `{owner, admin, member, billing, external}`. The `is_external` column is dropped. Every read site that previously consulted the flag (or the `effective_workspace_role()` helper) reads `role` directly. The helper is deleted.
+
+- **Role hierarchy places external at the bottom.** `external < member < billing < admin < owner` in `ROLE_HIERARCHY`. Externals cannot invite anyone (preset has no `member:invite`). An admin can invite externals; an external can only ever be assigned `role='external'`.
+
+- **Invariant is maintained at write-time only.** `role='external'` ⟺ no `org_membership` row for the user in this org. The invite endpoint, the accept endpoint, and the org-membership endpoints each enforce this when they write. There is no read-time fallback derivation, no startup reconciler, and no Directus trigger. If state drifts in a degenerate scenario the fix is manual.
+
+- **The role dropdown is not a cross-boundary lever.** On a non-external row the dropdown shows `Admin / Billing / Member` (no `External` option). On an external row the dropdown is locked, showing `External` only. To convert an external into an org member the admin goes to the org settings page, adds them to the org, returns to the workspace, removes the external row, and re-invites as `member`. The workspace UI never has a single button that mutates `org_membership`.
+
+- **The invite endpoint takes `role` directly.** `POST /v2/workspaces/{id}/invite` body becomes `{ email, role }`. `is_org_member` is dropped. The endpoint branches on `role == 'external'` to decide whether to write `org_membership`. The `workspace_invite` table's `include_org_membership` column is replaced with `role`; the accept path reads it.
+
+- **The policy preset `guest` is renamed to `external`.** Content is unchanged from the existing `guest` preset: `project:read`, `project:update`, `conversation:read`, `chat:use`, `report:view`, `report:generate`. Explicit denials (anything outside that allowlist) are preserved by being absent from the preset.
+
+- **Seat counting is unified by role.** `_SEAT_ROLES` in `seat_capacity.py` becomes `{owner, admin, member, billing, external}`. `compute_effective_seat_state` returns `(seats_used, member_count, external_count)` keyed on role only. The `assert_can_add_guest` and `assert_can_add_member` aliases are removed — call sites call `assert_can_add_seat` directly.
+
+- **Usage response field semantics are corrected, not preserved.** `WorkspaceUsageResponse.seat_count` is repurposed to mean `members + externals` (the bar numerator). New fields `member_count`, `external_count`, and `pending_count` carry the breakdown. `guest_count` is removed. The same rename propagates to `admin.py` workspace rollups and `orgs.py` org rollups so the names align across the codebase.
+
+- **The billing card displays the unified pool truthfully.** A single progress bar above three sub-rows (`Members`, `Externals`, `Pending invites`). Rows with count zero are hidden. The "1 member + 1 guest" parenthetical is removed.
+
+## Consequences
+
+- **`role='external'` is the single source of truth for "outside the org."** A future reader looking at a `workspace_membership` row no longer has to consult a second column or a helper function to know how the user is treated by the policy engine. The cost is that every existing read site that referenced `is_external` had to be touched in one pass — covered by the rename PR, but the grep needs to be exhaustive (`is_external`, `effective_workspace_role`, `guest_count`, `guest`, `Guest`).
+
+- **The "user is in our org but external to this workspace" case is unrepresentable by design.** With Path A loose-coupled, the invariant says you can't have both. If product ever wants "limited access for an org colleague," that's a separate role (e.g., `viewer`) — it is not a use of `external`. This is intentional; conflating the two was part of the original confusion.
+
+- **Promoting external → member requires a re-invite, losing per-row state.** An external who is later added to the org keeps `role='external'` until an admin removes them and re-invites as member. Any per-row state on `workspace_membership` (custom_policies extras, future fields) is lost in the re-invite. We accept this because (a) the transition is rare, (b) it forces the cross-boundary action to be deliberate, and (c) building a non-destructive "convert" endpoint would have to handle the cross-table transaction we explicitly chose to avoid putting behind a dropdown.
+
+- **The `seat_count` field name now means something different than it did in older API consumers.** Pre-prod for this surface, so backward compatibility is not a concern. Anyone reading `seat_count` after this change gets the unified total (members + externals); they previously got members-only. The Pydantic schema change forces TypeScript consumers to recompile, so silent breakage is bounded.
+
+- **The 400 "Guests can't be admins…" error path is removed.** `POST /v2/workspaces/{id}/invite` now rejects out-of-enum roles at the schema layer (422). The domain-level error message disappears; callers that were parsing it for branching logic need to handle the 422 instead. No known internal callers do this.
+
+- **The role enum is bigger; some role-aware switch/if statements need an `external` arm.** Backend (`policies.py`, role-hierarchy maps, `_SEAT_ROLES`, `inheritance.get_effective_members`) and frontend (`roles.ts` `displayRole`/`roleColor`, role-select components, badge logic). The compiler/type-check catches most but not all; a deliberate sweep is needed at implementation time.
+
+- **i18n surface grows by one term per locale.** Every "Guest"/"Guests" string is replaced with "External"/"Externals" in en, nl, de, fr, es, it. `pnpm messages:extract` → translate → `pnpm messages:compile` is on the critical path for any deploy that ships this change.
diff --git a/echo/docs/prds/external-role-and-unified-seat-counting.md b/echo/docs/prds/external-role-and-unified-seat-counting.md
new file mode 100644
index 000000000..e0702c0d5
--- /dev/null
+++ b/echo/docs/prds/external-role-and-unified-seat-counting.md
@@ -0,0 +1,221 @@
+# External as a workspace role, and unified seat counting that actually counts
+
+## Problem Statement
+
+Workspace hosts who invite outside collaborators (consultants, partners, interviewees, anyone not in their organisation) hit four user-visible defects today:
+
+1. **The role label lies.** A person invited as a "guest" appears on the members page with their role shown as `Member`, next to a small `Guest` badge. Hosts read the role column and assume the person has full member capabilities; the policy engine actually treats them as a guest. The mismatch breeds mistrust ("did this work?") and operational errors ("I thought they could publish reports").
+
+2. **The seat counter is wrong.** A workspace on Pioneer with one admin + one member + one guest shows `2 of 5 seats used` and the parenthetical `(1 member + 1 guest)`. The actual unified seat pool is 3 of 5. Hosts looking at this display either (a) think guests don't consume seats — the original concern that triggered this work — or (b) plan headcount against a number that's silently wrong. When they hit the cap they hit it earlier than they predicted.
+
+3. **The invite mental model is two-axes for no reason.** The invite wizard asks the host to choose between "add an org member" and "add a guest." That's two flows for what is actually one decision: *what role does this person get*. The two-axes model leaks into every downstream surface — the dropdown options, the badge, the seat breakdown, the localized strings — and every one of them is a chance for the two axes to disagree.
+
+4. **The guest preset has no name in the data.** Guests have `role='member'` on disk and the actual guest permission preset is swapped in at read time by a helper function. A future write path that forgets to call that helper silently grants member-level capabilities to a guest. The bug is one missing function call away at all times.
+
+The first three are host-visible. The fourth is a developer footgun that costs us every time we touch this area.
+
+## Solution
+
+Promote the concept of "external collaborator" to a first-class workspace role and remove the parallel `is_external` flag. One role enum, one source of truth, one breakdown shown honestly.
+
+**For hosts:**
+
+- Externals show up on the members page with their role displayed as **External** (not Member with a Guest badge). The filter chips include an **Externals** group.
+- The invite wizard still has two conceptual steps (pick org colleagues vs. invite outside collaborators) because that maps to two different lookup flows, but the second step's output is simply `role='external'` — the wizard no longer carries a separate is_org_member toggle.
+- The billing card shows a single seats-used progress bar over the cap, with a small breakdown beneath it: **Members**, **Externals**, **Pending invites** — each row only appearing when its count is non-zero. The misleading "1 member + 1 guest" parenthetical is gone.
+- Externals consume seats. A Pioneer workspace with admin + member + external shows `3 of 5 seats used` — the number any host would have expected.
+- The role dropdown for an external row is locked to External. The role dropdown for any non-external row offers Admin / Billing / Member only (no External option). Promoting an external to a full org member happens by adding them to the org on the org settings page, then re-inviting them to the workspace as Member.
+
+**For developers:**
+
+- A single field — `workspace_membership.role` — answers every question about a row's identity and capabilities. The `is_external` flag, the `effective_workspace_role()` helper, and the parallel `assert_can_add_guest` / `assert_can_add_member` capacity aliases are removed.
+- The `external` role preset is identical in content to today's `guest` preset (no project creation, no report publishing, no usage visibility, no invite, no conversation deletion — only read access to projects/conversations/reports, the ability to update projects shared with them, the ability to use chat, and the ability to generate reports). The rename happens in `policies.py`.
+- ADR-0003 documents the trade-offs (strict vs loose coupling between role and `org_membership`; why we kept invariant enforcement at write-time only and rejected a cross-table reconciler).
+
+The seat-count bug fix and the role rename ship together. They are technically separable but conceptually the same problem — both come from "guests are a flag, not a role."
+
+## User Stories
+
+1. As a workspace admin, I want externals to appear on the members page with their role shown as "External", so I never confuse them with full members.
+2. As a workspace admin, I want a filter chip labelled "Externals" on the members page, so I can scope the list to outside collaborators.
+3. As a workspace admin, I want the badge wording to be "External" (singular) on a row and "Externals" (plural) on the filter, so the language matches whether I'm looking at one person or a group.
+4. As a workspace admin, I want the role dropdown on an external row to be locked and to display "External" only, so I cannot accidentally grant them admin, billing, or member capabilities.
+5. As a workspace admin, I want the role dropdown on a non-external row to offer Admin / Billing / Member only (no External option), so I cannot accidentally demote a colleague across the org-membership boundary from a single dropdown change.
+6. As a workspace admin, I want to invite an external collaborator by entering their email in the invite wizard's second step, so the flow matches my mental model of "add outsiders."
+7. As a workspace admin, I want the invite wizard's second step titled "Invite externals", so the language is consistent with the role name everywhere else.
+8. As a workspace admin on a Pioneer or higher tier, I want every external I invite to consume a seat against my tier cap, so my seat usage matches what I planned for.
+9. As a workspace admin on a free or pilot tier, I want the seat cap to hard-block at the unified count (members + externals), so I don't get an unexpected 402 at the 3rd invite when I thought I had 5 seats.
+10. As a workspace admin, I want the billing card's seat bar to show the unified total (members + externals) over the cap, so the number I see matches the number my actions are gated against.
+11. As a workspace admin, I want a small breakdown beneath the seat bar showing Members, Externals, and Pending invites as separate rows, so I can see where each seat goes without the bar lying about the total.
+12. As a workspace admin, I want breakdown rows with a count of zero to be hidden, so the card doesn't clutter with empty information.
+13. As a workspace admin, I want pending invites to count toward the seat bar and appear as their own breakdown row, so the cap behaviour matches what I see — no surprise 402 on the next invite.
+14. As a workspace admin on Pioneer or higher, I want externals to bill as seat overage just like extra members do, so my invoice matches my actual headcount usage.
+15. As a workspace admin, I want the misleading "1 member + 1 guest" parenthetical removed from the billing card, so the card is no longer ambiguous about whether the buckets are independent.
+16. As a workspace admin, I want to promote an external to a full member by adding them to the org first and then re-inviting them to the workspace as a member, so the cross-boundary action is deliberate and the workspace UI never silently mutates org-level data.
+17. As a workspace admin, I want the system to refuse promoting an external to admin, billing, or member directly via the role dropdown, so I'm forced to take the cross-boundary action through the org settings page.
+18. As a workspace admin, I want the system to refuse demoting a member, admin, or billing user to external directly via the role dropdown, so I cannot delete someone's org membership from a workspace-page dropdown change.
+19. As a workspace admin, I want the invite wizard to reject role escalation (an admin can only invite up to admin level, no owner), so role escalation rules continue to apply with the new enum.
+20. As an external collaborator, I want my role to display as "External" everywhere I see it (settings pages, members page, my own profile if shown), so my position in the workspace is clear.
+21. As an external collaborator, I want to be unable to create new projects, so my limited access is enforced and I don't see broken or denied UI on actions I'm not allowed to take.
+22. As an external collaborator, I want to be unable to publish reports, so my draft outputs don't reach a broader audience than the host intended.
+23. As an external collaborator, I want to be unable to delete conversations, so I cannot destroy data I don't own.
+24. As an external collaborator, I want to be unable to invite anyone to the workspace, so I cannot expand the workspace's membership without the host's involvement.
+25. As an external collaborator, I want to be unable to view workspace usage or invoices, so I don't see commercial information I have no reason to see.
+26. As an external collaborator, I want to be able to read projects shared with me, update them (within the host's permission grant), read conversations, use chat, view reports, and generate reports, so I can do the collaborative work I was invited for.
+27. As a workspace member, I want to keep all my existing capabilities (project create, conversation delete, report publish, usage visibility, chat) unchanged, so the introduction of the external role doesn't affect my day-to-day.
+28. As a workspace admin or billing role, I want the seat overage forecast on the billing card to be computed against the unified seats-used number, so the forecasted euro figure matches the bar I see above it.
+29. As a Dutch-speaking host, I want all "Guest" / "Guests" strings translated to the Dutch equivalent of External / Externals, so my UI is consistent in my language.
+30. As a German-speaking host, I want the same translation work to ship in German, so the rollout is uniform across locales.
+31. As a French-, Spanish-, and Italian-speaking host, I want the same translation work to ship in my locale, so no locale is left with the old "Guest" wording.
+32. As a future developer reading the codebase, I want a single field on `workspace_membership` to tell me whether a user is external, so I don't have to remember to consult a helper function or risk granting member-level capabilities by accident.
+33. As a future developer, I want ADR-0003 to document why we made `external` a stored role rather than a flag, and why we accepted loose coupling between role and `org_membership` rather than a write-side cascade, so I don't re-litigate the decision when I touch this area.
+34. As a future developer, I want every place in the backend that previously read `is_external` to be updated in one sweep, so there is no half-migrated state where some checks consult `role` and others consult the dropped flag.
+35. As a future developer, I want the schema migration script to follow the established `scripts/create_schema.py` Directus pattern (idempotent REST calls, `field_exists()` / `collection_exists()` guards), so the migration aligns with the rest of the project's schema-evolution practices.
+36. As an org admin who needs to add an existing external to my organisation, I want to do that on the org settings page (adding them to `org_membership`), then return to the workspace and re-invite them as Member, so the action is explicit and the workspace UI doesn't carry a footgun for cross-table mutations.
+37. As a workspace admin, I want the per-row badge that previously read "Guest" to read "External" with no tooltip or subtitle explaining what External means, so the UI stays clean and the wording does the explaining.
+38. As a workspace admin, I want the 400 error message "Guests can't be admins, owners, or billing" to no longer fire, so the invite endpoint's input validation happens cleanly at the schema layer (422 on out-of-enum role) instead of a domain-level error message that conflates two things.
+39. As a workspace admin, I want the `is_external` column dropped from the schema entirely, so the data model has exactly one field encoding this fact and there is no opportunity for the flag and the role to disagree.
+40. As a workspace admin, I want any existing `workspace_membership` row with `is_external=true` to be migrated to `role='external'` in a one-shot script before the column is dropped, so no existing data is lost in the schema change.
+41. As a workspace admin, I want the `workspace_invite.include_org_membership` column replaced by a `role` column, so the invite row carries the same single-field semantics as the eventual membership.
+42. As a workspace admin, I want the accept path (`onboarding.py` invite-acceptance) to read `role` from the invite and branch the `org_membership` write on `role == 'external'`, so the accept-time logic mirrors the send-time logic exactly.
+43. As a workspace admin, I want the unified seat cap to still hard-block at the cap on free and pilot tiers and still allow overage on Pioneer and above, so the existing tier policy (already correct in `seat_capacity.py`) carries through unchanged.
+44. As a workspace admin, I want pending invite counts to be returned from the usage endpoint (not hidden client-side), so the breakdown row can render without a separate API call.
+45. As a workspace admin, I want the org-level rollup in admin UIs (the staff `/admin` view of all workspaces and the org settings page's per-workspace summary) to use the same renamed field names (`seat_count` = unified total, `member_count`, `external_count`, `pending_count`), so internal staff tools agree with the host-facing card.
+46. As a workspace admin viewing the usage card, I want a row count of zero on any breakdown sub-row to hide that row entirely, so a workspace with only members (no externals, no pending) sees a clean "Members: N" row and nothing else.
+47. As a workspace admin, I want the analytics events that record role-aware properties (if any) to use `external` instead of `guest` in their payload, so downstream dashboards reflect the new vocabulary.
+
+## Implementation Decisions
+
+**Schema**
+
+- `workspace_membership.role` enum is extended to `{owner, admin, member, billing, external}`. A one-shot Python migration script following the `scripts/create_schema.py` pattern: idempotent REST calls against the Directus admin API, `field_exists()` / `collection_exists()` guards, run step-by-step.
+- The migration: (a) add `external` to the role choices via field-update REST calls, (b) `UPDATE workspace_membership SET role='external' WHERE is_external=true`, (c) drop the `is_external` column, (d) on `workspace_invite`, add a `role` column, copy from `include_org_membership` (true → "member", false → "external"), drop `include_org_membership`. Verify each step manually before proceeding.
+- Pull the schema afterwards (`cd directus && bash sync.sh ... pull`) and commit the snapshot JSON. Do not hand-write the snapshot.
+- Pre-production for this surface area, so the migration is one-shot — no compat shim, no rollback consideration beyond the script being idempotent.
+
+**Backend — `policies` module**
+
+- Rename the preset key `guest` to `external`. Content unchanged: `project:read`, `project:update`, `conversation:read`, `chat:use`, `report:view`, `report:generate`. Explicit denials remain implicit (anything not in the preset is denied).
+- Delete the `effective_workspace_role()` helper. Every read site now uses `role` directly.
+- `ROLE_HIERARCHY` in the invite endpoint gains `external` at the bottom: `{external: 0, member: 1, billing: 2, admin: 3, owner: 4}`. An admin can grant up to admin; no role can grant `owner`. Externals cannot be granted any role higher than external.
+
+**Backend — `seat_capacity` module**
+
+- `_SEAT_ROLES` becomes `{owner, admin, member, billing, external}`.
+- `compute_effective_seat_state` returns `(seats_used, member_count, external_count)` keyed on role only (no `is_external` consultation).
+- The `assert_can_add_guest` and `assert_can_add_member` aliases are removed. Call sites call `assert_can_add_seat` directly.
+- `count_pending_invites` now buckets by the new `role` column on `workspace_invite`. Return shape stays a two-tuple of `(pending_member_invites, pending_external_invites)` — same structure, renamed semantics.
+- Hard-block tiers (free, pilot) and overage tiers (Pioneer+) are unchanged. The existing tier policy already routes through `assert_can_add_seat`.
+
+**Backend — `inheritance.get_effective_members`**
+
+- Stop emitting `is_external` in the per-row dict. Only `role`, `user_id`, `source`, and existing fields flow through.
+
+**Backend — invite endpoint (`POST /v2/workspaces/{id}/invite`)**
+
+- Request body becomes `{ email: str, role: "admin"|"member"|"billing"|"external" }`. Drop `is_org_member`.
+- Branch logic: if `role == "external"`, write `workspace_membership` only, skip `org_membership` write. If `role` is anything else, ensure an `org_membership` row exists for the user in this org (create if absent), then write `workspace_membership`.
+- Inline enforcement of the invariant `role='external'` ⟺ no `org_membership` row, with a comment referencing ADR-0003. (Not extracted into a service layer — two call sites, three lines each, premature abstraction.)
+- Remove the 400 "Guests can't be admins, owners, or billing" branch — replaced by enum validation at the Pydantic schema layer (out-of-enum role → 422).
+- Role-escalation check (an inviter can only grant roles at or below their own level) continues to apply against the new hierarchy.
+
+**Backend — accept path (`onboarding.py`)**
+
+- Read `role` from the `workspace_invite` row. Mirror the invite endpoint's branch on `role == "external"` to decide whether to write `org_membership`.
+- Continue to call `assert_can_add_seat` at accept time (race protection) — the cap may have shrunk between send and accept.
+
+**Backend — usage response (`/v2/workspaces/{id}/usage`)**
+
+- Response field semantics:
+ - `seat_count` (repurposed): unified total = members + externals. This is the progress-bar numerator.
+ - `member_count` (new): users with role in {owner, admin, member, billing}.
+ - `external_count` (new): users with role = "external".
+ - `pending_count` (new): pending invites (members + externals combined). The single combined count is sufficient for the row in the card; no need to split for the host-facing display.
+ - `guest_count`: removed.
+- The same rename propagates to `admin.py` workspace rollups and `orgs.py` org rollups so internal staff tools agree with the host card.
+
+**Backend — `WorkspaceInviteRequest` schema**
+
+- Drop `is_org_member`. The `role` enum gains `"external"`. Pydantic enum validation handles the 422 case.
+
+**Frontend — `lib/roles`**
+
+- `displayRole` returns "External" for `role === "external"`.
+- `roleColor` returns gray for `external` (matches today's guest badge color).
+- `isAdminRole` unchanged. `external` is never admin-level.
+
+**Frontend — Workspace settings members section**
+
+- Per-row badge: replace `Guest` with `External` (singular, gray).
+- Filter chips: rename "Guests" to "Externals" (plural).
+- Role dropdown: on an external row, lock the control and show "External" only. On a non-external row, the existing `Admin / Billing / Member` options remain — no new "External" option (per the decision that the dropdown is not a cross-boundary lever).
+- No tooltip on the badge or row — the wording does the explaining.
+
+**Frontend — `UsageCard`**
+
+- Single progress bar at the top: `seat_count / seat_count_included` (now the unified total).
+- Beneath the bar, three sub-rows in this order: **Members** (count), **Externals** (count), **Pending invites** (count). Each row is hidden when its count is zero.
+- Remove the "(1 member + 1 guest)" parenthetical inline string entirely.
+
+**Frontend — `WorkspaceInviteWizard`**
+
+- Step 2 submits `role: "external"` for each email. The `is_org_member` field is removed from the request payload.
+- Step title: "Invite externals."
+
+**Frontend — Lingui**
+
+- Replace every `"Guest"` / `"Guests"` string with `"External"` / `"Externals"`. Run `pnpm messages:extract`, translate the new keys in `.po` for en, nl, de, fr, es, it (Dutch uses informal je/jij; Italian uses informal tu, A2 reading level — per `brand/STYLE_GUIDE.md`), then `pnpm messages:compile`.
+
+**Analytics**
+
+- Any PostHog event payload that included `{role: "guest"}` now sends `{role: "external"}`. No new events are added; this is a property-value rename only.
+
+## Testing Decisions
+
+A good test for this surface tests **external behaviour through the module's public interface** — not the implementation. For `seat_capacity`, that means asserting on the return values of `compute_effective_seat_state` and on whether `assert_can_add_seat` raises, not on internal set construction. For `policies`, that means asserting on `has_policy(role='external', ...)` answers, not on the contents of the preset dict.
+
+**Two modules get unit tests:**
+
+1. **`seat_capacity` — the bug repro and the unified-pool behaviour.**
+ - Given a workspace with `[admin, member, external]` rows, `compute_effective_seat_state` returns `(3, 2, 1)`.
+ - Given a workspace with `[admin, member, external]` rows on a free tier with `included_seats=3`, `assert_can_add_seat` raises 402.
+ - Given a workspace with `[admin, member]` rows on a free tier with `included_seats=3`, `assert_can_add_seat` succeeds (room for one more, either member or external).
+ - Given a workspace on Pioneer with overage allowed, `assert_can_add_seat` never raises regardless of count.
+ - Given `include_pending=True` and 2 pending invites against a cap of 3 with 2 used, `assert_can_add_seat` raises (2 + 2 ≥ 3).
+ - Derived org admins (from `get_effective_members`) count as seats just like direct members.
+
+2. **`policies` — preset content and `has_policy` answers.**
+ - `external` role does NOT have: `project:create`, `report:publish`, `conversation:delete`, `workspace:view_usage`, `member:invite`, `member:manage`, `settings:manage`, `workspace:view_invoices`.
+ - `external` role DOES have: `project:read`, `project:update`, `conversation:read`, `chat:use`, `report:view`, `report:generate`.
+ - `member` role retains all its current policies (regression guard against accidental removal).
+ - `has_policy("external", custom_policies=None, "project:create")` is `False`. `has_policy("external", custom_policies=None, "project:read")` is `True`.
+ - The role hierarchy: `external < member < billing < admin < owner`.
+
+**Prior art:** the existing test suite under `server/dembrane/tests/` (or wherever `policies` and `seat_capacity` tests currently live — grep for `test_policies` and `test_seat_capacity` to locate). Match style: pytest, async fixtures where Directus is involved, plain unit tests where the function is pure.
+
+**Not tested via unit tests** (verified by manual click-through and existing integration tests):
+
+- Frontend role dropdown lock / filter chip rename / badge text — covered by the existing pattern of running `pnpm dev` and clicking through the members page in en + one non-en locale.
+- The invite wizard's step 2 payload — covered by manually inviting an external and observing the network request body and the resulting row in Directus.
+- The `UsageCard` layout — covered by visual inspection at common (and edge) count combinations (0 externals; 0 pending; both non-zero; both zero).
+- The schema migration script — run step-by-step against a dev Directus and verified row-by-row before proceeding.
+
+## Out of Scope
+
+- **A "Convert external to member" UI action on the workspace members page.** The single-click conversion was rejected: promotion happens by going to the org settings page, adding the user to the org, returning to the workspace, removing the external row, and re-inviting as member. The cross-table side effect is intentionally not a single-click workspace action.
+- **Auto-promotion when an external is independently added to the org.** If an admin adds an external user to the org via the org settings page, the user's existing `workspace_membership` rows stay at `role='external'` until manually changed by re-invite. No background reconciler, no trigger, no derived role lookup.
+- **A background reconciler for the invariant `role='external'` ⟺ no `org_membership` row.** Enforcement is write-time only. If state drifts in a degenerate scenario (manual SQL, partial deploy), the fix is manual.
+- **Multi-workspace external (Slack-style multi-channel guest).** An external is external to a single workspace per row. If the same person needs external access to two workspaces, that's two `workspace_membership` rows. No new collection or shared cross-workspace identity for externals.
+- **A "viewer" or other limited-but-internal role for users who ARE in the org but should have restricted workspace access.** Externals are strictly "not in the org" by invariant. If product later wants "limited access for an org colleague," that's a new role — not a use of `external`.
+- **External-facing communications about the rename.** Internal docs are updated as part of this work, but help-center articles, marketing copy, and release notes are out of scope until a separate communications pass.
+- **Backwards compatibility for the `WorkspaceUsageResponse` field rename.** Pre-prod for this surface, so consumers are updated in lockstep with the backend change. No legacy field aliases, no deprecation period.
+
+## Further Notes
+
+- **Respect ADR-0003** (`docs/adr/0003-external-as-role.md`) — that document records the full chain of decisions (Path A with loose coupling, the role-vs-flag trade-off, why we kept invariant enforcement at write-time only, why a service-layer extraction was rejected, the unified seat-pool repro and root cause).
+- **The seat-counting bug is the highest-value fix in this PRD.** Even though the bulk of the work is the role rename, the bar showing the wrong number is the host-visible regression that motivated the work. The fix lives in repurposing the `seat_count` field semantics in the usage response and is testable in `seat_capacity`.
+- **The schema migration script is single-use and lives in `scripts/`.** Once run against staging and the schema snapshot is pulled and committed, the script can stay in the repo as a record of the migration but is not re-run.
+- **Lingui translation work is on the critical path.** Don't ship the frontend rename without compiling the message catalog in all six locales — partial translation leaves the UI mixing "Guest" (untranslated locale) and "External" (translated locale).
+- **The `is_external` flag is one of two fields that were carrying the same fact.** The other was `workspace_invite.include_org_membership`. Both are dropped in this PRD. If any third field encoding the same fact is discovered during implementation (grep for `external`, `guest`, `is_external`), drop it too — the goal is exactly one source of truth.
+- **No new policies are introduced.** The `external` preset is a rename of the existing `guest` preset with identical content. The PRD does not change *what* externals can do — only how they appear and how they are counted.
+- **No new tier behaviour.** Free and pilot continue to hard-block; Pioneer+ continue to bill overage; Guardian remains unlimited. The unified pool is already enforced in `assert_can_add_seat`; this PRD makes the reporting agree with the enforcement.
diff --git a/echo/frontend/src/components/organisation/OrganisationInviteWizard.tsx b/echo/frontend/src/components/organisation/OrganisationInviteWizard.tsx
index d1642f66d..34603ac69 100644
--- a/echo/frontend/src/components/organisation/OrganisationInviteWizard.tsx
+++ b/echo/frontend/src/components/organisation/OrganisationInviteWizard.tsx
@@ -60,7 +60,7 @@ async function inviteToWorkspace(
const res = await fetch(
`${API_BASE_URL}/v2/workspaces/${workspaceId}/invite`,
{
- body: JSON.stringify({ email, is_org_member: true, role }),
+ body: JSON.stringify({ email, role }),
credentials: "include",
headers: { "Content-Type": "application/json" },
method: "POST",
@@ -85,8 +85,9 @@ async function inviteToWorkspace(
* Organisation-scope invites don't exist as a single backend endpoint — the
* matrix §3 model is that someone "joins the organisation" by joining their
* first workspace. So this wizard fans out: it calls the workspace
- * invite endpoint with is_org_member=true for every selected workspace.
- * The first call creates the organisation membership; subsequent calls reuse it.
+ * invite endpoint with a non-external role (member/billing/admin) for
+ * every selected workspace — the invariant (ADR-0003) means non-external
+ * invites always carry an org_membership write.
*
* Step 2 cards show tier + member count + a few avatar bubbles of
* current members so the admin can eyeball who's already in without
diff --git a/echo/frontend/src/components/project/ProjectUsageAndSharing.tsx b/echo/frontend/src/components/project/ProjectUsageAndSharing.tsx
index 9e88aa1ac..1a137452d 100644
--- a/echo/frontend/src/components/project/ProjectUsageAndSharing.tsx
+++ b/echo/frontend/src/components/project/ProjectUsageAndSharing.tsx
@@ -47,7 +47,6 @@ interface WorkspaceSettingsMember {
avatar: string | null;
role: string;
source: string;
- is_external: boolean;
}
interface WorkspaceSettingsResponse {
@@ -127,7 +126,7 @@ export function ProjectUsageAndSharing({ projectId, visibility }: Props) {
const { data: shares, isLoading: sharesLoading } = useProjectShares(projectId);
const [memberSearch, setMemberSearch] = useState("");
const [memberFilter, setMemberFilter] = useState<
- "all" | "admins" | "members" | "guests"
+ "all" | "admins" | "members" | "externals"
>("all");
const [inviteOpen, setInviteOpen] = useState(false);
@@ -207,7 +206,7 @@ export function ProjectUsageAndSharing({ projectId, visibility }: Props) {
email: m.email,
avatar: m.avatar,
role: m.role,
- is_external: m.is_external,
+ is_external: m.role === "external",
})),
)
: (shares ?? []).map((s) => ({
@@ -236,7 +235,7 @@ export function ProjectUsageAndSharing({ projectId, visibility }: Props) {
if (r.is_external) return false;
if (r.role === "owner" || r.role === "admin") return false;
}
- if (memberFilter === "guests" && !r.is_external) return false;
+ if (memberFilter === "externals" && !r.is_external) return false;
if (!q) return true;
return (
(r.display_name || "").toLowerCase().includes(q) ||
@@ -494,7 +493,7 @@ export function ProjectUsageAndSharing({ projectId, visibility }: Props) {
{ value: "admins", label: t`Admins` },
{ value: "members", label: t`Members` },
...(hasGuestRows
- ? [{ value: "guests", label: t`Guests` }]
+ ? [{ value: "externals", label: t`Externals` }]
: []),
],
}}
@@ -580,7 +579,7 @@ export function ProjectUsageAndSharing({ projectId, visibility }: Props) {
variant="light"
color="gray"
>
- Guest
+ External
)}
diff --git a/echo/frontend/src/components/settings/MyAccessCard.tsx b/echo/frontend/src/components/settings/MyAccessCard.tsx
index 9cde88817..1c545b297 100644
--- a/echo/frontend/src/components/settings/MyAccessCard.tsx
+++ b/echo/frontend/src/components/settings/MyAccessCard.tsx
@@ -24,7 +24,6 @@ interface Workspace {
org_name: string;
role: string;
tier: string;
- is_external: boolean;
project_count: number;
member_count: number;
}
@@ -201,15 +200,9 @@ export const MyAccessCard = () => {
- {ws.is_external
- ? t`Guest`
- : displayRole(ws.role)}
+ {displayRole(ws.role)}
diff --git a/echo/frontend/src/components/workspace/OrganisationUsageRollup.tsx b/echo/frontend/src/components/workspace/OrganisationUsageRollup.tsx
index 8905e4e73..d8ce6742f 100644
--- a/echo/frontend/src/components/workspace/OrganisationUsageRollup.tsx
+++ b/echo/frontend/src/components/workspace/OrganisationUsageRollup.tsx
@@ -81,7 +81,7 @@ interface OrgUsageWorkspaceRow {
seats_pct: number | null;
seat_cap_hit: boolean;
approaching_seat_cap: boolean;
- guest_count: number;
+ external_count: number;
downgraded_at: string | null;
at_cap: boolean;
approaching_cap: boolean;
@@ -94,7 +94,7 @@ interface OrgUsage {
workspace_count: number;
total_audio_hours: number;
total_seat_count: number;
- total_guest_count: number;
+ total_external_count: number;
total_project_count: number;
workspaces_at_cap: number;
workspaces_approaching_cap: number;
diff --git a/echo/frontend/src/components/workspace/SeatCapBanner.tsx b/echo/frontend/src/components/workspace/SeatCapBanner.tsx
index 910e62c76..7c1e49c09 100644
--- a/echo/frontend/src/components/workspace/SeatCapBanner.tsx
+++ b/echo/frontend/src/components/workspace/SeatCapBanner.tsx
@@ -8,7 +8,7 @@ import { useI18nNavigate } from "@/hooks/useI18nNavigate";
import { useWorkspace } from "@/hooks/useWorkspace";
/**
- * Level-2 status banner for seat cap reached (unified — guests share
+ * Level-2 status banner for seat cap reached (unified — externals share
* the same seat pool as members).
*
* Mounts in WorkspaceLayout alongside DowngradeBanner. Persistent strip
@@ -21,7 +21,7 @@ interface UsageProbe {
tier: string;
seat_count: number;
seat_count_included: number | null;
- guest_count: number;
+ external_count: number;
seat_invite_blocked?: boolean;
}
diff --git a/echo/frontend/src/components/workspace/UsageCard.tsx b/echo/frontend/src/components/workspace/UsageCard.tsx
index 144fbf480..b6caf9376 100644
--- a/echo/frontend/src/components/workspace/UsageCard.tsx
+++ b/echo/frontend/src/components/workspace/UsageCard.tsx
@@ -63,12 +63,14 @@ function formatEur(value: number | null | undefined): string {
* Workspace usage card (matrix v1.1 §8).
*
* Role-aware rendering:
- * - Member: hours / seats / guests / projects, raw numbers.
+ * - Member: hours / seats / projects, raw numbers.
* - Admin + Billing: adds overage forecast and next-tier recommendation.
*
- * Source fields come pre-differentiated from the backend (next_tier +
- * overage_forecast_eur are null for members — `workspace:view_invoices`
- * gates them server-side).
+ * Seat block: single bar over the unified pool (members + externals),
+ * with three optional sub-rows beneath — Members, Externals, Pending
+ * invites. Rows with count zero are hidden. The bar numerator
+ * (data.seat_count) is the value enforcement code (assert_can_add_seat)
+ * counts against — they always agree.
*/
export const UsageCard = ({ workspaceId }: { workspaceId: string }) => {
const queryClient = useQueryClient();
@@ -219,7 +221,9 @@ export const UsageCard = ({ workspaceId }: { workspaceId: string }) => {
)}
- {/* Seats (unified — guests share this pool) */}
+ {/* Seats — unified pool (members + externals). Breakdown
+ rows sit beneath the bar; zero-count rows hide so a
+ workspace with only members reads cleanly. */}
@@ -238,17 +242,29 @@ export const UsageCard = ({ workspaceId }: { workspaceId: string }) => {
{seatsPct !== null && (
)}
- {data.guest_count > 0 && (
-
- ({data.seat_count - data.guest_count}{" "}
- {" "}
- + {data.guest_count}{" "}
- )
-
+ {data.member_count > 0 && (
+
+
+ Members
+
+ {data.member_count}
+
+ )}
+ {data.external_count > 0 && (
+
+
+ Externals
+
+ {data.external_count}
+
+ )}
+ {data.pending_count > 0 && (
+
+
+ Pending invites
+
+ {data.pending_count}
+
)}
diff --git a/echo/frontend/src/components/workspace/WorkspaceInviteWizard.tsx b/echo/frontend/src/components/workspace/WorkspaceInviteWizard.tsx
index d6b171510..e60ee82e7 100644
--- a/echo/frontend/src/components/workspace/WorkspaceInviteWizard.tsx
+++ b/echo/frontend/src/components/workspace/WorkspaceInviteWizard.tsx
@@ -53,12 +53,11 @@ async function inviteToWorkspace(
workspaceId: string,
email: string,
role: string,
- isOrgMember: boolean,
): Promise<{ status: string; email: string; email_sent: boolean }> {
const res = await fetch(
`${API_BASE_URL}/v2/workspaces/${workspaceId}/invite`,
{
- body: JSON.stringify({ email, is_org_member: isOrgMember, role }),
+ body: JSON.stringify({ email, role }),
credentials: "include",
headers: { "Content-Type": "application/json" },
method: "POST",
@@ -74,7 +73,7 @@ async function inviteToWorkspace(
interface ExternalRow {
id: string;
email: string;
- role: "member";
+ role: "external";
}
interface Props {
@@ -89,7 +88,7 @@ interface Props {
// corresponding step is disabled with an upgrade prompt instead of
// letting the user fill the form only to fail at submit.
memberInviteBlocked?: boolean;
- guestInviteBlocked?: boolean;
+ externalInviteBlocked?: boolean;
// True when the workspace is on a Pioneer+ tier and already at or over
// included seats. Picker stays enabled (overage is allowed), but we
// surface a soft warning so admins know each new member adds to the
@@ -111,8 +110,8 @@ interface Props {
* externals through a similar add-rows UI in step 2.
*
* Submit fans out to the existing per-email invite endpoint; the
- * backend already handles "already organisation member? skip org_membership"
- * and guest-clamp-to-member, so nothing new is needed on the server.
+ * backend handles "already organisation member? skip org_membership"
+ * and writes role='external' on the membership/invite row.
*/
export function WorkspaceInviteWizard({
opened,
@@ -121,7 +120,7 @@ export function WorkspaceInviteWizard({
orgId,
existingMemberAppUserIds,
memberInviteBlocked = false,
- guestInviteBlocked = false,
+ externalInviteBlocked = false,
memberOverageActive = false,
seatOverageRate = null,
}: Props) {
@@ -146,9 +145,12 @@ export function WorkspaceInviteWizard({
const availableOrganisationMembers = useMemo(() => {
if (!organisationMembers) return [];
- // People not already in this workspace.
+ // People not already in this workspace. Externals (no org_membership,
+ // only reachable via external workspace rows) are excluded here —
+ // step 1 is for real org members; externals belong in step 2.
return organisationMembers.filter(
- (m) => !existingMemberAppUserIds.has(m.app_user_id),
+ (m) =>
+ !existingMemberAppUserIds.has(m.app_user_id) && m.role !== "external",
);
}, [organisationMembers, existingMemberAppUserIds]);
@@ -177,7 +179,7 @@ export function WorkspaceInviteWizard({
{
email: "",
id: Math.random().toString(36).slice(2),
- role: "member",
+ role: "external",
},
]);
};
@@ -205,12 +207,10 @@ export function WorkspaceInviteWizard({
const calls: ReturnType[] = [];
for (const email of organisationEmails) {
- calls.push(inviteToWorkspace(workspaceId, email, role, true));
+ calls.push(inviteToWorkspace(workspaceId, email, role));
}
for (const email of externalEmails) {
- // Guests are clamped to 'member' on the backend regardless; we
- // send member here so the UI matches.
- calls.push(inviteToWorkspace(workspaceId, email, "member", false));
+ calls.push(inviteToWorkspace(workspaceId, email, "external"));
}
const results = await Promise.allSettled(calls);
@@ -231,7 +231,7 @@ export function WorkspaceInviteWizard({
queryClient.invalidateQueries({
queryKey: ["v2", "organisation", orgId, "members"],
});
- // Refresh seat / guest cap flags after invites land.
+ // Refresh seat cap flags after invites land.
queryClient.invalidateQueries({
queryKey: ["v2", "workspace-usage", workspaceId, 0],
});
@@ -266,17 +266,17 @@ export function WorkspaceInviteWizard({
);
// Pre-flight: don't even let them submit picks the backend will 402 on.
const memberPicksWillFail = hasMemberPicks && memberInviteBlocked;
- const guestPicksWillFail = hasExternalPicks && guestInviteBlocked;
+ const externalPicksWillFail = hasExternalPicks && externalInviteBlocked;
const canSubmit =
(hasMemberPicks || hasExternalPicks) &&
!memberPicksWillFail &&
- !guestPicksWillFail;
+ !externalPicksWillFail;
return (
Invite members}
+ title={step === 0 ? Invite members : Invite externals}
centered
size="lg"
>
@@ -303,7 +303,7 @@ export function WorkspaceInviteWizard({
Free a seat by removing someone, or upgrade to add more
- members. You can still invite guests in the next step.
+ members. You can still invite externals in the next step.
@@ -429,7 +429,7 @@ export function WorkspaceInviteWizard({
)}
{/* Role picker — applies to everyone selected in step 1.
- Externals in step 2 are always 'member' (guest clamp). */}
+ Step 2 always submits role='external'. */}
{selectedOrganisationMembers.size > 0 && !memberInviteBlocked && (
-
+
@@ -495,21 +495,21 @@ export function WorkspaceInviteWizard({
- {guestInviteBlocked && (
+ {externalInviteBlocked && (
All seats taken on this tier
- Remove a member or guest, or upgrade to invite more
+ Remove a member or external, or upgrade to invite more
people.
)}
- {!guestInviteBlocked && externals.length === 0 && (
+ {!externalInviteBlocked && externals.length === 0 && (
No externals yet. Add one if you want someone outside your
@@ -518,7 +518,7 @@ export function WorkspaceInviteWizard({
)}
- {!guestInviteBlocked && externals.length > 0 && (
+ {!externalInviteBlocked && externals.length > 0 && (
{externals.map((row) => (
@@ -544,7 +544,7 @@ export function WorkspaceInviteWizard({
)}
- {!guestInviteBlocked && (
+ {!externalInviteBlocked && (
{role ? (
- isAdmin && isDirect && membershipId ? (
+ // External rows can't be flipped to/from a non-external
+ // role from this dropdown (ADR-0003). Promotion goes
+ // through the explicit add-to-org + re-invite flow.
+ role === "external" ? (
+
+
+ {displayRole(role)}
+
+
+ ) : isAdmin && isDirect && membershipId ? (
}
onClick={() => {
const nextRole = member.is_external
- ? "guest"
+ ? "external"
: "member";
modals.openConfirmModal({
children: (
diff --git a/echo/frontend/src/routes/project/ProjectsHome.tsx b/echo/frontend/src/routes/project/ProjectsHome.tsx
index e5b2e1a67..531142c2f 100644
--- a/echo/frontend/src/routes/project/ProjectsHome.tsx
+++ b/echo/frontend/src/routes/project/ProjectsHome.tsx
@@ -186,13 +186,13 @@ export const ProjectsHomeRoute = () => {
const canManageWorkspace =
workspace?.role === "owner" || workspace?.role === "admin";
- // Guests (external workspace access) cannot create projects or pin —
- // their surface is view-only on the workspace level. Gate the CTAs
- // up front so we don't lure them into a click that 403s.
- const isExternalGuest = workspace?.is_external === true;
+ // Externals cannot create projects or pin — their surface is
+ // view-only on the workspace level. Gate the CTAs up front so we
+ // don't lure them into a click that 403s.
+ const isExternal = workspace?.role === "external";
const canCreateProject =
- !isExternalGuest && !user.data?.disable_create_project;
- const canPinOnThisWorkspace = !isExternalGuest;
+ !isExternal && !user.data?.disable_create_project;
+ const canPinOnThisWorkspace = !isExternal;
const totallyEmpty =
allProjects.length === 0 &&
debouncedSearchValue === "" &&
@@ -259,16 +259,16 @@ export const ProjectsHomeRoute = () => {
{totallyEmpty ? (
- {isExternalGuest ? (
+ {isExternal ? (
Nothing here for you yet.
) : (
Let's hear your first conversation.
)}
- {isExternalGuest ? (
+ {isExternal ? (
- You're a guest in this workspace. Projects will show up
+ You're an external in this workspace. Projects will show up
here once someone on the organisation shares one with you.
) : (
diff --git a/echo/frontend/src/routes/workspaces/WorkspaceSelectorRoute.tsx b/echo/frontend/src/routes/workspaces/WorkspaceSelectorRoute.tsx
index a9c981038..ccd5b4dd6 100644
--- a/echo/frontend/src/routes/workspaces/WorkspaceSelectorRoute.tsx
+++ b/echo/frontend/src/routes/workspaces/WorkspaceSelectorRoute.tsx
@@ -64,7 +64,6 @@ interface Workspace {
org_logo_url: string | null;
project_count: number;
member_count: number;
- is_external: boolean;
members_preview: MemberPreview[];
usage: WorkspaceUsage;
has_pending_upgrade_request?: boolean;
@@ -176,7 +175,7 @@ function WorkspaceCard({
const wsLogo = resolveLogoUrl(workspace.logo_url);
const organisationLogo = resolveLogoUrl(workspace.org_logo_url);
// Logo rules (2026-04-24 ask, refined):
- // - Guest workspace: ws logo if set, else organisation logo. A guest's
+ // - External workspace: ws logo if set, else organisation logo. An external's
// anchor is the organisation that invited them — always show something
// so the card has a visual hook.
// - Internal workspace: only show the ws logo. No ws logo → no
@@ -184,7 +183,7 @@ function WorkspaceCard({
// organisation, so repeating the organisation logo on every internal card is
// just visual noise ("see here there is no special workspace
// icon so no need to show").
- const headerLogo = workspace.is_external
+ const headerLogo = (workspace.role === "external")
? wsLogo || organisationLogo
: wsLogo;
// Calmer meta-line: role + tier as a single dimmed string, no
@@ -193,8 +192,8 @@ function WorkspaceCard({
// warning keeps color — it's the one actionable exception.
const capitalizedTier =
workspace.tier.charAt(0).toUpperCase() + workspace.tier.slice(1);
- const metaParts = workspace.is_external
- ? [t`Guest of ${workspace.org_name}`]
+ const metaParts = (workspace.role === "external")
+ ? [t`External of ${workspace.org_name}`]
: [displayRole(workspace.role), capitalizedTier];
return (
@@ -237,7 +236,7 @@ function WorkspaceCard({
label={t`${capitalizedTier} · tap to see what's included`}
position="bottom-start"
withArrow
- disabled={workspace.is_external}
+ disabled={(workspace.role === "external")}
>
{metaParts.join(" · ")}
@@ -583,8 +582,8 @@ export const WorkspaceSelectorRoute = () => {
: workspaces;
// Group by organisation (org)
- const internalWorkspaces = filtered.filter((w) => !w.is_external);
- const externalWorkspaces = filtered.filter((w) => w.is_external);
+ const internalWorkspaces = filtered.filter((w) => !(w.role === "external"));
+ const externalWorkspaces = filtered.filter((w) => (w.role === "external"));
// Seed groups from `organisations` first — that way a organisation with zero workspaces
// still renders a hero card + AddWorkspace affordance instead of getting
@@ -718,7 +717,7 @@ export const WorkspaceSelectorRoute = () => {
{orgGroups.size > 0 && }
- As a guest
+ As an external
{externalWorkspaces.map((ws) => (
diff --git a/echo/frontend/src/routes/workspaces/WorkspaceSettingsRoute.tsx b/echo/frontend/src/routes/workspaces/WorkspaceSettingsRoute.tsx
index 3d6b10d37..cfbaac8e5 100644
--- a/echo/frontend/src/routes/workspaces/WorkspaceSettingsRoute.tsx
+++ b/echo/frontend/src/routes/workspaces/WorkspaceSettingsRoute.tsx
@@ -66,9 +66,13 @@ interface WorkspaceMember {
avatar: string | null;
role: string;
source: string;
- is_external: boolean;
}
+// Helper: external collaborators are identified by role==='external'
+// (ADR-0003). Drives badge + filter logic across this route.
+const isExternalMember = (m: { role: string }): boolean =>
+ m.role === "external";
+
interface WorkspaceDetail {
id: string;
name: string;
@@ -262,19 +266,18 @@ export const WorkspaceSettingsRoute = () => {
const queryClient = useQueryClient();
const { data: meV2 } = useV2Me();
const { workspace: myWorkspaceSummary } = useWorkspace();
- // Guest = is_external on my direct row. Matrix §4: guest permissions
- // mirror member inside their assigned workspace, but they don't see
- // usage / privacy / pending invites (matrix §4 "View usage & overage"
- // row is ✗ for Guest).
- const iAmGuest = myWorkspaceSummary?.is_external === true;
+ // External = role==='external' on my direct row (ADR-0003).
+ // Matrix §4: external permissions are strictly scoped — no settings,
+ // usage, privacy, or pending invites surfaces.
+ const iAmExternal = myWorkspaceSummary?.role === "external";
// Externals don't have a manage surface at all (2026-04-24). Bounce
// them back to the project list of the workspace they came in on.
// Keep the effect above the loading gate so hook order is stable.
useEffect(() => {
- if (iAmGuest && workspaceId) {
+ if (iAmExternal && workspaceId) {
navigate(`/w/${workspaceId}/projects`, { replace: true });
}
- }, [iAmGuest, workspaceId, navigate]);
+ }, [iAmExternal, workspaceId, navigate]);
const [deleteConfirm, setDeleteConfirm] = useState("");
const [
inviteModalOpened,
@@ -282,7 +285,7 @@ export const WorkspaceSettingsRoute = () => {
] = useDisclosure(false);
const [memberSearch, setMemberSearch] = useUrlSearch();
- type WsRoleFilter = "all" | "admins" | "billing" | "members" | "guests";
+ type WsRoleFilter = "all" | "admins" | "billing" | "members" | "externals";
const [memberRoleFilter, setMemberRoleFilter] = useState("all");
// User override for the matrix toggle. `null` means "follow whatever the
// workspace is currently on" (seeded from settings.billing_period when it
@@ -335,7 +338,9 @@ export const WorkspaceSettingsRoute = () => {
seat_invite_blocked?: boolean;
seat_count: number;
seat_count_included: number | null;
- guest_count: number;
+ member_count: number;
+ external_count: number;
+ pending_count: number;
}>;
},
queryKey: ["v2", "workspace-usage", workspaceId, 0],
@@ -416,7 +421,7 @@ export const WorkspaceSettingsRoute = () => {
onError: (err: Error) => toast.error(err.message),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["v2", "workspace-settings"] });
- // Frees a seat / guest slot — re-enable invite button.
+ // Frees a seat — re-enable invite button.
queryClient.invalidateQueries({ queryKey: ["v2", "workspace-usage"] });
toast.success(t`Member removed`);
},
@@ -478,7 +483,7 @@ export const WorkspaceSettingsRoute = () => {
// Members list order (2026-04-24): internals first — sorted by role
// (owner → admin → billing → member) — then externals at the bottom.
- // Matches the mental model "who's in your organisation, then your guests."
+ // Matches the mental model "who's in your organisation, then your externals."
// Stable within each tier by display_name so the list doesn't shuffle
// when roles change.
const MEMBERS_ROLE_WEIGHT: Record = {
@@ -490,7 +495,7 @@ export const WorkspaceSettingsRoute = () => {
const sortedMembers = useMemo(() => {
if (!settings) return [];
return [...settings.members].sort((a, b) => {
- if (a.is_external !== b.is_external) return a.is_external ? 1 : -1;
+ if (isExternalMember(a) !== isExternalMember(b)) return isExternalMember(a) ? 1 : -1;
const ar = MEMBERS_ROLE_WEIGHT[a.role] ?? 99;
const br = MEMBERS_ROLE_WEIGHT[b.role] ?? 99;
if (ar !== br) return ar - br;
@@ -504,18 +509,18 @@ export const WorkspaceSettingsRoute = () => {
const q = memberSearch.trim().toLowerCase();
return sortedMembers.filter((m) => {
if (memberRoleFilter === "admins") {
- if (m.is_external) return false;
+ if (isExternalMember(m)) return false;
if (!(m.role === "owner" || m.role === "admin")) return false;
}
if (memberRoleFilter === "members") {
- if (m.is_external) return false;
+ if (isExternalMember(m)) return false;
if (m.role !== "member") return false;
}
if (memberRoleFilter === "billing") {
- if (m.is_external) return false;
+ if (isExternalMember(m)) return false;
if (m.role !== "billing") return false;
}
- if (memberRoleFilter === "guests" && !m.is_external) return false;
+ if (memberRoleFilter === "externals" && !isExternalMember(m)) return false;
if (!q) return true;
return (
(m.display_name || "").toLowerCase().includes(q) ||
@@ -524,14 +529,14 @@ export const WorkspaceSettingsRoute = () => {
});
}, [sortedMembers, memberSearch, memberRoleFilter]);
- const hasGuestMembers = useMemo(
- () => sortedMembers.some((m) => m.is_external),
+ const hasExternalMembers = useMemo(
+ () => sortedMembers.some((m) => isExternalMember(m)),
[sortedMembers],
);
- // Show Billing chip only when ≥1 billing-role member exists (mirrors hasGuestMembers).
+ // Show Billing chip only when ≥1 billing-role member exists (mirrors hasExternalMembers).
const hasBillingMembers = useMemo(
- () => sortedMembers.some((m) => !m.is_external && m.role === "billing"),
+ () => sortedMembers.some((m) => !isExternalMember(m) && m.role === "billing"),
[sortedMembers],
);
@@ -539,7 +544,7 @@ export const WorkspaceSettingsRoute = () => {
const adminLikeCount = useMemo(
() =>
sortedMembers.filter(
- (m) => !m.is_external && (m.role === "admin" || m.role === "owner"),
+ (m) => !isExternalMember(m) && (m.role === "admin" || m.role === "owner"),
).length,
[sortedMembers],
);
@@ -576,11 +581,11 @@ export const WorkspaceSettingsRoute = () => {
);
}
- // Guests don't have a settings surface (matrix §4). The useEffect above
+ // Externals don't have a settings surface (matrix §4). The useEffect above
// kicks them to /projects, but that fires on the next tick — without
// this early return the settings tabs flash briefly. Render nothing
// while the redirect resolves.
- if (iAmGuest) {
+ if (iAmExternal) {
return null;
}
@@ -615,19 +620,19 @@ export const WorkspaceSettingsRoute = () => {
is already in the nav breadcrumb; duplicating it here
was audit noise (2026-04-23). */}
- {iAmGuest ? (
+ {iAmExternal ? (
- Guest of {settings.org_name}
+ External of {settings.org_name}
) : (
)}
- {!iAmGuest && settings.type_discount && (
+ {!iAmExternal && settings.type_discount && (
{settings.type_discount.replace(/_/g, " ")}
)}
- {!iAmGuest &&
+ {!iAmExternal &&
settings.percent_discount != null &&
settings.percent_discount > 0 && (
@@ -648,9 +653,9 @@ export const WorkspaceSettingsRoute = () => {
- {/* Guests bypass the tab structure — they have one workspace
+ {/* Externals bypass the tab structure — they have one workspace
and nothing to navigate. Tabs come next for everyone else. */}
- {!iAmGuest && (
+ {!iAmExternal && (
@@ -688,9 +693,9 @@ export const WorkspaceSettingsRoute = () => {
- Every workspace member, including guests, counts as
- one seat. One person never takes more than one seat
- per workspace, even if they're on multiple
+ Every workspace member, including externals, counts
+ as one seat. One person never takes more than one
+ seat per workspace, even if they're on multiple
organisations.
@@ -706,7 +711,7 @@ export const WorkspaceSettingsRoute = () => {
{/* Matrix §1 full capacity matrix on the billing tab.
Non-compact: price / duration / seats / overage /
- hours / guests / training. Highlights the current
+ hours / externals / training. Highlights the current
tier so admins can see what they have vs what's
next. */}
@@ -801,8 +806,8 @@ export const WorkspaceSettingsRoute = () => {
? [{ label: t`Billing`, value: "billing" }]
: []),
{ label: t`Members`, value: "members" },
- ...(hasGuestMembers
- ? [{ label: t`Guests`, value: "guests" }]
+ ...(hasExternalMembers
+ ? [{ label: t`Externals`, value: "externals" }]
: []),
],
value: memberRoleFilter,
@@ -841,7 +846,7 @@ export const WorkspaceSettingsRoute = () => {
) : (
- Add members or a guest to this workspace.
+ Add members or an external to this workspace.
)
}
@@ -849,7 +854,7 @@ export const WorkspaceSettingsRoute = () => {
seatInviteBlocked ? (
All seats are taken on this tier. Remove a member
- or guest, or upgrade the workspace tier to invite
+ or external, or upgrade the workspace tier to invite
more people.
) : undefined
@@ -903,9 +908,9 @@ export const WorkspaceSettingsRoute = () => {
)}
- {member.is_external && (
+ {isExternalMember(member) && (
- Guest
+ External
)}
@@ -967,23 +972,32 @@ export const WorkspaceSettingsRoute = () => {
Admin
+ ) : canManage && isExternalMember(member) ? (
+ // External row: dropdown locked to "External".
+ // Promotion to a member role goes through the
+ // org settings page → remove → re-invite flow
+ // (ADR-0003). The workspace UI never has a
+ // single button that mutates org_membership.
+
+
+ External
+
+
) : canManage ? (