# Deactivation

> A schema mixin that marks a subject as deactivated (can't log in) while keeping its data fully visible — the deliberate opposite of soft-delete.



---

<!-- source: en/plugins/deactivation.md -->
## Deactivation

_A schema mixin that marks a subject as deactivated (can't log in) while keeping its data fully visible — the deliberate opposite of soft-delete._

`@voltro/plugin-deactivation` ships a single surface: the `deactivation()` schema mixin. A deactivated subject — typically a user who can no longer log in — has its data stay fully **visible** (assignments, history, audit trail). That's the deliberate opposite of `softDelete()`, which **hides** the row from default reads and anonymises PII for GDPR.

**Status:** ✓ shipped.

## What it adds

The mixin contributes two columns:

| Column | Type | Meaning |
|---|---|---|
| `deactivatedAt` | nullable timestamp | set ⇒ the subject is deactivated; `null` ⇒ active |
| `deactivatedBy` | nullable reference → Actor | who performed the deactivation |

It's a **pure schema mixin** (columns only), exactly like `audit()` — no read scoping, no delete interception. You set `deactivatedAt` with a normal `ctx.store.update(...)`.

`deactivation()` transitively requires `audit()` (a deactivation is an audit-worthy event, and the timestamps sit together on the row), so `audit()` auto-stamps `updatedAt` / `updatedBy` alongside — the WHO/WHEN of the change is captured for free. Its stable id is `voltro/deactivation`.

## Composing it

```ts
import { id, table, text } from '@voltro/database'
import { deactivation } from '@voltro/plugin-deactivation'

export const users = table('users', {
  id:    id(),
  email: text().unique(),
  name:  text(),
}).with(deactivation())
```

The dep resolver dedupes, so listing `audit()` explicitly alongside is harmless:

```ts
import { audit } from '@voltro/plugin-audit/mixin'
import { deactivation } from '@voltro/plugin-deactivation'

table('users', { /* … */ }).with(audit(), deactivation())
```

A project that doesn't want the barrel can import from the `./mixin` subpath:

```ts
import { deactivation } from '@voltro/plugin-deactivation/mixin'
```

## Deactivating + reactivating

Both are plain updates — `audit()` records the actor and time:

```ts
// deactivate — update(table, primaryKey, patch)
await ctx.store.update('users', userId, {
  deactivatedAt: new Date(),
  deactivatedBy: ctx.request.subject.id,
})

// reactivate
await ctx.store.update('users', userId, {
  deactivatedAt: null,
  deactivatedBy: null,
})
```

Because the row is never hidden, your own queries decide what "active" means — the user stays visible everywhere in the app while their login is refused.

## Enforcing "a deactivated user can't log in"

The mixin's promise is **self-enforcing** through `@voltro/plugin-auth`'s post-authentication subject-guard seam — you do **not** hand-roll a `deactivatedAt === null` check in an auth resolver. Import `deactivationGuard()` from the `/guard` subpath and wire it onto the auth plugin in one line:

```ts
import { authRoutesPlugin } from '@voltro/plugin-auth/plugin'
import { deactivationGuard } from '@voltro/plugin-deactivation/guard'

authRoutesPlugin({
  store,
  secret: process.env.VOLTRO_SESSION_SECRET!,
  defaultTenantId: 'acme',
  subjectGuards: [deactivationGuard()],
})
```

The auth pipeline runs each subject guard **after** the credential check (password / MFA / magic-link / passkey) but **before** it issues a session. When the resolved user's `deactivatedAt` is set, `deactivationGuard()` vetoes: the login returns a **403** whose body `error` is `account_deactivated`, and **no session cookie is issued** — on every sign-in path. Reactivate the user (`deactivatedAt = null`) and the next login proceeds normally. See the [auth plugin's subject-guard seam](/docs/plugins/auth#post-authentication-subject-guards).

`AccountDeactivated` — a `Schema.TaggedError` on the same `/guard` subpath — is the typed form of the rejection for code that prefers matching on `_tag === 'AccountDeactivated'` over the wire body. The `/guard` subpath is **server-only** and is not imported by the mixin, so a `*.entity.ts` importing `deactivation()` never drags the auth surface into the browser bundle.

## Deactivation vs soft-delete

| | `deactivation()` | `softDelete()` |
|---|---|---|
| Row visible in default reads | yes | no (hidden) |
| PII | untouched | anonymised |
| Typical use | user can't log in, data stays | GDPR erasure / "delete" |
| Read scoping | none | filters hidden rows out |

The two are **orthogonal and compose**. A user can be deactivated (visible) and later soft-deleted (hidden) — apply both mixins.

## When to use which

- **Deactivation** — revoke access but keep the subject's contributions intact and attributable (the common "disable an employee account" case).
- **Soft-delete** — the user must disappear from the app and their PII must be scrubbed.
- **Both** — start with deactivation, escalate to soft-delete if an erasure request arrives.
