shipped foundation

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).
  • 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-XXXX using an unambiguous alphabet (no 0/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.
  • 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.
  • 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.
  • 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_required with 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.

Role semantics (the contract)

RoleSeesCan do
ownereverythingeverything incl. org settings, leave org when another owner/admin remains, delete org
admineverythingmanage members/teams/work, leave org when another owner/admin remains, delete org
managerorg-wide work datamanage work in their scope; invite members
memberown + team workCRUD own work, submit pulses, leave org
viewerread-only org datanothing 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 (timezone default Asia/Ho_Chi_Minh, brief_time default 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; slug unique; brief schedule fields consumed by F12/F21.
  • OrganizationMember — the actor identity for all business data (never FK to User from domain models). role: owner/admin/manager/member/viewer. The persisted status enum still includes invited/active/suspended for compatibility, but shipped invite flows use InvitationLink as the pending artifact and create OrganizationMember rows only when a person is already active or later suspended.
  • InvitationLink — token (UUID7), short_code (LUMI-XXXX), optional email lock, role, optional team, expires_at, max_uses/use_count, is_active.
  • Standard-team bootstrap is owned by the organization creation service but persists normal F03 Team rows. Each standard team has a stable nullable bootstrap_key (sales|marketing|ops|product|services|hr|admin|accounting) unique within the organization. User-created teams have bootstrap_key=NULL; the key is immutable and names remain editable. Organization create accepts an optional language field populated automatically from the active Lumi UI locale; it is not another form question. vi initializes Vietnamese names and every other supported value initializes English. Older clients that omit it fall back to the creator’s saved, user-editable User.language, matching F01/F37 rather than the browser/OS Accept-Language header. 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 choose OrganizationMember.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

MethodPathMin roleNotes
POST/api/organizations/any userOptional 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/viewerOrg for current X-Organization-ID
PATCH/api/organizations/current/update/adminName, timezone, brief_time, working_days
POST/api/organizations/current/delete/adminRequires exact slug confirmation
POST/api/organizations/accept-invitation/any userConsume {token} or {short_code} → membership
GET/api/members/viewerList members (status=active only)
POST/api/members/leave/viewerLeave unless the actor owns projects; transfer is required first
POST/api/members/invite/owner/adminDirect 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>/adminChange role/team
POST/api/members/<id>/suspend/adminImmediate 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/adminImmediate removal; same explicit handoff confirmation/audit; owner-role target still blocked
GET/api/members/invitation-links/owner/adminList invitation links
POST/api/members/invitation-links/create/owner/adminCreate 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

#CaseTypeWhere
1Cross-tenant read/write blocked on every resourcebackendtests/test_lumi.py::TestTenantIsolation, tests/test_solid.py
2Invitation link + short code: happy path, expiry, max_uses, email-locked, invalid codebackendtests/test_multitenancy.py::TestInvitationLinks
3Suspend/remove revokes access immediatelybackendtests/test_multitenancy.py::TestRemoveMember
4Multi-org user sees correct data per headerbackendtests/test_multitenancy.py::TestMultiOrgMembership
5Leave org + delete org lifecycle, including governance guardrailsbackendtests/test_multitenancy.py::TestOrganizationLifecycle
6Viewer mutation attempts all 403backendtests/test_lumi.py::TestViewerCannotMutate, tests/test_api.py::TestViewerPermissions
7Org setup, create/join, leave, delete, switche2e/mobileweb org lifecycle E2E + iOS/Android gate/workspace tests
8Well-known LUMI-TEST demo invitation: long-lived, idempotent, join via lowercase inputbackendtests/organizations/test_demo_invite.py::TestEnsureDemoInvitation
9Join with code journey: register → wrong code error → LUMI-TEST → main shelle2e/mobile.maestro/critical/51_join_with_code.yaml
10New organization creates exactly eight keyed teams, eight linked private channels, and creator membershipsbackendbackend/tests/organizations/test_standard_workspace_bootstrap.py
11Mid-bootstrap failure leaves no organization, team, channel, or membership rowsbackendbackend/tests/organizations/test_standard_workspace_bootstrap.py
12Existing organizations are unchanged and retrying the service does not duplicate keyed teams/channelsbackendbackend/tests/organizations/test_standard_workspace_bootstrap.py
13Explicit en/vi Lumi locale field, saved-language fallback for older clients, stable keys, and unchanged primary teambackend + web/sharedbackend/tests/organizations/test_standard_workspace_bootstrap.py + P60 client tests
14Suspend/remove confirms and audits newly granted scope, transfers every owned project, and revokes access atomicallybackend + PostgreSQLbackend/tests/organizations/test_project_owner_deactivation.py
15Project owner cannot self-leave until explicit transfers completebackendbackend/tests/organizations/test_project_owner_deactivation.py
16Shared organization management gives invitation codes a distinct Owner/Admin-only section before the member listshared Android/iOSOrgInvitationVisibilityTest; 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 through UserManager.normalize_email before 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 member response 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

#TriggerResponse
ES1POST /api/organizations/ with missing or blank name400 — serializer validation error
ES2PATCH /api/organizations/current/update/ by non-owner/admin403 IsOrgOwnerOrAdmin
ES3POST /api/organizations/current/delete/ with wrong confirm_slug400 “Confirmation slug does not match the current organization.”
ES4POST /api/members/leave/ as sole active member400 “Delete the organization instead of leaving your last active membership.”
ES5POST /api/members/leave/ as last active owner/admin400 “Promote another owner or admin before leaving this organization.”
ES6POST /api/members/invite/ for a user already in the org (any status or email letter casing)400 “Already a member”
ES7POST /api/organizations/accept-invitation/ with expired link400 “Invitation link has expired”
ES8POST /api/organizations/accept-invitation/ with fully-used link400 “Invitation link has been fully used”
ES9POST /api/organizations/accept-invitation/ when already a member400 “Already a member of this organization”
ES10POST /api/organizations/accept-invitation/ with email-locked link, wrong user email403 “This invitation is for a different email”
ES11POST /api/organizations/accept-invitation/ with unknown token or short_code404 “Invalid invitation link” / “Invalid invitation code”
ES12DELETE /api/members/<id>/ targeting the org owner400 “Cannot remove the owner”
ES13DELETE /api/members/<id>/ targeting self400 “Cannot remove yourself”
ES14DELETE /api/members/<id>/ with non-existent member ID404
ES15PATCH /api/members/<id>/ or POST /api/members/<id>/suspend/ with non-existent ID404 — FIXED 2026-06-13
ES16Any authenticated request from a suspended member403 — IsOrgMember filters status="active"
ES17X-Organization-ID header absent403 — IsOrgMember returns False immediately
ES18Project ownership handoff fails during suspend/removeTransaction rolls back; member remains active and every project keeps its prior owner
ES19Member owns projects and calls leave409 project_owner_transfer_required with count; no membership, owner, roster, or ACL change
ES20Suspend/remove would grant actor private scope without confirm_project_handoff=true409 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)

#ClaimStatusEvidence
1Org CRUD: create (creator→owner), current, update, delete with slug confirmation✅ matchtests/test_multitenancy.py::TestOrganizationLifecycle; ES3 delete guard
2Invitation link + short_code dual redemption (LUMI-XXXX; same expiry/max_uses/email-lock/team)✅ matchInvitationLink.short_code; accept_invitation accepts both; TestInvitationLinks
3Five roles enforced (viewer → 403 on all mutations)✅ matchIsOrgOwnerOrAdmin, IsOrgManager, IsOrgMember; TestViewerCannotMutate
4Suspend → immediate 403 (IsOrgMember filters status="active")✅ matchorganizations/permissions.py:9-24; TestRemoveMember
5Leave safeguards: last active owner/admin → 400; sole member → 400✅ match_has_other_active_owner_or_admin(); ES4, ES5
6Cross-tenant: 404 not 403 on all domain resources✅ matchTenantQuerySetMixin; TestTenantIsolation
7Pending 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.✅ matchinvite_member and accept_invitation both create status="active"; list_members returns active only; test_pulses_adherence still defensively excludes manual invited fixtures from totals
8Concurrent 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
9Short-code exhaustion: 20 retries, RuntimeError → 500✅ acceptedSME scale makes exhaustion negligible; generate_short_code() RuntimeError path documented
10Role 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

  • manager vs admin boundary 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-chosen current_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-26 recorded the contract-defined 400 Already a member of this organization response from the Android typed invitation-code flow in dev build 1.0.2-dev+61.
  • OrgManagementScreenModel.joinWithCode now classifies only that exact status-and-detail pair as expected validation. The existing user-facing Already a member of this organization feedback is unchanged; malformed or unrelated invitation failures remain reportable.

Evidence:

  • OrgManagementScreenModelTest.joinWithCodeAlreadyMemberRemainsValidationFeedbackWithoutNonFatalReporting proves 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_id scoping, 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.md cases O01–O05.

2026-07-29 — A direct invite no longer squats the invitee’s email

Type: Auth/onboarding correctness fix

Summary:

  • invite_member resolved the invitee with User.objects.get_or_create, which skips UserManager.create_user and wrote the row with password="". 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_provisioned with an explicit unusable password. Registration claims that row in place (F01), preserving the status="active" membership, so the invitee finishes sign-up already inside the organization.
  • Pending access still lives on InvitationLink; this change does not alter OrganizationMember.status semantics, and status="invited" remains unused by shipped flows.
  • A claim is capped at member: inviting someone as owner/admin/manager (or with a custom_role) still works, but the role is lowered on claim and written to the audit log as member.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.0009 backfills 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_code contract 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 / issue 7632319665 recorded the backend’s expected short_code max-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.GAP002 obligations.

2026-07-16 — Mobile: make the zero-org gate surface deterministic (ONB-04)

Type: Mobile verification hardening

Summary:

  • Added a stable root selector to OrgSetupScreen while preserving the existing create-name, create, join-code, and join selectors.
  • Extended .maestro/critical/02_auth_register.yaml to 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_API35 execution of .maestro/critical/02_auth_register.yaml passed 1/1 in 51 seconds. The exact tested source commit was eb04b6cbeb9358e0898f4ffd486a37a867451650; the built and installed APK SHA-256 was f2a074ce0ccdb4600b88c8e9686c6aed9086d200fd92c11bf0ce85c6afdd23cb.
  • 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.md is 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 partial because 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-TEST invitation into the demo organization. Called by seed_demo_lumi and by data migration 0021_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_button testTag (OrgSetupScreen) and org_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 in tests/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.

Type: Security Fix

Summary:

  • GET /api/members/invitation-links/ (list_invitation_links, views.py:372) was gated by IsOrgMember only, so any active member — including plain members and viewers — could list all of the org’s active invitation links, which carry join-secret token and short_code fields.
  • Raised the gate to IsOrgOwnerOrAdmin, matching create_invitation_link (same view file, line 383). Members and viewers now receive 403.
  • §5 permission column updated from any member to owner/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 to isAdminOrOwner (Sidebar.tsx:97); no UI change needed. Regular members have no path to this surface via normal navigation.

Reason:

  • Invitation links carry token (UUID) and short_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 RBAC roles.manage.

Impact:

  • Backend: list_invitation_links now returns 403 for role member, manager, and viewer.
  • 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_denied in tests/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 role manager. Actual gate is IsOrgOwnerOrAdmin (organizations/views.py:218); managers who call this endpoint receive 403. Updated §5 to owner/admin.
  • POST /api/members/<id>/remove/: §5 said min role owner. Actual gate is IsOrgOwnerOrAdmin (organizations/views.py:343); admins can remove members (removing the owner-role target is still blocked by a separate 400 guard). Updated §5 to owner/admin.
  • GET/POST /api/members/invitation-links/, …/create/: §5 collapsed list and create under a single admin row. Actual gates differ: list (list_invitation_links) is IsOrgMember (views.py:373) — any active member; create (create_invitation_link) is IsOrgOwnerOrAdmin (views.py:383). Split into two rows reflecting the real split.

FLAG — three permission drifts resolved by aligning to shipped code. Decision required:

  1. 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 with IsOrgOwnerOrAdmin, so managers cannot invite. If this was unintentional, the fix is to relax the code permission to IsOrgManager; if it was a deliberate tightening, §2’s role-semantics table also needs updating.
  2. Invitation-link listing visible to all members: The original spec implied admin-only management. The code ships with IsOrgMember on 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 to IsOrgOwnerOrAdmin.
  3. 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.py permission decorator reality, verified with grep -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: InvitationLink carries pending access, while normal org-member rows are created as active and may later become suspended or 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 (slug race and inline permission checks).

Reason:

  • The backend creates active memberships in both invite_member and accept_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-XXXX short 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_member and suspend_member now wrap OrganizationMember.objects.get() in try/except DoesNotExist returning 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:

  • InvitationLink generates short_code (LUMI-XXXX) alongside UUID token.
  • 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 dictates LUMI-XXXX over 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.