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)

The two bugs behind the symptom

  1. Wrong individual org chosen as default. The no-saved-choice fallback is organizations.first(where: { $0.isIndividual }) (App/AppModel.swift:218). Among several INDIVIDUAL orgs it picks the first in the list, not the user’s own. The client cannot currently tell them apart because the MyOrganizations query requests only id name type.
  2. The sticky choice is wiped on sign-out. clearUserState() runs cache.clear() (App/AppModel.swift:181) — correct and mandated by ADR-0028 (cross-user isolation). So after any sign-out the persisted activeOrgId is 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

  1. DB migration (profiles is 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 NULL so deleting the org cleanly clears the preference. Requires Paul’s confirmation before running (per Plings-API/CLAUDE.local.md).

  2. QuerymyProfile { defaultOrganizationId }. New Profile type, deliberately extensible for future per-user preferences. Reads the caller’s profiles row (or returns null default if no row / no preference).
  3. MutationsetDefaultOrganization(organizationId: ID!): Profile!.
    • Authz gate = membership check in the resolver, consistent with ADR-0024/0025 and the org-authz-membership-not-jwt-claim rule: the caller must have an organization_members row for organizationId, else ACCESS_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 profiles row (INSERT ... ON CONFLICT (id) DO UPDATE) keyed on auth.uid(), since profiles are not guaranteed to exist for every user.
  4. Update Plings-API/requirements.txt only if new imports are introduced (none expected). Docs: update docs/database/SCHEMA-VERIFICATION.md and the API docs in the same PR.

Plings-iOS

  1. Model + query: add role: String? to the Organization model and to the MyOrganizations operation selection (PlingsKit/Sources/PlingsKit/API/*).
  2. Fetch default on login: add a MyProfileOperation (or fold into the existing bootstrap) returning defaultOrganizationId; cache it locally so the UI reads it instantly (local-first — never wait on the network).
  3. Cold-start resolution in AppModel: replace the first(where: isIndividual) fallback with the resolution order above (role == "owner" && isIndividual = personal org).
  4. Set default (optimistic + write-behind): a new QueueJob kind .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).
  5. 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

Testing

Cross-repo merge order

  1. Plings-API first (migration + myProfile + setDefaultOrganization live).
  2. Plings-iOS second (degrades gracefully if the field is missing, so no hard coupling).

Companion deliverables (same PRs)

Non-goals / future