> ## Documentation Index
> Fetch the complete documentation index at: https://checkfu.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Tenancy and governance

> Understand organizations, tenants, Workspaces, principals, PermissionAssignments, and policy enforcement.

An **Organization** is the administrative and commercial root. It owns memberships, API keys, billing identity, and one or more normally implicit **Tenants**. Its public isolation boundaries are CMA-compatible **Workspaces**. A Workspace has a `wrkspc_` identity, name, tags, residency fields, and an irreversible archive lifecycle. Development, test, staging, and production are separate Workspaces—ordinary names or tags, not a Checkfu-specific stage field. Runtime and configuration resources are isolated by Workspace. Two operator-owned registries are platform-global: [Harnesses](/concepts/harnesses-and-models) and [platform supply](/concepts/billing-and-platform-supply).

A **SourceRepository** is an Organization-owned logical Git repository identity: provider, owner, repository, default ref, and display metadata. It deliberately contains no credentials and names no IntegrationConnection. A later synchronization must select its IntegrationConnection explicitly, so repository identity never becomes ambient provider authority. Administrators create and update SourceRepositories; every Organization member may read the directory, and no Workspace can observe another Organization's entries.

Ordinary API keys are hard-bound to one Workspace, matching CMA; request headers cannot select another. Workspace quotas are live execution-admission policy: concurrent Runs, monthly model tokens, and monthly Sandbox seconds are enforced from authoritative reservations and UsageLedger receipts. PermissionAssignments, ActionPolicies, [ActionApprovals](/concepts/action-approvals), and Workspace retention policy are implemented, including the governance decision endpoint.

## Principals

A **Principal** is the end user or service identity on whose behalf an Agent acts. The customer's backend authenticates its own users and asserts the Principal when it calls Checkfu. Checkfu API keys stay server-side; they are never exposed to those end users.

Every [Run](/concepts/sessions-and-runs) records its Principal so capability access, audit records, and usage can be attributed consistently.

## Groups

A **PrincipalGroup** is a Workspace-scoped set of Principals. PrincipalGroups are first-class PermissionAssignment subjects: grant a PrincipalGroup access once, then manage its membership without rewriting every PermissionAssignment. Group names and membership change together through optimistic `expected_resource_version` updates, so two directory syncs cannot silently overwrite each other. Deleting a Principal removes it from every PrincipalGroup before the Principal disappears.

