# Controls

> Compliance frameworks and controls with evidence files and notes, recurring evidence tasks with assignees and an overdue state, versioned policies that members acknowledge, and a status rollup per framework.

Source: https://bettersupabase.com/docs/blocks/controls

`controls` keeps what an audit asks for in one place. You list the controls of
a framework such as SOC 2 or ISO 27001, attach evidence to each control, plan
the evidence that has to be collected again on a schedule, and publish
policies that every member acknowledges. Each control's status follows from
its evidence and tasks, and `posture` rolls it up per framework.

```bash
better-supabase add controls
```

```ts
import { createControls } from "better-supabase/blocks/controls";
```

The module requires `tenant`, `access` and `versions`. With
[attachments](/docs/blocks/attachments) a piece of evidence can be a file,
with [notifications](/docs/blocks/notifications) a missed task notifies its
assignee, with `outbox` it records the events, and with `audit` every change
is audited.

## Tables [#tables]

| Table                     | Holds                                                                                              |
| ------------------------- | -------------------------------------------------------------------------------------------------- |
| `control_frameworks`      | A framework per organization by `key`, with a name and version                                     |
| `controls`                | A control in a framework by `code`, with a title, an owner and whether it applies                  |
| `control_evidence`        | Evidence for a control: an attachment, a note or both, who collected it and until when it is valid |
| `evidence_tasks`          | Recurring evidence: a title, an interval in days, the next due date and an assignee                |
| `policies`                | A policy by `key` with an owner and its current published version                                  |
| `policy_acknowledgements` | Which member acknowledged which version of a policy, and when                                      |

Members with `controls.read` read every table.

## Frameworks and controls [#frameworks-and-controls]

```ts
const controls = createControls({ transport });

const soc2 = await controls
  .saveFramework(organizationId, {
    key: "soc2",
    name: "SOC 2",
    version: "2017",
  })
  .orThrow();

const access = await controls
  .saveControl({
    frameworkId: soc2.id,
    code: "CC6.1",
    title: "Logical access",
    ownerId,
  })
  .orThrow();
```

Saving a framework with a key that exists, or a control with a code that
exists in the framework, updates it. A control with `applicable: false` is
out of scope. Frameworks, controls, evidence tasks and policies need
`controls.manage`.

A control's `status` is computed, never stored:

| Status           | When                                                               |
| ---------------- | ------------------------------------------------------------------ |
| `not_applicable` | the control doesn't apply                                          |
| `failing`        | an active evidence task is overdue, or all its evidence has lapsed |
| `passing`        | it has evidence that is still valid                                |
| `not_started`    | it has no evidence yet                                             |

## Evidence and tasks [#evidence-and-tasks]

```ts
await controls
  .addEvidence(access.id, {
    attachmentId,
    note: "Quarterly access review",
    validUntil: Temporal.PlainDate.from("2026-12-31"),
  })
  .orThrow();

const task = await controls
  .saveEvidenceTask(access.id, {
    title: "Review access",
    intervalDays: 90,
    assigneeId,
  })
  .orThrow();
```

Evidence is an attachment in the organization, a note, or both; adding it
needs `controls.collect`. A task is due on `dueOn` (default today) and is
`overdue` once that day has passed. Evidence added with `evidenceTaskId`
answers the task: its due date moves `intervalDays` past today, and the
evidence is valid until then unless `validUntil` says otherwise. A task's
assignee may collect for it without `controls.collect`.

Run `notifyOverdue` daily with the service role, for example from a
[job](/docs/blocks/jobs). It records `evidence_task.missed` once for each
lapse and notifies the assignee, or the control's owner when the task has
none.

## Posture [#posture]

```ts
const [framework] = await controls.posture(organizationId).orThrow();
// { total, passing, failing, notStarted, notApplicable, overdueTasks }
```

`posture` returns one rollup per framework, or for one framework when you
pass its id.

## Policies [#policies]

```ts
const policy = await controls
  .savePolicy(organizationId, {
    key: "acceptable-use",
    title: "Acceptable use",
  })
  .orThrow();

await controls
  .publishPolicy(policy.id, { body: markdown }, "2026 edition")
  .orThrow();
await controls.acknowledgePolicy(policy.id).orThrow();
```

`publishPolicy` stores the content as a frozen [version](/docs/blocks/versions)
and makes it current. Members acknowledge the current version with
`controls.acknowledge`; publishing again asks everyone to acknowledge the new
version. `policies(organizationId, { pendingOnly: true })` lists the policies
waiting for the caller, and `acknowledgements(policyId)` lists every member
with the time they acknowledged the current version. An archived policy can't
be published or acknowledged.

| Method                                 | Returns                                      |
| -------------------------------------- | -------------------------------------------- |
| `saveFramework(organizationId, input)` | the framework                                |
| `frameworks(organizationId)`           | the frameworks                               |
| `saveControl(input)`                   | the control                                  |
| `getControl(controlId)`                | the control with its evidence and tasks      |
| `controls(organizationId, options)`    | the controls by framework and code           |
| `addEvidence(controlId, input)`        | the evidence                                 |
| `removeEvidence(evidenceId)`           | the removed evidence                         |
| `saveEvidenceTask(controlId, input)`   | the task                                     |
| `evidenceTasks(organizationId, opts)`  | the tasks, the earliest due first            |
| `posture(organizationId, frameworkId)` | one rollup per framework                     |
| `notifyOverdue()`                      | how many lapsed tasks it reported            |
| `savePolicy(organizationId, input)`    | the policy                                   |
| `publishPolicy(policyId, content)`     | the policy with its new version              |
| `archivePolicy(policyId)`              | the archived policy                          |
| `acknowledgePolicy(policyId)`          | the policy with the caller's acknowledgement |
| `policies(organizationId, options)`    | the policies with acknowledgement counts     |
| `acknowledgements(policyId)`           | every member and when they acknowledged      |

## Errors [#errors]

| Hint                          | Cause                                                                       |
| ----------------------------- | --------------------------------------------------------------------------- |
| `CONTROLS_FORBIDDEN`          | the caller lacks `controls.read`, `.manage`, `.collect` or `.acknowledge`   |
| `CONTROLS_INVALID`            | a field is invalid, or the attachment or a member isn't in the organization |
| `CONTROL_FRAMEWORK_NOT_FOUND` | no framework with this id                                                   |
| `CONTROL_NOT_FOUND`           | no control with this id                                                     |
| `CONTROL_EVIDENCE_NOT_FOUND`  | no evidence with this id                                                    |
| `EVIDENCE_TASK_NOT_FOUND`     | the evidence task isn't a task of this control                              |
| `POLICY_NOT_FOUND`            | no policy with this id                                                      |
| `POLICY_INVALID`              | the content isn't an object, or the owner isn't a member                    |
| `POLICY_ARCHIVED`             | the policy is archived and can't be published                               |
| `POLICY_NOT_PUBLISHED`        | the policy has no current version to acknowledge, or it is archived         |

## Events [#events]

`control.saved`, `control.evidence_added`, `evidence_task.missed`,
`policy.published` and `policy.acknowledged`. The
[events page](/docs/extending/events) lists their payloads.