Spec: F02 — Multi-tenant Organizations & Roles
Status: shipped foundation · agreed bootstrap expansion · Audited: 2026-07-30
1. Problem & CEO Job
Trust the signal + decide fast. A CEO will only feed Lumi honest operational data if it is structurally impossible for another company to see it. Multi-tenancy is the trust foundation; roles keep the CEO’s view executive (everything) while members see their lane. One cross-tenant leak ends the product — this is the most security-critical feature in Lumi. But isolation alone is not enough: the CEO also needs to onboard a new hire in seconds (paste a link into Zalo, dictate a code over a call) and, if they run multiple businesses, switch between orgs without signing out.
2. User Stories & Acceptance Criteria
- As a CEO, I want to invite my team with a link I can paste into Zalo/Telegram, so that onboarding takes seconds and needs no email infrastructure.
- Given an invitation link with role + optional team, when a person opens
/accept-invitation/:token, then they register-or-login and become an active member with that role. - Given an expired link or one at
max_uses, when opened, then a clear “link no longer valid — ask your admin for a new one” message (never a stack trace or silent failure).
- Given an invitation link with role + optional team, when a person opens
- As a CEO, I want a short invitation code I can dictate over Zalo or a call, so that a buried link is not a blocker.
- Given a new invitation, when it is created, then it also gets a unique short code in format
LUMI-XXXXusing an unambiguous alphabet (no0/O/1/I). - Given
POST /api/organizations/accept-invitation/, when I send{short_code}instead of{token}, then the same invitation object is redeemed with the same expiry, max-use, email-lock, and team semantics.
- Given a new invitation, when it is created, then it also gets a unique short code in format
- As a CEO, I want to suspend or remove a member instantly, so that a departing employee loses access the moment I act.
- Given a suspended member, when they call any API with that org’s
X-Organization-ID, then 403 — immediately, not at next login. - Given the departing member owns projects, when an owner/admin suspends or removes them, then every owned project transfers to the acting owner/admin, that actor joins each project roster if needed, and the membership loses access in the same transaction. If this grants the actor new private project/channel access, confirmation names the counts and the audit records the granted scope. Any failure changes neither ownership nor membership status.
- Given a suspended member, when they call any API with that org’s
- As a member of two companies, I want to switch organizations, so that consultants/fractional staff can use one account.
- Given memberships in orgs A and B, when I send
X-Organization-ID: B, then I see only B’s data, with my role in B.
- Given memberships in orgs A and B, when I send
- As an authenticated user with no invitation, I want to create or join an organization after login, so that Lumi never leaves me in an org-less dead end.
- Given an authenticated user with zero active memberships, when they open web, iOS, or Android, then Lumi asks whether they want to create an organization or join with an invitation code.
- Given they create an organization from that state, when creation succeeds, then they become
owner, the new organization becomes current. - Given a new organization, when creation succeeds, then it already contains Sales, Marketing, Ops, Product, Services, HR, Admin, and Accounting teams; the creator is manager and team admin of each, and each team has one private linked channel.
- Given any required team, channel, role, or membership write fails, when organization creation returns, then the whole transaction rolls back and no partial organization is visible.
- Given an existing organization created before this contract, when the release deploys, then Lumi does not silently add or rename teams; this bootstrap applies only to new organizations.
- As a member, I want to leave an organization, so that one account can stop seeing a company I no longer work with.
- Given a non-owner/admin member, when they leave the current organization, then their membership is deleted.
- Given the last active owner/admin of an organization, when they try to leave, then Lumi blocks the action with a plain-language error.
- Given I own one or more projects, when I try to leave, then Lumi returns
409 project_owner_transfer_requiredwith the owned-project count and changes nothing until I transfer them.
- As an owner or admin, I want to delete an organization, so that defunct companies do not linger as billable tenants.
- Given an owner/admin types the current organization slug correctly, when they confirm delete, then the organization and its tenant-owned data are deleted.
- Given the slug confirmation is wrong, when they attempt delete, then nothing is deleted and they get a 400-level error.
- As a viewer (e.g. investor, advisor), I want read access only.
- Given role
viewer, when I attempt any mutation, then 403 on every endpoint.
- Given role
Role semantics (the contract)
| Role | Sees | Can do |
|---|---|---|
| owner | everything | everything incl. org settings, leave org when another owner/admin remains, delete org |
| admin | everything | manage members/teams/work, leave org when another owner/admin remains, delete org |
| manager | org-wide work data | manage work in their scope; invite members |
| member | own + team work | CRUD own work, submit pulses, leave org |
| viewer | read-only org data | nothing mutating |
Five roles, no fine-grained permissions — deliberate (docs/AGENTS.md → What Not To Build).
3. Scope Boundaries
- In: org creation from auth bootstrap, atomic creation of the standard team/channel set,
current/update/delete, membership lifecycle (invite link → active → suspended/removed/left), role
enforcement, org switching, org-level settings (
timezonedefaultAsia/Ho_Chi_Minh,brief_timedefault 08:00,working_days). - Out: email invitations, SSO/SCIM, per-object permissions, soft-delete/archive for organizations, organization ownership transfer, and automatic team backfill for existing organizations.
Competitive note — the real alternative is no shared tenant at all. Our target SME owner — a Vietnamese interior fit-out / thi công contractor — does not run a workspace; the “org” today is a tangle of Zalo groups, a shared Excel file, and the owner’s memory of who does what. There is no incumbent tenant to migrate from, and no SMTP-based invite flow the staff will ever open. Lumi’s job is therefore not “be a better Jira/Asana org model” — it is to give chaos a structure the owner can stand up in seconds and the staff can join with zero training. Two design choices serve that:
- Onboarding collapses to one step. The no-org bootstrap gate routes zero-org users into create-or-join immediately after auth (F01 §2) — no separate “create a site, then a project, then a board” ladder. The owner who just left a Zalo thread is in a working org in their first minute.
- Invites travel where the team already lives. A paste-into-Zalo link plus a dictate-over-a-call short code (
LUMI-XXXX) needs no email infrastructure — staff accept from the same chat app they use all day, in seconds. This is Loop 1 (owner → staff) — expansion inside one company. The separate Loop 2 (company → company) acquisition leg — pulling in a supplier, subcontractor, or client as a new tenant via an external confirmation link — lives in F71 and is deliberately not conflated with internal invites here.
The five-role model (owner/admin/manager/member/viewer) is org-wide, not per-project: the owner says “this person sees everything” once, instead of re-granting on every project — the minimum viable role surface for an owner who is still the central router of every decision.
4. Data Model
Organization— global model;slugunique; brief schedule fields consumed by F12/F21.OrganizationMember— the actor identity for all business data (never FK toUserfrom domain models).role: owner/admin/manager/member/viewer. The persistedstatusenum still includesinvited/active/suspendedfor compatibility, but shipped invite flows useInvitationLinkas the pending artifact and createOrganizationMemberrows only when a person is already active or later suspended.InvitationLink—token(UUID7),short_code(LUMI-XXXX), optionalemaillock,role, optionalteam,expires_at,max_uses/use_count,is_active.- Standard-team bootstrap is owned by the organization creation service but persists normal F03
Teamrows. Each standard team has a stable nullablebootstrap_key(sales|marketing|ops|product|services|hr|admin|accounting) unique within the organization. User-created teams havebootstrap_key=NULL; the key is immutable and names remain editable. Organization create accepts an optionallanguagefield populated automatically from the active Lumi UI locale; it is not another form question.viinitializes Vietnamese names and every other supported value initializes English. Older clients that omit it fall back to the creator’s saved, user-editableUser.language, matching F01/F37 rather than the browser/OSAccept-Languageheader. The Vietnamese mapping is Kinh doanh, Tiếp thị, Vận hành, Sản phẩm, Dịch vụ, Nhân sự, Hành chính, and Kế toán in the same key order. Bootstrap does not chooseOrganizationMember.team; the creator’s optional primary/pulse team remains unset until they select one.
Isolation is explicit and manual: IsOrgMember resolves X-Organization-ID into
request.organization_id and request.membership; every query writes its own tenant predicate.
There is no TenantMiddleware, TenantManager, TenantQuerySetMixin, or request.tenant.
5. API Contract
| Method | Path | Min role | Notes |
|---|---|---|---|
| POST | /api/organizations/ | any user | Optional language comes from active Lumi locale, falling back to saved user language; one transaction creates org, owner membership, roles, general channel, default CRM pipeline, eight standard teams, and eight linked team channels |
| GET | /api/organizations/current/ | viewer | Org for current X-Organization-ID |
| PATCH | /api/organizations/current/update/ | admin | Name, timezone, brief_time, working_days |
| POST | /api/organizations/current/delete/ | admin | Requires exact slug confirmation |
| POST | /api/organizations/accept-invitation/ | any user | Consume {token} or {short_code} → membership |
| GET | /api/members/ | viewer | List members (status=active only) |
| POST | /api/members/leave/ | viewer | Leave unless the actor owns projects; transfer is required first |
| POST | /api/members/invite/ | owner/admin | Direct invite; email resolves through the global lowercase, case-insensitive account identity. An address with no account yet gets a placeholder User marked is_provisioned with an unusable password, which its owner claims at registration (F01) — the invite never locks anyone out of their own email. A claimed membership is capped at member regardless of the invited role (ADR-049) |
| PATCH | /api/members/<id>/ | admin | Change role/team |
| POST | /api/members/<id>/suspend/ | admin | Immediate lockout; if projects are owned, requires confirm_project_handoff=true, transfers them atomically to the acting admin, and audits newly granted private scope |
| POST | /api/members/<id>/remove/ | owner/admin | Immediate removal; same explicit handoff confirmation/audit; owner-role target still blocked |
| GET | /api/members/invitation-links/ | owner/admin | List invitation links |
| POST | /api/members/invitation-links/create/ | owner/admin | Create invitation link |
Every endpoint explicitly scopes tenant-owned rows to request.organization_id; cross-tenant
object access returns 404 (not 403 — do not confirm existence).
6. UI / UX
/settings(SettingsPage.tsx) — org profile + org switcher + create/join/leave/delete controls in one page./organization-setup(web) and the equivalent iOS/Android gate — shown only when the authenticated user has zero active orgs./settings/invite(InvitePage.tsx),/accept-invitation/:token(AcceptInvitePage.tsx) — accept flow must work logged-out (register inline).- Shared Android/iOS Manage organization places a distinct Invite teammates / Mời thành viên section immediately after the organization identity for owners and admins. It exposes the tenant-scoped email invite form alongside the existing invitation-link API (create, copy, share, and replace states) without widening backend authorization; manager, member, and viewer roles do not receive either invite action or the join secret.
- States: members list loading (Skeleton rows), empty no-org setup state, invalid/expired token page with next step, leave blocked because user is last active owner/admin, delete confirmation mismatch.
- Standard teams require no setup wizard or success checklist. After organization creation the existing mobile shell opens normally; Teams and Messages contain the ready-to-use results.
- Web, Android, and iOS send the current Lumi locale in the create request automatically; users do not answer a second language question.
- i18n:
settings,invite,accept-invite.
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Cross-tenant read/write blocked on every resource | backend | tests/test_lumi.py::TestTenantIsolation, tests/test_solid.py |
| 2 | Invitation link + short code: happy path, expiry, max_uses, email-locked, invalid code | backend | tests/test_multitenancy.py::TestInvitationLinks |
| 3 | Suspend/remove revokes access immediately | backend | tests/test_multitenancy.py::TestRemoveMember |
| 4 | Multi-org user sees correct data per header | backend | tests/test_multitenancy.py::TestMultiOrgMembership |
| 5 | Leave org + delete org lifecycle, including governance guardrails | backend | tests/test_multitenancy.py::TestOrganizationLifecycle |
| 6 | Viewer mutation attempts all 403 | backend | tests/test_lumi.py::TestViewerCannotMutate, tests/test_api.py::TestViewerPermissions |
| 7 | Org setup, create/join, leave, delete, switch | e2e/mobile | web org lifecycle E2E + iOS/Android gate/workspace tests |
| 8 | Well-known LUMI-TEST demo invitation: long-lived, idempotent, join via lowercase input | backend | tests/organizations/test_demo_invite.py::TestEnsureDemoInvitation |
| 9 | Join with code journey: register → wrong code error → LUMI-TEST → main shell | e2e/mobile | .maestro/critical/51_join_with_code.yaml |
| 10 | New organization creates exactly eight keyed teams, eight linked private channels, and creator memberships | backend | backend/tests/organizations/test_standard_workspace_bootstrap.py |
| 11 | Mid-bootstrap failure leaves no organization, team, channel, or membership rows | backend | backend/tests/organizations/test_standard_workspace_bootstrap.py |
| 12 | Existing organizations are unchanged and retrying the service does not duplicate keyed teams/channels | backend | backend/tests/organizations/test_standard_workspace_bootstrap.py |
| 13 | Explicit en/vi Lumi locale field, saved-language fallback for older clients, stable keys, and unchanged primary team | backend + web/shared | backend/tests/organizations/test_standard_workspace_bootstrap.py + P60 client tests |
| 14 | Suspend/remove confirms and audits newly granted scope, transfers every owned project, and revokes access atomically | backend + PostgreSQL | backend/tests/organizations/test_project_owner_deactivation.py |
| 15 | Project owner cannot self-leave until explicit transfers complete | backend | backend/tests/organizations/test_project_owner_deactivation.py |
| 16 | Shared organization management gives invitation codes a distinct Owner/Admin-only section before the member list | shared Android/iOS | OrgInvitationVisibilityTest; OrgInvitationDiscoverabilitySourceTest |
8. Current Behavior
Shipped foundation. Multi-tenant membership, invitations, role-scoped settings, org switching, and
last-owner protections are live; invitation short codes were added later as a hardening pass.
Tenant isolation is enforced manually through request.organization_id predicates in every
ViewSet/service query. Leave and delete flows include governance safeguards that prevent orgs from
being left without an active owner/admin. Pending invite access is represented by
InvitationLink; the normal member lifecycle in shipped flows is active → suspended/removed/left,
not invited → active.
Organization creation now runs atomically and seeds the owner, roles, one general channel, the default CRM pipeline, and eight stable-key standard teams with one inherited private channel each. The request may carry the active Lumi locale; older clients fall back to the user’s saved language, and every supported non-Vietnamese locale receives the English team names. Suspending or removing a project owner requires explicit confirmation, locks and transfers every owned project to the acting owner/admin, synchronizes newly granted private-channel scope, and writes an audit record in the same transaction. Self-leave remains blocked until the member explicitly transfers ownership. Shared native presentation and exact-route virtual-device proof now cover the seeded workspace through P60 E01 on Android and iOS virtual runtimes. Web, macOS, named-device, and store proof remain P60 follow-up slices.
On shared mobile, the direct-invite duplicate-member response remains visible validation feedback but is classified as an expected ES6 outcome rather than a Sentry non-fatal error. Other HTTP 400 responses retain normal telemetry so client/API contract defects are not hidden.
2026-07-25 — Direct invitations preserve canonical account identity
POST /api/members/invite/lowercases a valid email throughUserManager.normalize_emailbefore account lookup or creation.- Email-locked invitation links store that same canonical identity; acceptance also normalizes legacy mixed-case locks before comparison.
- A letter-case variant of an existing member resolves that same user and
returns the stable
Already a memberresponse instead of reaching the database uniqueness constraint. - Focused integration proof covers direct-invite storage, the existing-member case variant, email-lock storage, and legacy mixed-case lock acceptance.
9. Error States
| # | Trigger | Response |
|---|---|---|
| ES1 | POST /api/organizations/ with missing or blank name | 400 — serializer validation error |
| ES2 | PATCH /api/organizations/current/update/ by non-owner/admin | 403 IsOrgOwnerOrAdmin |
| ES3 | POST /api/organizations/current/delete/ with wrong confirm_slug | 400 “Confirmation slug does not match the current organization.” |
| ES4 | POST /api/members/leave/ as sole active member | 400 “Delete the organization instead of leaving your last active membership.” |
| ES5 | POST /api/members/leave/ as last active owner/admin | 400 “Promote another owner or admin before leaving this organization.” |
| ES6 | POST /api/members/invite/ for a user already in the org (any status or email letter casing) | 400 “Already a member” |
| ES7 | POST /api/organizations/accept-invitation/ with expired link | 400 “Invitation link has expired” |
| ES8 | POST /api/organizations/accept-invitation/ with fully-used link | 400 “Invitation link has been fully used” |
| ES9 | POST /api/organizations/accept-invitation/ when already a member | 400 “Already a member of this organization” |
| ES10 | POST /api/organizations/accept-invitation/ with email-locked link, wrong user email | 403 “This invitation is for a different email” |
| ES11 | POST /api/organizations/accept-invitation/ with unknown token or short_code | 404 “Invalid invitation link” / “Invalid invitation code” |
| ES12 | DELETE /api/members/<id>/ targeting the org owner | 400 “Cannot remove the owner” |
| ES13 | DELETE /api/members/<id>/ targeting self | 400 “Cannot remove yourself” |
| ES14 | DELETE /api/members/<id>/ with non-existent member ID | 404 |
| ES15 | PATCH /api/members/<id>/ or POST /api/members/<id>/suspend/ with non-existent ID | 404 — FIXED 2026-06-13 |
| ES16 | Any authenticated request from a suspended member | 403 — IsOrgMember filters status="active" |
| ES17 | X-Organization-ID header absent | 403 — IsOrgMember returns False immediately |
| ES18 | Project ownership handoff fails during suspend/remove | Transaction rolls back; member remains active and every project keeps its prior owner |
| ES19 | Member owns projects and calls leave | 409 project_owner_transfer_required with count; no membership, owner, roster, or ACL change |
| ES20 | Suspend/remove would grant actor private scope without confirm_project_handoff=true | 409 project_handoff_confirmation_required with project/channel counts and no mutation |
10. Diagrams & Docs Touched
DIAGRAMS.md tenant flow · backend/DIAGRAMS.md ER diagram · GLOSSARY multi-tenancy terms.
11. Implementation Audit (2026-06-11)
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | Org CRUD: create (creator→owner), current, update, delete with slug confirmation | ✅ match | tests/test_multitenancy.py::TestOrganizationLifecycle; ES3 delete guard |
| 2 | Invitation link + short_code dual redemption (LUMI-XXXX; same expiry/max_uses/email-lock/team) | ✅ match | InvitationLink.short_code; accept_invitation accepts both; TestInvitationLinks |
| 3 | Five roles enforced (viewer → 403 on all mutations) | ✅ match | IsOrgOwnerOrAdmin, IsOrgManager, IsOrgMember; TestViewerCannotMutate |
| 4 | Suspend → immediate 403 (IsOrgMember filters status="active") | ✅ match | organizations/permissions.py:9-24; TestRemoveMember |
| 5 | Leave safeguards: last active owner/admin → 400; sole member → 400 | ✅ match | _has_other_active_owner_or_admin(); ES4, ES5 |
| 6 | Cross-tenant: 404 not 403 on all domain resources | ✅ match | TenantQuerySetMixin; TestTenantIsolation |
| 7 | Pending access lives on InvitationLink; shipped org-member flows create status="active" rows and only later transition them to suspended or deletion. status="invited" remains a compatibility enum/manual-fixture state, not a first-class product phase. | ✅ match | invite_member and accept_invitation both create status="active"; list_members returns active only; test_pulses_adherence still defensively excludes manual invited fixtures from totals |
| 8 | Concurrent org creation with same name — application-layer slug loop, not DB constraint | ⚠️ gap | _unique_organization_slug() loops with no DB lock; concurrent callers can produce duplicate slug race |
| 9 | Short-code exhaustion: 20 retries, RuntimeError → 500 | ✅ accepted | SME scale makes exhaustion negligible; generate_short_code() RuntimeError path documented |
| 10 | Role permission checks inline in views — not consolidated into a single permission module | ⚠️ gap | §9 audit: “consolidate into one permission map so the table in §2 is provably the code”; tracked for next org touch |
Summary: 8/10 match; 2 issues (concurrent slug race, inline permission checks).
Edge Cases Catalog (formerly §11, 2026-06-12; E1–E14): Key items integrated: slug collision (E1→row 8), concurrent creation (E2→row 8), short-code exhaustion (E3→row 9), inactive link → 404 (E4 — by design), email-locked link wrong user → 403 without revealing target (E5), accepting invite while suspended (E7 — returns 400 “Already a member”), team deleted between invite/accept → team FK SET_NULL, member skips team (E8), demotion of sole owner (E9 — FIXED 2026-06-13), status="invited" retained only as a compatibility/manual-fixture enum rather than a shipped lifecycle phase (E10→row 7), sole-owner suspend guard (E11 — FIXED 2026-06-13), suspended members invisible in list (E12), cross-tenant 404 (E13), non-UUID org header (E14). Full catalog in git history (2026-06-12 changelog entry).
12. Open Questions
managervsadminboundary is thin in practice; revisit only if real orgs ask — do not add roles.me/orgs/already includes per-org role in code. Whether it should expose a server-chosencurrent_org_id: defer unless switching UX becomes inconsistent.
13. Bug / Change Log
2026-08-24 — Keep duplicate short-code joins out of error telemetry
Type: Shared mobile telemetry fix
Summary:
- Sentry
LUMI-ANDROID-26recorded the contract-defined400 Already a member of this organizationresponse from the Android typed invitation-code flow in dev build1.0.2-dev+61. OrgManagementScreenModel.joinWithCodenow classifies only that exact status-and-detail pair as expected validation. The existing user-facingAlready a member of this organizationfeedback is unchanged; malformed or unrelated invitation failures remain reportable.
Evidence:
OrgManagementScreenModelTest.joinWithCodeAlreadyMemberRemainsValidationFeedbackWithoutNonFatalReportingproves the state remains visible and the reporter is not called.- The Sentry event was from
com.locuno.lumi.dev@1.0.2-dev+61; post-fix release observation remains required before resolving the signature.
2026-08-25 — Show the invited member role after organization setup (lumi-fb #107)
Type: Shared mobile role-copy bug fix
Summary:
- Organization setup now retains the role returned by the create/join response.
- Creators continue to see owner guidance, while invitation joiners see member wording and member-safe feature hints instead of an owner-only invitation prompt.
- Added shared model and source regressions for the role state and English/Vietnamese copy.
Evidence boundary:
- Shared JVM proof establishes response-role propagation and copy selection. Exact merged mobile artifacts and reporter-device verification remain separate handoff and acceptance evidence.
2026-08-15 — Restore the mobile email invite form (lumi-fb #99)
Type: Mobile organization-management enhancement
Summary:
- Shared Android/iOS organization management now renders the existing owner/admin direct-invite contract as an email form before the member directory.
- The form reuses
OrgManagementScreenModel.inviteMember, keeps the backend tenant and role gate, preserves visible duplicate-member validation, and leaves invitation-code sharing unchanged.
Evidence boundary:
- Shared KMP source/model proof establishes the role gate, form placement, request wiring, and validation behavior. A focused Android Maestro runner owns a disposable P60 tenant and exact APK submit proof; named Android/iOS device confirmation remains separate release and acceptance evidence.
2026-08-15 — Keep organization creation visible above the Android keyboard (lumi-fb #98)
Type: Mobile layout bug fix
Summary:
- The shared Settings → Manage organisation switch dialog now centers its overlay card inside the Android IME-safe viewport, so the organization-name field and Create action remain reachable when the keyboard is open.
- Added locale-independent targets for the dialog’s create field and action to keep the regression path attached to the actionable semantics nodes.
- Organization roles, tenant selection, validation, and API calls are unchanged.
Evidence boundary:
- Source/unit proof protects the shared dialog’s single-owner IME contract. Exact merged dev APK runtime evidence and reporter-device confirmation remain separate and are recorded with the issue handoff.
2026-08-11 — Make mobile invitation codes discoverable without widening access
Type: Mobile discoverability fix
Summary:
- Moved the existing invitation-code controls out of the organization identity card and into a distinct localized Invite teammates / Mời thành viên section before the member directory.
- Preserved the backend’s Owner/Admin authorization boundary and the existing create, copy, share, replace, expiry, usage, and redemption behavior.
- Added shared role-policy and source-order regressions for both Android and iOS hosts.
Evidence boundary:
- Shared JVM proof establishes role and structural placement. Named Android/iPhone interaction, accessibility-service, clipboard/share-sheet, and reporter-device discoverability remain open.
2026-07-30 — Specify an immediately useful new organization
Type: Behavior Change
Summary:
- New organizations atomically receive eight stable standard teams and one inherited private channel per team, with no setup wizard.
- Existing organizations are explicitly not backfilled.
- Corrected the stale tenancy description: this repository uses manual
request.organization_idscoping, not the nonexistent middleware/manager stack.
Reason:
- A blank organization forces the owner to model the company before anyone can communicate.
Impact:
- F02 owns bootstrap orchestration; F03 owns team behavior; F29 owns linked-channel custody; ADR-050 and P60 define their shared transaction boundary.
Tests:
- Planned in
docs/test-plan/project-team-channels.mdcases O01–O05.
2026-07-29 — A direct invite no longer squats the invitee’s email
Type: Auth/onboarding correctness fix
Summary:
invite_memberresolved the invitee withUser.objects.get_or_create, which skipsUserManager.create_userand wrote the row withpassword="". The address then read as taken at registration and had nothing to authenticate against at login, so every invited person was locked out of their own email (lumi-fb#66).- Rows the invite creates are now marked
is_provisionedwith an explicit unusable password. Registration claims that row in place (F01), preserving thestatus="active"membership, so the invitee finishes sign-up already inside the organization. - Pending access still lives on
InvitationLink; this change does not alterOrganizationMember.statussemantics, andstatus="invited"remains unused by shipped flows. - A claim is capped at
member: inviting someone as owner/admin/manager (or with acustom_role) still works, but the role is lowered on claim and written to the audit log asmember.role_capped_on_claim, so an admin re-grants it once the right person is in.
Evidence:
backend/tests/security/test_invited_account_claim.py— claim once, membership preserved, duplicate invite still rejected, real/claimed/system accounts unclaimable.- Migration
accounts.0009backfills already-squatted rows.
Evidence boundary:
- Joining has no mailbox-verification gate by design
(ADR-049). Whoever registers an
invited address first reaches
member-level data in that tenant; admin review and removal is the control, and the privilege cap bounds the damage.
2026-07-28 — One mobile entry opens the complete organization screen
Type: Navigation simplification
Summary:
- Shared iOS/Android Workspace and Android’s legacy account directory now expose one localized Manage organization row instead of four adjacent organization rows.
- The consolidated row opens the first Switch organization tab. Switch, join, create, and management functions remain inside the existing tabbed screen.
- Legacy route aliases remain available for deep-link compatibility; only visible directory duplication is removed.
Evidence boundary:
- Cross-platform source policy and native shell route tests cover inventory and first-tab routing. Reporter-device discoverability, localization, focus, and accessibility remain open.
2026-08-14 — iOS Home organization badge opens organization management
Type: Navigation repair
Summary:
- The organization badge in the iOS Lumi/Home header is an accessible button, not display-only decoration.
- Selecting it immediately opens the existing organization-management screen on its first organization-list tab, matching Android without adding a confirmation step or duplicate route.
Evidence boundary:
- Contract source proof covers the callback, route, and localized accessibility label. iOS simulator proof covers the rendered navigation path; reporter-device confirmation remains open.
2026-07-25 — Reject overlong mobile invitation codes before the API
Type: Bug Fix
Summary:
- Shared organization setup and Settings join flows now normalize typed invitation codes once and stop non-empty values longer than the API’s nine-character
short_codecontract before networking. - The existing localized invalid-code UI remains the user-facing response; expected local validation never enters non-fatal telemetry.
- Shared JVM regressions cover both entry paths and preserve the valid nine-character boundary.
Evidence:
- Sentry
LUMI-ANDROID-26/ issue7632319665recorded the backend’s expectedshort_codemax-length validation as an Android production error. - Contract Graph requirement
F02.AC006.
2026-07-25 — Keep duplicate-member invite validation out of error telemetry
Type: Production telemetry fix
Summary:
- Sentry LUMI-ANDROID-25 recorded the contract-defined ES6 response twice for one production user.
- Shared mobile now identifies only the exact
400 "Already a member"direct-invite response as expected validation. - The user still sees
Already a member; unrelated invite failures remain reportable.
Evidence boundary:
- The unfiltered shared JVM suite proves state and reporter behavior. Named-device runtime proof remains open.
2026-07-20 — Deterministic iOS tenant-switch and relaunch proof
Type: Verification hardening
Summary:
- Replaced the mutable production-demo dependency in critical flow 44 with a disposable local user who owns a sparse source organization and a target organization containing one target-only company.
- Extended the flow beyond the in-process switch: it stops and relaunches the app without clearing credentials, reopens Companies, and requires the same target-only company again.
- Added fail-closed cleanup for both organizations, both memberships, the user,
and the target company; registered shared/iOS/policy proofs in
F02.AC004.
Evidence boundary:
- Current-source build 151 passed the disposable two-tenant switch and relaunch
journey on the single approved iOS 26.4 simulator with executable SHA-256
ce3652b356785945afaf49af837cae3b0dd0cfc398684d78cae9f13984a18952. - The deterministic runner is simulator mechanics evidence. Named Android and
signed named-iPhone execution remain explicit
F02.GAP002obligations.
2026-07-16 — Mobile: make the zero-org gate surface deterministic (ONB-04)
Type: Mobile verification hardening
Summary:
- Added a stable root selector to
OrgSetupScreenwhile preserving the existing create-name, create, join-code, and join selectors. - Extended
.maestro/critical/02_auth_register.yamlto select and complete the create path after the organization setup choice surface; the separate join round trip remains covered by.maestro/critical/51_join_with_code.yaml.
Honest gate:
- A guarded
Lumi_Pixel7_API35execution of.maestro/critical/02_auth_register.yamlpassed 1/1 in 51 seconds. The exact tested source commit waseb04b6cbeb9358e0898f4ffd486a37a867451650; the built and installed APK SHA-256 wasf2a074ce0ccdb4600b88c8e9686c6aed9086d200fd92c11bf0ce85c6afdd23cb. - The retained command evidence is
/Users/james/.maestro/tests/2026-07-16_143017. Visual inspection confirmed both create and join paths before mutation, a keyboard-visible organization field and Create CTA, and successful transition to the authenticated shell. - This is Android emulator UI evidence only. Join mutation, named Android
floor-device, iPhone 12, and PM scoring remain open;
docs/tests/mobile-screen-audit.mdis unchanged.
2026-07-13 — Contract Graph migration slice
Type: Contract and proof traceability
Summary:
- Added the canonical F02 CUE contract for organization lifecycle, active-member governance, and token/short-code invitation redemption; F35 invitation email, F52 onboarding, F57 workspace apps, and F73 custom roles remain explicit ownership boundaries.
- Attached stable proof IDs to 12 existing backend security tests and regenerated the Contract Graph
reports. F02 is intentionally
partialbecause browser invitation and named Android, iOS, and macOS runtime evidence are not yet available.
Impact: No product behavior change. The contract makes 13 previously unowned organization/member endpoints traceable and preserves the five-role, tenant-isolated product boundary.
Tests: The Contract Graph verifier executes the selected F02 pytest proof records; the remaining
runtime gap is explicit in contracts/features/F02.cue.
2026-07-10 — Well-known LUMI-TEST demo invitation for store review + join E2E
Type: Change
Summary:
- App Store review (Guideline 2.1(a), v1.0 build 143) could not assess “Join with code”: ad-hoc invitation links expire after 7 days with random codes, so nothing given to a reviewer survives a review cycle.
- Added
apps/organizations/demo_invite.py::ensure_demo_invitation()— an idempotent, long-lived (~10 years, 100k uses)LUMI-TESTinvitation into the demo organization. Called byseed_demo_lumiand by data migration0021_demo_invitation_code(skips environments without demo data), so every deploy guarantees the code exists. - Live-verified the accept path on dev before changing anything: create link (201), join as fresh user without an org header (201), lowercase/whitespace input normalized by
AcceptInvitationSerializer(strip().upper()), invalid code 404, duplicate join 400 — no backend defects found. - Mobile: added
org_join_buttontestTag (OrgSetupScreen) andorg_mgmt_join_field/org_mgmt_join_button(OrgManagementScreen JoinCodeTab); the static-label form system (#1597) makes label-text taps miss the fields, so E2E needs ids. - New Maestro journey
.maestro/critical/51_join_with_code.yaml: register fresh user → wrong code shows the error banner → LUMI-TEST joins the demo org → main shell. New backend tests intests/organizations/test_demo_invite.py(idempotency, expiry refresh, alphabet, no-demo-org no-op, lowercase join, migration/seed code pinning).
Reason: store reviewers (and any evaluator) need a permanently valid join code; the join flow itself was correct but unguarded by E2E and unreachable for review.
2026-07-02 — Security fix: restrict invitation-link listing to owner/admin
Type: Security Fix
Summary:
GET /api/members/invitation-links/(list_invitation_links,views.py:372) was gated byIsOrgMemberonly, so any active member — including plain members and viewers — could list all of the org’s active invitation links, which carry join-secrettokenandshort_codefields.- Raised the gate to
IsOrgOwnerOrAdmin, matchingcreate_invitation_link(same view file, line 383). Members and viewers now receive 403. - §5 permission column updated from
any membertoowner/admin(was flagged as a decision item in the 2026-07-02 documentation-truth entry below; decision is: tighten to owner/admin per original spec intent). - Manager-level invite (the original §2 role-semantics promise) is intentionally deferred as a product decision; the one-line change would be relaxing the gate to
IsOrgManager— tracked in §12 open questions, not implemented here. - Frontend:
/settings/invite(InvitePage.tsx) is already sidebar-gated toisAdminOrOwner(Sidebar.tsx:97); no UI change needed. Regular members have no path to this surface via normal navigation.
Reason:
- Invitation links carry
token(UUID) andshort_code(LUMI-XXXX) secrets that allow any user to join the org without further approval. Exposing these to all members is a permission over-exposure inconsistent with the original spec intent and with F73 RBACroles.manage.
Impact:
- Backend:
list_invitation_linksnow returns 403 for rolemember,manager, andviewer. - Frontend: No change required — the page is already hidden from non-admin/owner in the sidebar.
- Tests: Added
test_list_invitation_links_admin_200,test_list_invitation_links_member_denied,test_list_invitation_links_viewer_deniedintests/test_multitenancy.py::TestInvitationLinks.
2026-07-02 — Documentation truth: align §5 permission column to shipped code; flag behavior drifts
Type: Documentation Change
Summary:
POST /api/members/invite/: §5 said min rolemanager. Actual gate isIsOrgOwnerOrAdmin(organizations/views.py:218); managers who call this endpoint receive 403. Updated §5 toowner/admin.POST /api/members/<id>/remove/: §5 said min roleowner. Actual gate isIsOrgOwnerOrAdmin(organizations/views.py:343); admins can remove members (removing the owner-role target is still blocked by a separate 400 guard). Updated §5 toowner/admin.GET/POST /api/members/invitation-links/,…/create/: §5 collapsed list and create under a singleadminrow. Actual gates differ: list (list_invitation_links) isIsOrgMember(views.py:373) — any active member; create (create_invitation_link) isIsOrgOwnerOrAdmin(views.py:383). Split into two rows reflecting the real split.
FLAG — three permission drifts resolved by aligning to shipped code. Decision required:
POST /api/members/invite/(direct invite): The original spec promised manager-level invite rights, matching the role-semantics table in §2 which states managers can “invite members.” The code ships withIsOrgOwnerOrAdmin, so managers cannot invite. If this was unintentional, the fix is to relax the code permission toIsOrgManager; if it was a deliberate tightening, §2’s role-semantics table also needs updating.- Invitation-link listing visible to all members: The original spec implied admin-only management. The code ships with
IsOrgMemberon the GET, so any active member (including viewers) can list all active invitation links. If this is unintentional, the fix is to raise the list gate toIsOrgOwnerOrAdmin. - The
POST /api/members/<id>/remove/change (owner → owner/admin) is a narrowing of stated restriction, not an expansion of access; it is unlikely to be unintentional but is flagged for completeness.
Reason:
- §5 permission column mismatched
organizations/views.pypermission decorator reality, verified withgrep -n "IsOrgOwnerOrAdmin\|IsOrgMember" views.py.
Impact:
- Documentation only. No API, DB, or UI behavior changed. Code decisions are flagged above for the human to resolve.
Tests:
- None (documentation change).
2026-07-02 — Clarify invited-status contract to match shipped org lifecycle
Type: Documentation Change
Summary:
- Corrected §4, §8, and §11 so F02 no longer overclaims
OrganizationMember.status="invited"as an active product phase. - Documented the real shipped split:
InvitationLinkcarries pending access, while normal org-member rows are created asactiveand may later becomesuspendedor be removed/left. - Reclassified row 7 of the implementation audit from spec drift to an explicit compatibility note, keeping the remaining open issues focused on the true debt (
slugrace and inline permission checks).
Reason:
- The backend creates active memberships in both
invite_memberandaccept_invitation; the earlier spec wording described a lifecycle the product does not actually run.
Impact:
- Documentation only. No API, DB, or UI behavior changed.
Tests:
- None (documentation change).
2026-06-15 — Frontend route namespaced
Frontend route namespaced: /invite -> /settings/invite (app-wide IA restructure; API paths unchanged).
2026-06-14 — Strategy v2 realignment (positioning)
Type: Documentation Change
Summary:
- Rewrote the §3 competitive note. Old framing positioned Lumi’s org model and invite flow against US SaaS (“Jira requires a site and a project, Asana requires a team name, Monday demands a board, ClickUp demands a workspace”; “Jira, Asana, Monday, ClickUp all send email invites”). New framing positions against the real first-market alternative: no shared tenant at all — a Vietnamese contractor whose “org” is a tangle of Zalo groups + a shared Excel + owner memory. Kept the two product points (one-step bootstrap; Zalo-paste link +
LUMI-XXXXshort code as Loop 1) and the org-wide five-role rationale. Added a one-line pointer that the external company→company (Loop 2) leg lives in F71, explicitly not conflated with internal invites.
Reason:
- Realign to competitive-strategy-v2.md (Inversion 1 — be the stack, nothing to overlay; Inversion 7 — real competitor is Zalo/WhatsApp + Excel; §10.4 Loop 1 vs Loop 2) and the registry “Positioning doctrine (v2)” rules 1, 2, 4, and 5.
Impact: Positioning only. No behavior change — org bootstrap, invitation link + short-code redemption, role enforcement, and tenant isolation are all unchanged.
Tests: None (documentation change).
Notes: No edits to §4 Data Model, §5 API Contract, §7 Test Plan, §9 Error States, §11 Implementation Audit, or the Status line. F71 referenced as an existing spec only — no scope change to F02.
2026-06-14 — Restructure to TEMPLATE.md section order; expand §11 audit (SP fleet)
Type: Documentation Change
Summary:
- Numbered “Current Behavior” as §8.
- §9 Implementation Audit (narrative) → §11 (10-row table); §8 Diagrams → §10; §11 Edge Cases absorbed into §11 note; §12 Error States (ES1-ES17) → §9; §10 Open Questions → §12; §13+§14 Bug/Change Log merged → §13.
- §11 audit: 10 rows, 7 ✅, 3 issues (
status="invited"dead code, slug race, inline role checks).
2026-06-13 — Governance safeguards: sole-owner demotion + suspension (E9, E11)
Type: Bug Fix
Summary:
update_member: calls_has_other_active_owner_or_admin()before demoting owner/admin role; returns 400 if none remain.suspend_member: calls same guard before suspending owner/admin; returns 400 if none remain.- Tests:
test_demote_sole_owner_returns_400,test_demote_owner_allowed_when_another_admin_exists,test_suspend_sole_owner_returns_400. - ES15 fixed: both
update_memberandsuspend_membernow wrapOrganizationMember.objects.get()intry/except DoesNotExistreturning 404.
2026-06-12 — Spec hardening: Edge Cases (E1–E14) and Error States (ES1–ES17)
Type: Documentation Change
2026-06-11 — Short invitation codes shipped
Type: Feature
Summary:
InvitationLinkgeneratesshort_code(LUMI-XXXX) alongside UUIDtoken.- Accept endpoint accepts
{token}or{short_code}interchangeably.
2026-06-12 — Org lifecycle completion (leave, delete, no-org bootstrap)
Type: Behavior Change
Summary:
- Formalized the no-org bootstrap flow, self-serve leave organization, and admin org deletion.
- F01, F02, F24, and F30 now agree on a first-class org lifecycle.
- Delete requires explicit slug confirmation. Leave is blocked if it would leave the org without an active owner/admin.
14. Success Metrics (Appendix)
- Time-to-team (invite-to-accept P50 < 60s): measured server-side as minutes from invitation link creation to first
POST /api/organizations/accept-invitation/that produces an active membership. Tied to §2 story 1 — the CEO pastes a link into Zalo or dictatesLUMI-XXXXover a call. A P50 above 60s means the invite flow has friction (registration gate, link-recall failure). - Org-setup completion rate > 80%: measured server-side as new-user authentication sessions that reach an active membership within 30 minutes, divided by total zero-membership authentication sessions. Tied to §2 story 6 — the no-org bootstrap state.
- Multi-org switch latency < 2s P95: measured client-side from org-switcher tap to the first API response bearing the new org’s data. Tied to §2 story 4 — fractional consultants running two companies on one account.