Default organization — design spec
Created: Sat 04 Jul 2026 07:57:47 CEST Updated: Sat 04 Jul 2026 07:57:47 CEST - Initial design Document Version: 1.0 - Initial design Security Classification: Internal Technical Documentation Target Audience: iOS developer, Backend developer Author: Paul Wisén (design), Claude (drafting)
Problem
On login/relaunch the iOS app lands the user in the wrong organization. A user who is a
member of several INDIVIDUAL-type orgs (their own “me” org plus other people’s
personal orgs they were added to) is dropped into whichever individual org happens to sort
first — not their own. The reporter (Paul) always lands in Ami Verkkoperä instead of his
own Paul Wisén org, even though he almost always works in his own.
This is a system-level question — “how does the system decide which organization is active when a session starts?” — not a one-user fix. The general answer must also serve a user who only uses Plings at work and wants their business org to be the landing org.
What already exists (discovery)
- Sticky active org:
activeOrgIdis persisted in the cache (CacheStore.setActiveOrgId) and restored on launch —AppModel.refreshOrganizations(App/AppModel.swift). - Org switcher: the “Skapar som” picker calls
AppModel.setActiveOrg(_:)— transient working-org switch. - Ownership signal already on the API:
Organization.role(owner/member) is already in the GraphQL schema (Plings-API/app/graphql.py) and is populated by themyOrganizationsresolver (Plings-API/app/organization_resolvers.py, joinsorganization_members.role). The iOS client simply does not request the field today.
The two bugs behind the symptom
- Wrong individual org chosen as default. The no-saved-choice fallback is
organizations.first(where: { $0.isIndividual })(App/AppModel.swift:218). Among severalINDIVIDUALorgs it picks the first in the list, not the user’s own. The client cannot currently tell them apart because theMyOrganizationsquery requests onlyid name type. - The sticky choice is wiped on sign-out.
clearUserState()runscache.clear()(App/AppModel.swift:181) — correct and mandated by ADR-0028 (cross-user isolation). So after any sign-out the persistedactiveOrgIdis gone and bug 1 takes over.
Decision
Introduce an explicit, user-set, server-stored default organization as the thing that decides the active org on cold-start / login. This was chosen over “just remember the last-used org” because an explicit default also serves the work-only user (pin the business org once) and gives predictable restart behaviour.
Two clearly separated concepts:
| Concept | Scope | Persistence | Set by |
|---|---|---|---|
defaultOrganizationId (NEW) |
Where I land on restart / login | Server-stored user preference, cached locally | Rarely — an explicit action in Account → Standardorganisation |
activeOrgId (exists) |
Which org I’m working in right now | In-memory for the session (cache as warm-relaunch aid) | Often — the “Skapar som” picker, transient |
Cold-start / login resolution order:
activeOrgId = cachedDefault
?? serverDefault (myProfile.defaultOrganizationId)
?? personalOrg // type == INDIVIDUAL && role == "owner"
?? organizations.first
This fixes bug 1 (personal-org fallback uses role == owner, no longer “first
individual”) and bug 2 (server-stored default survives the ADR-0028 cache wipe and
re-seeds on next login; a new device inherits it too).
Explicitly NOT doing “remember last-used across restart.” During a running session the picker still remembers the working org (unchanged). On the next cold-start the active org is re-seeded from the default, not from the last-used org — so hopping into someone else’s org for one task and then relaunching still lands you back in your own. (This is the “both” option the user declined.)
UI placement — Account, not the picker
The “set as default” affordance lives in Account → Standardorganisation, not in the “Skapar som” picker. Rationale (user’s own point): putting it in the picker conflates the frequent everyday act of switching working org with the rare act of pinning a landing org, making it feel like a default must be chosen every time you work. Keeping them separate keeps the picker the fast daily tool and the default a set-once setting. (Aligns with the “design-for-fewest-user-actions” north star: the common path stays one tap.)
Architecture
Plings-API
- DB migration (
profilesis the per-user preferences home; RLS enabled, currently 1 row — writes must upsert the row):ALTER TABLE public.profiles ADD COLUMN default_organization_id uuid REFERENCES public.organizations(id) ON DELETE SET NULL;ON DELETE SET NULLso deleting the org cleanly clears the preference. Requires Paul’s confirmation before running (perPlings-API/CLAUDE.local.md). - Query —
myProfile { defaultOrganizationId }. NewProfiletype, deliberately extensible for future per-user preferences. Reads the caller’sprofilesrow (or returnsnulldefault if no row / no preference). - Mutation —
setDefaultOrganization(organizationId: ID!): Profile!.- Authz gate = membership check in the resolver, consistent with ADR-0024/0025 and
the
org-authz-membership-not-jwt-claimrule: the caller must have anorganization_membersrow fororganizationId, elseACCESS_DENIED. The API runs under a service role and bypasses RLS, so the resolver is the authoritative gate — RLS is not relied on for this. - Upserts the
profilesrow (INSERT ... ON CONFLICT (id) DO UPDATE) keyed onauth.uid(), since profiles are not guaranteed to exist for every user.
- Authz gate = membership check in the resolver, consistent with ADR-0024/0025 and
the
- Update
Plings-API/requirements.txtonly if new imports are introduced (none expected). Docs: updatedocs/database/SCHEMA-VERIFICATION.mdand the API docs in the same PR.
Plings-iOS
- Model + query: add
role: String?to theOrganizationmodel and to theMyOrganizationsoperation selection (PlingsKit/Sources/PlingsKit/API/*). - Fetch default on login: add a
MyProfileOperation(or fold into the existing bootstrap) returningdefaultOrganizationId; cache it locally so the UI reads it instantly (local-first — never wait on the network). - Cold-start resolution in
AppModel: replace thefirst(where: isIndividual)fallback with the resolution order above (role == "owner" && isIndividual= personal org). - Set default (optimistic + write-behind): a new
QueueJobkind.setDefaultOrganization. Setting the default updates the cached preference immediately (and, if the user wants, the active org), then enqueues the server sync. Server = durability only; a slow/failed sync never changes what the user sees (ADR-0003). - UI: an Account row “Standardorganisation” showing the current default with a picker to change it. Degrades gracefully if the API field is absent (falls back to personal org), so iOS can ship after the API without a hard version coupling.
Data flow (cold-start)
launch/login
→ read cached defaultOrganizationId (instant, offline-safe)
→ in background: MyProfile + MyOrganizations refresh
→ resolve activeOrgId (cachedDefault ?? serverDefault ?? personalOrg ?? first)
→ user may switch working org via picker (transient, this session only)
Data flow (set default)
Account → pick org
→ optimistic: cache defaultOrganizationId (+ optionally set active) [instant]
→ enqueue .setDefaultOrganization job
→ QueueProcessor drains → setDefaultOrganization mutation [durability]
Edge cases
- Offline at login, no cache: fall back to personal org (
role == owner), then first org. - Default org later deleted / user removed from it:
ON DELETE SET NULLclears it server-side; client resolution falls through to personal org. The set-default resolver’s membership gate prevents pinning an org you’re not in. - Multiple
INDIVIDUALorgs you own (unusual): personal-org fallback picks the firstowner+INDIVIDUAL; the explicit default overrides this anyway once set. - Profiles row missing (only 1 row exists today): the mutation upserts; the query treats a missing row as “no default”.
- New device: no local cache → server default is fetched and applied. This is the payoff of server-side storage.
Testing
- API (
poetry run pytest): migration applied;myProfilereturns null then the set value;setDefaultOrganizationupserts; membership gate denies setting a default for an org the caller is not a member of;ON DELETE SET NULLbehaviour. - PlingsKit (
swift test):Organizationdecodesrole; cold-start resolution picks cachedDefault → serverDefault → personalOrg(owner) → first in the right precedence; offline path uses cache;.setDefaultOrganizationjob round-trips. - Device (Paul): land in your own org after login (not Ami); set default in Account; switch working org in picker, relaunch → back to default; sign out/in → default survives; airplane mode login → correct org from cache; set default offline → syncs on reconnect.
Cross-repo merge order
- Plings-API first (migration +
myProfile+setDefaultOrganizationlive). - Plings-iOS second (degrades gracefully if the field is missing, so no hard coupling).
Companion deliverables (same PRs)
- ADR (new, load-bearing): “Active org on cold-start = explicit server-stored default, not
sticky-last-used.” Ships in the implementing PR (code-coupled → same-PR gate). Scope
[iOS,API]. Cites ADR-0003 (local-first), ADR-0024/0025 (authz), ADR-0028 (sign-out wipe). - Use-case + flow doc (use-case catch): “Choosing / defaulting the active organization” is
a named user-facing flow. Draft the flow doc from the code + a use-case skeleton; Paul
confirms the use-case intent. Flow doc co-located in
Plings-iOS/docs/flows/; indexed inPlings-Web/docs/flows/README.md. - ROADMAP: add the item to
Plings-API/ROADMAP.mdandPlings-iOS/ROADMAP.md; move to Done with PR refs on merge.
Non-goals / future
- Syncing the last-used working org across devices (declined — default is the restart seed).
- A web UI for the default org (Plings-Web) — add to Backlog if wanted.
- Per-context defaults (e.g. different default by location/time) — out of scope.