This complete directory flow creates two Principals, lists the directory, creates a Group, and updates its name and membership. The cURL tab assumes `jq` is installed; both tabs assume the authentication variables from [Get access](/reference/access) are exported.

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light","dark":"github-dark"}}
  ADA_ID=$(
    curl -s --request POST "https://api.checkfu.com/v1/organizations/workspaces/$CHECKFU_WORKSPACE_ID/principals" \
      --header "Authorization: Bearer $CHECKFU_API_KEY" \
      --header "Checkfu-Version: 2026-08-27" \
      --header "Idempotency-Key: principal-ada-$(uuidgen)" \
      --header "Content-Type: application/json" \
  		--data "{\"external_subject_id\":\"ada-$(uuidgen)\",\"principal_type\":\"person\",\"display_name\":\"Ada\"}" |
      jq -r .id
  )

  GRACE_ID=$(
    curl -s --request POST "https://api.checkfu.com/v1/organizations/workspaces/$CHECKFU_WORKSPACE_ID/principals" \
      --header "Authorization: Bearer $CHECKFU_API_KEY" \
      --header "Checkfu-Version: 2026-08-27" \
      --header "Idempotency-Key: principal-grace-$(uuidgen)" \
      --header "Content-Type: application/json" \
  		--data "{\"external_subject_id\":\"grace-$(uuidgen)\",\"principal_type\":\"person\",\"display_name\":\"Grace\"}" |
      jq -r .id
  )

  curl -s --get "https://api.checkfu.com/v1/organizations/workspaces/$CHECKFU_WORKSPACE_ID/principals" \
    --header "Authorization: Bearer $CHECKFU_API_KEY" \
    --header "Checkfu-Version: 2026-08-27" \
    --data-urlencode "limit=100"

  GROUP=$(
    curl -s --request POST "https://api.checkfu.com/v1/organizations/workspaces/$CHECKFU_WORKSPACE_ID/principal-groups" \
      --header "Authorization: Bearer $CHECKFU_API_KEY" \
      --header "Checkfu-Version: 2026-08-27" \
      --header "Idempotency-Key: group-operators-$(uuidgen)" \
      --header "Content-Type: application/json" \
  		--data "{\"key\":\"operators\",\"display_name\":\"Operators\",\"member_subjects\":[\"$ADA_ID\"]}"
  )
  GROUP_ID=$(printf '%s' "$GROUP" | jq -r .id)
  GROUP_VERSION=$(printf '%s' "$GROUP" | jq -r .resource_version)

  curl -s --get "https://api.checkfu.com/v1/organizations/workspaces/$CHECKFU_WORKSPACE_ID/principal-groups" \
    --header "Authorization: Bearer $CHECKFU_API_KEY" \
    --header "Checkfu-Version: 2026-08-27" \
    --data-urlencode "limit=100"

  curl -s --request PATCH "https://api.checkfu.com/v1/principal-groups/$GROUP_ID" \
    --header "Authorization: Bearer $CHECKFU_API_KEY" \
    --header "Checkfu-Version: 2026-08-27" \
    --header "Content-Type: application/json" \
  		--data "{\"expected_resource_version\":$GROUP_VERSION,\"display_name\":\"Reviewers\",\"member_subjects\":[\"$ADA_ID\",\"$GRACE_ID\"]}"
  ```

  ```ts TypeScript theme={"theme":{"light":"github-light","dark":"github-dark"}}
  const ada = await checkfu.principals.create(
  	process.env.CHECKFU_WORKSPACE_ID,
    { external_subject_id: `ada-${crypto.randomUUID()}`, principal_type: "person", display_name: "Ada" },
    { idempotencyKey: `principal-ada-${crypto.randomUUID()}` },
  )

  const grace = await checkfu.principals.create(
  	process.env.CHECKFU_WORKSPACE_ID,
    { external_subject_id: `grace-${crypto.randomUUID()}`, principal_type: "person", display_name: "Grace" },
    { idempotencyKey: `principal-grace-${crypto.randomUUID()}` },
  )

  const directory = await checkfu.principals.list(process.env.CHECKFU_WORKSPACE_ID, { limit: 100 })

  const group = await checkfu.principalGroups.create(
  	process.env.CHECKFU_WORKSPACE_ID,
  	{ key: "operators", display_name: "Operators", member_subjects: [ada.id] },
    { idempotencyKey: `group-operators-${crypto.randomUUID()}` },
  )

  const groups = await checkfu.principalGroups.list(process.env.CHECKFU_WORKSPACE_ID, { limit: 100 })

  await checkfu.principalGroups.update(group.id, {
  	expected_resource_version: group.resource_version,
  	display_name: "Reviewers",
  	member_subjects: [ada.id, grace.id],
  })
  ```
</CodeGroup>

Use the returned Group ID in a PermissionAssignment as `"subject": { "kind": "group", "group": "grp_…" }`. List responses use the standard `data` / `next_page` envelope; send a non-null `next_page` back as `page` until it becomes `null`.

## PermissionAssignments

A **PermissionAssignment** is the universal permission primitive:

> A subject may use a resource with one permission.

The same primitive feeds one authorization layer and one audit surface.

```json PermissionAssignment theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "subject": { "kind": "principal", "principal": "prin_0123456789abcdef0123456789abcdef" },
  "resource": { "kind": "connection", "id": "conn_0123456789abcdef0123456789abcdef" },
  "permission": "use_connection"
}
```

Both `subject` and `resource` discriminate on `kind`, and each subject kind carries its own ID field:

| Subject kind         | ID field       |
| -------------------- | -------------- |
| `principal`          | `principal`    |
| `group`              | `group`        |
| `agent_definition`   | `agent`        |
| `agent_installation` | `installation` |
| `surface_scope`      | `surface`      |
| `session`            | `session`      |

Each resource kind admits only the permissions that make sense for it, so an impossible PermissionAssignment is rejected rather than silently ignored:

| Resource kind      | Permissions                                                                                |
| ------------------ | ------------------------------------------------------------------------------------------ |
| `agent-definition` | `invoke`, `steer`, `observe`, `approve`, `edit`, `view`, `act_as`, `use_agent`, `use_tool` |
| `connection`       | `use`, `use_connection`, `approve`                                                         |
| `tool-source`      | `use`, `use_tool`                                                                          |
| `skill`            | `use`                                                                                      |
| `memory-store`     | `read`, `write`, `use_memory`                                                              |
| `model-binding`    | `use`, `use_model`                                                                         |
| `harness`          | `use`, `use_harness`                                                                       |
| `surface`          | `invoke`, `steer`, `observe`, `approve`, `view`, `create_routine`, `manage_routine`        |

Note the hyphens: resource kinds are hyphenated (`memory-store`) while subject kinds are underscored (`agent_definition`).

On `agent-definition`, who *enforces* a permission differs by permission. `invoke` is enforced fail-closed at Session admission; every drive additionally enforces `invoke` or `steer`, chosen by the event — a `user.message` with no live Run takes `invoke`, and every other drive event (or a `user.message` during a live Run) takes `steer`. `act_as` is enforced wherever a Session runs under an acting Principal that is not the requester — installed and automation Sessions. That Principal is derived from the installation's placement or the stored Automation, never asserted on the request: Session create rejects a caller-supplied `acted_as` outright rather than checking it. `use_agent` is live agent→agent thread authority: the coordinator's published roster defines addressability, and this PermissionAssignment can only narrow it. The PermissionAssignment is enforced for each child-thread spawn and primary-to-child follow-up; a child-to-primary report continues the already admitted relationship and needs no reverse PermissionAssignment. A row whose subject is a *principal* is inert, so it is not how you give a person access.

Two are conditional: `observe` and `steer` are enforced by the trusted backend that exposes a Session view, and `approve` on the action-approval-response route only when your backend asserts `acted_as`. One is not PermissionAssignment-gated at all: `use_tool` on an agent's own Custom tools is **unconditionally ActionPolicy-only and defaults to allow** — no PermissionAssignment is consulted, and only a `deny`/`require_approval` ActionPolicy stops the call. (`use_tool` *is* PermissionAssignment-enforced on a `tool-source`.)

`edit` and `view` are *decidable* (`POST /v1/action-policies/evaluate` answers them for any subject) but agent registry reads and mutations authenticate your API key and carry no end-user identity, so **your backend is their enforcement point**: mint the rows, then ask the check before serving your own edit or read route. See [the embedded-builder guide](/guides/embed-an-agent-builder#what-checkfu-enforces-and-what-stays-yours) for the working pattern. `view` on an agent definition is platform-enforced in exactly one place, the [principal-scoped MCP lane](/reference/mcp#issue-and-manage-a-principal-credential). Other routes that *do* carry an asserted Principal are enforced normally — `GET /v1/transcripts` requires `principal_id` and checks that Principal's `view` on the SurfaceScope you query.

`approve` is load-bearing: a Principal holding it on the resource an ActionApproval's subject maps to — the surface or connection of a ToolInvocation ActionApproval, the declaring agent-definition or its surface for a custom-tool ActionApproval — can answer that ActionApproval without holding a Workspace approval-responder role, when a trusted backend asserts them via `acted_as`. See [ActionApprovals](/concepts/action-approvals).

PermissionAssignments are **immutable**. They are created and deleted, never patched, so an audited subject/resource/permission fact cannot be edited out from under its audit record. In v1 a PermissionAssignment enumerates a single resource. D6 retains that evaluator until measured latency, relation-composition, or row-volume evidence justifies a relationship-engine migration; scope expressions are not a promised next step.

Who may *write* a PermissionAssignment is a separate question from what a PermissionAssignment permits, and it is answered by the [API key's role](/reference/access), not by another PermissionAssignment: creating or deleting a PermissionAssignment or an ActionPolicy needs an `admin` or `root` key, while every Workspace role may read them. PermissionAssignments govern what agents and Principals may touch at runtime; key roles govern the control plane that authors them. Nothing recurses.

### Filter the PermissionAssignment list

`GET /v1/permission-assignments` accepts two optional filter pairs: `resource_kind` + `resource_id`, and `subject_kind` + `subject_id`. Both use the same kind vocabularies as the tables above, and they combine. Each pair is all-or-nothing: a kind without its ID (or the reverse) returns `400 validation.malformed` rather than an unfiltered list, so a typo cannot silently widen the answer. Filtering by both pairs asks the audit-shaped question directly: which PermissionAssignments let this subject touch this resource.

With `subject_kind=principal`, adding `effective=true` also returns rows whose subject is a Group the principal currently belongs to — each row verbatim, so a group-derived row still names its Group and your UI can render *why* access exists. Membership resolves through the same membership index the check uses, though the check tests strictly more, so this read is the weaker of the two by design. It reports rows, never permission to act: it composes no ActionPolicy, and a time-bounded row is returned with its bounds intact whether or not its window is open. `POST /v1/action-policies/evaluate` remains the only authorization answer. Follow `next_page` rather than treating a short page as the end — revoked rows are hidden after the page is taken, so a page can be short or even empty while more rows follow. `effective=true` is not available to `runner` keys.

## ActionPolicy and the decision

A PermissionAssignment answers whether an action is *permitted*. An ActionPolicy determines its *disposition*.

Every check returns one of three dispositions (`allow`, `require_approval`, or `deny`) together with the reason it reached that answer and the PermissionAssignments and ActionPolicy rules that matched.

The decision algorithm has three steps:

1. No matching PermissionAssignment → `deny`. ActionPolicies are never consulted, because there is nothing to modify.
2. Otherwise, the strongest matching active ActionPolicy rule wins, ordered `allow` \< `require_approval` \< `deny`.
3. No ActionPolicy rule matched → `allow`, on the strength of the PermissionAssignment alone.

This is the structural answer `POST /v1/action-policies/evaluate` returns: whether the access
exists, and what an ActionPolicy did to it. A tool call at runtime asks a second, narrower
question described below.

An ActionPolicy rule first matches on subject kind and ID, resource kind and ID,
permission, and optionally a surface scope. Every one of those structural
fields is a glob pattern where `*` and `?` are the only operators, and a bare
`*` is accepted for a whole subject or resource kind.

A rule may additionally carry up to 16 conjunctive argument conditions. Each
condition walks a bounded path through the operation's canonical arguments and
uses one of the closed operators `exact`, `one_of`, `not_one_of`, `range`,
`prefix`, `contains`, or `subset`. Operands are either JSON scalar literals (or
bounded scalar sets where the operator requires one) or references to a
server-derived trusted fact: `workspace_id`, `acted_as`, `surface_scope_id`, or
`connection_provider`. The browser can author that reference, but it never
asserts the fact's value. If a path, shape, or referenced fact is unavailable,
evaluation fails closed: an `allow` condition does not match, while a
`require_approval` or `deny` restriction still applies.

`POST /v1/action-policies/evaluate` runs exactly this decision without performing the action, which makes it the right way to pre-flight a UI: grey out what would be denied instead of letting a user discover it mid-Run.

### At runtime, a PermissionAssignment alone does not execute a mutation

A tool call runs the decision above on every mandatory check — the acting Principal and,
when installed, the AgentInstallation, on both the Connection and the tool source — and
then applies one more rule before anything executes:

1. Any check that denies → the call is denied. Any check that requires approval → the call requires approval.
2. Otherwise, a call proven to be a safe read runs.
3. Otherwise, the call runs only if **every** mandatory check reached `allow` because an ActionPolicy rule matched (`reason: "action_policy_allows"`).
4. Anything else requires approval.

So a valid PermissionAssignment with no ActionPolicy in the way returns `allow` from
`POST /v1/action-policies/evaluate` — correctly, because the access exists — while the same tuple on a
mutating tool call becomes an [ActionApproval](/concepts/action-approvals). PermissionAssignment answers whether the
agent may touch the resource at all; an explicit `allow` ActionPolicy rule is what lets it touch
the resource without a human. That default is deliberate: an unreviewed mutation is the
failure worth being conservative about.

A "safe read" is narrow. It requires safety hints marking the tool `read_only`, not
`destructive`, and not `requires_approval`, **and** a read-only transport method. MCP-bound
tools always POST, and their hints are authored by the upstream server and refreshed on
every sync, so an MCP-bound tool never reaches step 2 and never auto-allows on hints alone.
An explicit `allow` ActionPolicy is the only way to auto-allow one.

A [Custom tool](/concepts/capabilities#custom-tools) is the one family judged on ActionPolicy
alone: it has no PermissionAssignment to check, so with no ActionPolicy in the way the park proceeds.

An [ActionApproval](/concepts/action-approvals) is what `require_approval` produces. It freezes the full tool-call context at request time so the eventual decision cannot be replayed against changed arguments or a different Connection.

## Author and preflight in the console

The **Governance** page exposes the same primitives without adding a second policy language:

* **New PermissionAssignment** creates one exact, immutable subject × resource tuple and only offers permissions valid for that resource kind. Revoke and recreate to change it.
* **New ActionPolicy** authors one or more glob rules and their optional bounded argument conditions. Rule IDs must be unique inside the ActionPolicy; a matching `deny` remains stronger than `require_approval`, which remains stronger than `allow`. Trusted-fact operands are references resolved by the server at the operation boundary. ActionPolicies are immutable, so replace rather than edit one.
* **Structural preview** sends one exact subject, resource, permission, and optional SurfaceScope to `POST /v1/action-policies/evaluate`. It returns the disposition, reason, matching PermissionAssignment IDs, and matching ActionPolicy rules without performing the action.

The structural preview asks the live server evaluator directly, so stale or
truncated console registry lists do not affect its structural result. It does
not submit operation arguments or browser-asserted trusted facts, and therefore
does not claim to reproduce a concrete runtime decision for conditioned rules.
The real operation boundary evaluates those additional inputs and fails closed
when they cannot be inspected. Follow a matched `action_policy_id` with
`GET /v1/action-policies/{id}` to read the exact immutable ActionPolicy without scanning the
registry.

<Warning>
  An ActionPolicy cannot create access. If the structural preview reports `no_matching_permission_assignment`, adding an `allow` rule will not help. Create the exact PermissionAssignment first.
</Warning>

## Retention model

Every Workspace has one retention policy. `standard` may set a default TTL and per-content-class overrides; omitted TTLs retain content until explicit erasure. `zdr` is non-persistence, not encryption: customer execution content may cross the live request, Runner, model, and already-attached Session stream, but it never enters the Session log, control database, archive, queue, snapshot, or replay receipt. Transient Sandbox storage uses an `ephemeral_zdr` release and is destroyed rather than snapshotted.

Model wire evidence is the one content class whose ceiling a Workspace cannot raise. Every model-gateway-witnessed model call in a standard-retention Session records a [`wire.call`](/reference/events) event, and the exact bytes behind its digests are retained for **at most 30 days**, even where the Workspace policy sets no TTL at all. A shorter Workspace or `wire_frame` TTL still wins; nothing lengthens the ceiling. Resolving a digest back to bytes is [an operator route](/reference/events#resolve-retained-wire-content), not a tenant one: it requires an `admin` or `root` key and a digest the named event actually references, and after the ceiling passes the references stay structurally in the event while the content resolves `404`. A `zdr` Workspace constructs no wire evidence in the first place. The difference is the absence of `wire.call`, not a filtered copy of it.

ZDR deliberately preserves content-free authority: event positions and types, lifecycle state, immutable release and execution pins, authorization and idempotency HMACs, Runner lease identity, canonical error types, AuditRecords, Budget reservations, and UsageLedger facts. Event pages and exports report omitted positions as `reason: "zdr"` instead of pretending the sequence was continuous.

The runnable ZDR boundary is intentionally narrow. A managed Runner must already be blocked in the Workspace's live rendezvous before a pending or idle Session accepts a `user.message`; the request needs an `Idempotency-Key`, no attachments and no typed content blocks, a ZDR-compatible Sandbox release, and only credential-free mounts. Output is offered best-effort only to subscribers already attached when it is produced, at most once; even an attached subscriber may miss bytes, and reconnect cannot recover them. Running-message steering, custom-tool or approval continuation, Outcomes and Reports, Automation and multiagent-thread launch, ConnectedRuntime execution, Checkpoints, retained output or artifact capture, Git checkout plans, and Memory writeback fail closed rather than retaining a reduced copy.

<Warning>
  ZDR does not mean the provider never sees the prompt: an admitted live Run still sends transient content to the selected Runner and model provider. It means Checkfu does not durably retain that execution content. If no compatible live Runner slot can take immediate custody, admission fails before the message is authored.
</Warning>

## Next steps

<CardGroup cols={2}>
  <Card title="API reference" icon="code" href="/reference/overview">
    Explore the typed contract behind Workspaces, Sessions, and Runs.
  </Card>

  <Card title="Platform architecture" icon="sitemap" href="/concepts/platform">
    See how tenancy, PermissionAssignments, and policy compose into the control plane.
  </Card>
</CardGroup>
