# Session Scope
`SessionScope` keeps every cached controller and repository pointed at the identity that is authenticated right now, so a login after a logout, or a team switch by the same user, never leaves a previous tenant's rows on screen.
- [Introduction](#introduction)
- [Why It Exists](#why-it-exists)
- [Attaching](#attaching)
- [The Identity Resolver](#the-identity-resolver)
- [SessionScoped](#sessionscoped)
- [Registering a Non-Controller Holder](#registering-a-non-controller-holder)
- [Failure Modes](#failure-modes)
## Introduction
Magic resolves controllers as Type-keyed singletons and runs `onInit` exactly once per instance lifetime. On a single-tenant app that is the whole point: a controller's first fetch happens once and stays put. On a team-scoped product it is a defect waiting to happen: `MonitorController` boots for team A, caches team A's rows, and a switch to team B (or a logout followed by a different user's login) leaves those rows on screen with nothing that would ever clear them.
## Why It Exists
`SessionScope` is the one seam every scoped controller and repository routes through, rather than each one hand-rolling its own `Auth.stateNotifier` listener. It calls `resetForSession()` on every registered holder whenever the resolved identity changes, and does so **in place**: it never deletes and recreates a controller. A mounted `MagicStatefulViewState` holds its own controller instance directly, so a delete-then-recreate would freeze that view rather than refresh it.
## Attaching
`SessionScope.attach()` is explicit: no core provider calls it, because it must be the LAST `Auth.stateNotifier` listener so realtime, polling, and locale already point at the new session before any scoped holder's data is refetched.
```dart
@override
Future boot() async {
SessionScope.identity = () => Auth.check() ? '${Auth.id()}:$teamId' : null;
SessionScope.attach();
}
```
`attach()` is idempotent while already attached, and records the boot identity before subscribing, so the FIRST real change (not the app's own startup) is the first one that resets anything. `detach()` unsubscribes and forgets the recorded identity; registered holders and the `identity` resolver survive it, since they belong to their owners, not to the subscription.
## The Identity Resolver
`SessionScope.identity` defaults to the authenticated user id. A team-scoped app overrides it to fold the active team in, so a team switch by the same user counts as a change too:
```dart
SessionScope.identity = () => Auth.check() ? '${Auth.id()}:$teamId' : null;
```
A `null` identity means unauthenticated and nothing else. A change TO `null` (a logout) is recorded without triggering a reset: from the login screen a refetch can only 401. The next login resets before any authenticated view renders.
## SessionScoped
Implement `SessionScoped` on a controller or a holder that caches per-tenant rows:
```dart
class MonitorController extends MagicController
with MagicStateMixin>
implements SessionScoped {
@override
Future resetForSession() async {
setEmpty();
await loadMonitors();
}
}
```
`resetForSession` must **clear before it refetches**. Ordinary reload paths are deliberately non-destructive (a transport failure keeps the last-known-good rows on screen), and that is the wrong behaviour across an identity change: a failed refetch here must leave the screen empty, never populated with the previous tenant's data.
A controller implementing `SessionScoped` needs no explicit registration: `SessionScope.sync()` finds it by walking `Magic.controllers.whereType()` on every identity change.
## Registering a Non-Controller Holder
A `Repository` is not a controller, so it registers itself explicitly. This already happens in `Repository`'s own constructor; the pattern below is what a hand-rolled `SessionScoped` holder (something other than a `Repository`) does the same way:
```dart
class SearchIndex implements SessionScoped {
SearchIndex() {
SessionScope.register(this);
}
@override
Future resetForSession() async {
_entries.clear();
}
void dispose() {
SessionScope.unregister(this);
}
}
```
`SessionScope.unregister` must run when the holder is disposed; an unregistered-but-live holder would otherwise be reset forever, even after nothing references it.
## Failure Modes
| Situation | What happens |
|---|---|
| Two logins under the same identity in a row | The second `sync()` is a no-op: the identity did not change |
| A `resetForSession()` throws | Logged via `Log.error`, isolated per holder: one holder's throw never stops another's reset |
| A reset resolves another controller mid-iteration | Safe: the scoped set is snapshotted before iterating |
| A controller is also a registered holder (both a `SessionScoped` controller and separately `register`ed) | Resets exactly once: the snapshot is a `Set` |
| `attach()` called twice | The second call is a no-op; only one subscription is ever active |
| A logout | Recorded, never triggers a reset (would only 401) |