Deep-link interstitial for other-org items
## Epic ask (verbatim)
> "Allow user to be a member of several orgs, including Operator. At any given moment user acts on behalf of the currently selected org, no permissions mix or leak. User can switch between orgs by clicking on his icon and selecting a switch icon next to the org. Same model as in Stripe and other multitenant systems"
## Requirement (PRD, verbatim)
**R9 — Deep links into another org.** Suppose the user opens a URL or notification for an item belonging to org B while acting as A. **Member of B:** they see an interstitial: "This belongs to B — Switch to B". Nothing of B (title, content, counts) shows until they click. There is never a silent switch. **Not a member of B:** it is indistinguishable from a not-found.
## Acceptance (PRD AC13, verbatim)
"A member opening a B link while acting as A sees only the 'belongs to B — Switch to B' interstitial with no B content, and the button switches and opens the item. A non-member gets the standard not-found." — "e2e + API test asserting the interstitial response leaks no item fields."
## Approach (architecture §3.9, §5.7)
```
GET /api/auth/locate/{object_type}/{object_id}
200 { "org_id", "org_name" } # object exists in an org where the caller's identity has a LIVE membership and that org is not the active one
404 { "detail": "not found" } # everything else — byte-identical to a missing id
```
Generic over `authz.OBJECT_SPEC_BY_NAME` (every console object type with a detail route). Resolve each type's org through its OWN manifest derivation (`Stored` → `org_id`; `Derived` → walk the declared parent path in `tenancy/manifest.py`, one helper) under `identity_scope`, reading only the org — never a hand-written per-type query and never any other field. Census test: every registry type resolves, and the response carries no field but `org_id`/`org_name`.
Web: every detail page's existing not-found path (`EmptyState`, e.g. `TaskDetailPage.tsx:1749`) calls `locate` first; 200 → interstitial "This belongs to <B> — Switch to <B>" (no title/content/counts of B, readable org name) whose button calls `POST /api/auth/switch` (handling `reauth_required`: `otp` → TOTP prompt, IdP → `start_url`) then reloads the same URL; 404 → today's not-found unchanged. Never a silent switch.
Security surface: authz (existence oracle) — under the security-surface gate.
Out of scope: No change to the switch endpoint itself. No org in the URL path. No account-menu changes. Epic out-of-scope (PRD §5, verbatim): "No per-tab simultaneous orgs. Org is per session. Do NOT put the org in the URL path as an addressing scheme (a deep-link interstitial is allowed)." "No operator 'view as / impersonate' of an org the operator is not a member of." "Do not weaken the read/write seams."
Done means:
- API tests: member of B acting as A → 200 with exactly `{org_id, org_name}` (assert key set); non-member, disabled membership, nonexistent id, and object in the active org → 404 with a body byte-identical to a missing id.
- Census test over every `OBJECT_SPEC_BY_NAME` type passes and fails when a type's resolution is removed.
- AC13 e2e: acting as A, open a B task URL → interstitial shows B's name and no B title/content/counts (assert absence of the task title in the DOM); click Switch → lands on the item in B. Non-member → the standard not-found.
- AC17 — `./dc check full` green, including the tenancy bypass/scope censuses and the authz policy suite.
Required checks: gate
Outcome
Built GET /api/auth/locate + the "This belongs to B — Switch to B" interstitial on 6 detail pages; AC13 e2e + census pass; full gate green; security gate approved; merged #4514. Deferred: Google-MFA return-to, per-identity rate bucket.
Artifacts
Activity
- Draft
- Ready
- Runner started
- Awaiting response
- Awaiting response
- Runner resumed
- Runner ended (done)