# Permission scopes

> Choose the least API-key permissions required for ingestion, investigation, response, and administration.

Canonical page: https://anectico.com/docs/reference/permissions/


API-key scopes use `resource:action`. A key can receive only known scopes held by the member creating
it. Project scope and permission scope are both enforced: neither can widen the other.

The authenticated scope catalog at `GET /api/v1/account/api-keys/scopes` and **Settings → Access** is
authoritative. The groups below describe the current selectable catalog.

## Telemetry and investigation

| Resource | Scopes |
| --- | --- |
| Ingestion | `ingest:write` |
| Traces | `traces:read`, `traces:delete` |
| Logs | `logs:read`, `logs:delete` |
| Metrics | `metrics:read`, `metrics:delete` |
| Service health | `services:read` |
| Errors and Issues | `errors:read`, `errors:write`, `errors:delete` |
| Query and search | `query:read`, `query:write`, `search:read` |
| Saved searches | `saved_search:read`, `saved_search:write`, `saved_search:delete` |
| Customers and accounts | `persons:read`, `persons:profile:read`, `groups:read`, `groups:write`, `groups:delete` |
| AI agents and LLM cost | `agents:read`, `agents:content:read`, `agents:write`, `llm:read` |
| Events, cohorts, and flags | `analytics:read`, `analytics:query`, `analytics:write`, `cohorts:write`, `cohorts:delete`, `flags:read`, `flags:write`, `flags:delete` |
| Tracking-plan configuration | `tracking_plans:read`, `tracking_plans:write`, `tracking_plans:delete` |
| Saved-insight definitions | `insights:read`, `insights:write`, `insights:delete` |
| Anomalies | `anomalies:read`, `anomalies:write`, `anomalies:delete` |
| Replay | `replay:read`, `replay:content:read`, `replay:write`, `replay:delete` |
| Releases | `releases:read`, `releases:write` |
| Export | `export:read`, `export:write`, `export:download` (owner/admin only) |

`errors:write` allows Issue lifecycle actions and symbol uploads; deleting error data requires
`errors:delete`, which covers both units it has — deleting one Issue with its occurrences, and
erasing one customer's error occurrences across every identity they have used. Both are project-exact
and permanent, including for occurrences that arrive afterwards. `errors:delete` is not granted by
any of the narrower API-key presets (read-only, read-ingest, ingest-only) and must be named
explicitly — only the **Full access** preset, which grants the complete scope catalog, includes it.
Application SDKs use `ingest:write` for OTLP telemetry and captured errors, plus
`analytics:write` when sending diagnostic, identify, group, or screen events through the capture
surface. Session replay uses `replay:write` instead of `ingest:write`.

Tracking-plan list/get operations are available through REST, MCP and CLI; create/update/delete
use confirmed MCP actions and CLI, with session-only REST for human clients. See [Manage tracking plans](/docs/investigate/tracking-plans). `tracking_plans:read` is viewer-granted; `tracking_plans:write` is member-granted;
`tracking_plans:delete` is owner/admin-only. Writes and deletion also require the read scope for
configuration receipts. Capture and settings permissions do not grant plan management.
OAuth-delegated agents receive the read scope only; writes require a deliberately scoped API key.
Tracking permissions do not grant access to captured event values or change content permissions.

`analytics:query` authorizes typed analytics execution separately from reading events or results.
It also requires `analytics:read` and every source permission used by the definition. It is granted
to organization roles including viewers and is eligible for delegated OAuth; it does not grant
cohort mutation, export, or access to otherwise restricted source content.

Saved-insight get/list/search require `insights:read`. MCP and CLI create/update also require
`insights:write`; retirement requires admin-granted `insights:delete`. All mutation operations
require both `mcp:read` and `mcp:write`, preview and exact-argument confirmation. API keys can retire
only their own definitions; current admin user sessions can retire project definitions. These
permissions do not grant telemetry access. See [Manage saved insights](/docs/investigate/saved-insights).

Derived audience creation/replacement additionally requires `analytics:read`, `persons:read` and
all inherited source permissions alongside `cohorts:write`. MCP and CLI mutations use both
`mcp:read` and `mcp:write`, preview/confirmation and a stable UUID key. Exact audience reads need
`analytics:read`, `persons:read`, inherited source permissions and `mcp:read`, but no write or query
scope. See [derived audiences](/docs/investigate/product-analytics#save-and-reuse-an-exact-audience).

Creating, updating, refreshing, and replacing ordinary cohort membership requires `cohorts:write`.
This member-granted scope is separate from SDK capture (`analytics:write`) and is excluded from
external OAuth delegation. Application and browser SDK key presets do not grant cohort management. Deleting one is deliberately
separate under `cohorts:delete`, an owner/admin-only scope, because deletion is destructive.

`services:read` names per-service request rate, error rate, and duration, plus the per-operation
breakdown. `agents:read` names AI agent-run summaries: which agent ran, its model, step and
tool-call counts, duration, token and cost totals, and outcome. It also names the server-owned
24-hour fleet summary and the agent inventory and evidence trail around it — declared and
telemetry-discovered agents, the review queue over
those discoveries, which telemetry belongs to which agent, step-level agent events, action receipts,
chain verification, and signed bills of materials. None of that admits prompt, completion or tool
content: agent events expose bounded identity and provenance metadata, never content or a content
digest. It also reads exact outbound-drive audit records and recorded-session replay results. Replay
counts, stop reasons, comparison kinds, sizes, and digests remain visible; the real user turns and
agent responses still require `agents:content:read`. `llm:read` names LLM spend — the
per-model summary, the cost series, and the per-customer spend leaderboard.

`agents:write` is the one write in that family, and it is separate from every read in it. It names
declaring what you have deployed, the configuration that deployment resolved, the bill of materials
that configuration is made of — the three facts that let Anectico tell you when a running agent has
drifted away from the composition it was approved with — and **what your agent actually did**: one
lifecycle step of an action it took in the world, appended to your tamper-evident action log.
It also authorizes the explicit outbound control actions: opting one registered agent into driving,
sending one caller-authored message, and explicitly replaying one real recorded session. Starting a
session replay additionally requires `agents:content:read` because the action exports the person's
recorded words. Because these calls can trigger another system and spend its money, `agents:write`
is checked even when a trusted internal caller performs the transport work.

Nothing here can be inferred from telemetry. Your spans carry a configuration *hash*, and only the
system that resolved the configuration can say what that hash stands for; and only your agent knows
that it intended an action, was refused one, or could not confirm that an effect landed. So a release
pipeline declares its inventory and your agent records its own conduct, both with a key holding
`agents:write` and no dashboard involved. Reading any of it back — a configuration, a manifest, a
receipt — is `agents:read`, not this scope, and so is asking for a signed checkpoint of your action
log. It is owner/admin-only to mint, because a declaration changes what drift detection watches.

**The scope labels what you declare; it never lets you declare it as ours.** Every artifact it
produces records whose claim it is, inside the bytes the artifact's own digest covers:

| You declare | Recorded as | Anectico's own is |
| --- | --- | --- |
| A bill of materials | `authority: "declared"` | `authority: "mediated"` |
| An action-receipt step | `receipt_authority: "reported"` | `receipt_authority: "mediated"` |

`declared` means Anectico attests to what you declared and when, not that the components are accurate.
`reported` means Anectico attests that it received this statement and when, not that the action
happened. `mediated` is reserved for what the platform itself composed or performed. You cannot choose
the value — it follows from the credential that made the call, and a request that sets one is
rejected. Because it is inside the signed or chained bytes, anyone you hand an exported record to can
tell the two apart with the published key and no call to us. (The REST `authority` and
`receipt_authority` response fields carry the same fact as the prefixed enum name —
`ABOM_AUTHORITY_DECLARED`/`ABOM_AUTHORITY_MEDIATED` and
`RECEIPT_AUTHORITY_MEDIATED`/`RECEIPT_AUTHORITY_REPORTED` — outside the signed or chained bytes.)

One action has one authority for its whole life. A step you report under an action Anectico performed
is rejected rather than merged, so a receipt can never show your statement under our name.

**Each of those three reads stands on its own scope.** A key carrying only `services:read`,
`agents:read`, or `llm:read` reads that summary without also reading the spans underneath — that
separation is the point of the split. The same endpoints still accept `traces:read`, so a key that
already holds it keeps working unchanged; scope new keys with the specific read instead of
`traces:read`.

Three further scopes name **recorded content** rather than a summary of it, each separate from the
scope that lists the same records:

| Scope | Names | Its metadata-only counterpart |
| --- | --- | --- |
| `persons:profile:read` | A customer's profile properties — the traits your `identify()` calls attach, such as email, name, and plan | `persons:read` (identity, aliases, activity timeline) |
| `replay:content:read` | Recorded session content: the replay snapshots and their console and network payloads | `replay:read` (recording list and metadata) |
| `agents:content:read` | Agent-run content: prompts, completions, tool arguments and results, the reasoning transcript, and exact recorded-session replay turns and responses | `agents:read` (run and replay metadata, comparison, and cost) |

Treat all three as personal-data scopes. Replay masking is configured in the SDK and is best-effort,
and an agent transcript contains whatever your end user typed plus whatever the agent retrieved for
them, so a key that reaches this content reads it in its raw form.

**Each content scope is required for the content it names, everywhere.** The metadata-only
counterpart no longer reaches it, on any surface — not the API, not the dashboard, not the MCP
endpoint, not an AI investigation. Concretely:

- `replay:read` lists recordings and reads their start, end and duration. It does not open one:
  playback and the panel of errors and events captured during a session need
  `replay:content:read`.
- `agents:read` gives a run's summary — agent, model, steps, tools, duration, tokens, cost,
  outcome. The run's transcript needs `agents:content:read`.
- `persons:read` gives the identity spine — who exists, their aliases, their activity counts and
  timeline. Their profile properties need `persons:profile:read`, and so does anywhere those
  properties are used or matched: without it, an issue's affected customers and the LLM spend
  leaderboard identify people by their raw identifier instead of their name or email, and the
  people **search** matches identifiers only, so you cannot search by email address. (Whether a
  person is *identified* is part of the spine and is always shown — hiding it would misreport
  identified customers as anonymous.)

Some requests refuse outright with `403`; others return the record with the restricted field
omitted, so the surfaces that carry both — the people list, an issue's affected customers, an agent
run's scores — keep working for every role.

**In the dashboard, a refusal costs you that panel and nothing else.** The three detail pages that
carry restricted content are assembled from several reads, and the ones you are allowed keep
rendering:

| Page | Still shown | Replaced by a short note |
| --- | --- | --- |
| A customer | Their identifiers, first and last seen, the counts for events, errors, traces, logs, replay sessions and LLM spend, and the full activity timeline | The profile properties panel, and the accounts they belong to |
| An agent run | The run's shape — agent, model, status, steps, tool calls, errors, duration, cost — plus its quality scores and related errors | The span timeline, which carries the transcript |
| A session recording | Who the session belongs to, when it started, how long it ran, how many segments it has | Playback, and the product events captured during the session |

The note names what is hidden and says to ask an organization owner or admin. It never offers a retry:
nothing is broken, and the same request would be refused again. An outage looks different — it says
so and gives you a Retry.

The same rule follows the content wherever it is retold. An automated quality score's written
explanation is produced by a model that read the run's transcript, so it needs
`agents:content:read`; the numeric score and its label do not. An AI investigation is answered from
whatever its author was allowed to see, so reopening or continuing one needs the same access its
author had — otherwise Anectico shows the investigation and withholds the answer, and you can
re-run it to get one built from what you can see.

Alert labels follow it as well, and this one bites in a place people do not expect. An alert rule
grouped or filtered on a customer field labels its alerts with that field's value — an alert grouped
by `user_email` carries an email address. `alerts:read` admits the alert, not the address: the
customer-identifying labels (`user_email`, `user_id`, `user_ip_address`, and the customer
identifier) are returned only to a key that also holds the read scope of the signal the rule
queries, such as `errors:read`. Two things follow from that:

- Alerts whose labels are withheld also come back with an empty `fingerprint` and `group_key`.
  Those are digests computed from the full label set, so returning them would let a caller confirm a
  guessed email address. Alerts that carry no customer-identifying label are unaffected, and
  grouping and de-duplication behave exactly as before either way.
- **Notifications never carry them.** A Slack message, an email or a webhook goes to a destination
  with no permissions of its own and cannot be recalled, so those labels are left out of the
  delivered message and out of the stored delivery record — for every recipient, regardless of who
  configured the channel. Alerts still fire, still de-duplicate and still deep-link; open the alert
  in Anectico to see which customer it is about. Silences are unaffected: a silence matching on
  `user_email` still matches.

### Computed answers follow the data they were computed from

Anectico computes things **for** you — a cohort's membership, the accounts a segment matches, the
customer an alert fired on, a feature-flag decision. Each of those is an answer to a question asked
of your customer data, so each one needs the scope that admits the data behind it, not just the
scope that admits the page it appears on.

| A key reading… | also needs |
|---|---|
| Cohort members, member counts or a cohort preview built on a **profile property** | `persons:profile:read` |
| Cohort members, member counts or a cohort preview built on **events** | `analytics:read`; add `agents:content:read` for event-property filters outside the exact operational-metadata allowlist |
| An account segment's matched accounts, or flag targeting on **account properties** | `groups:read` |

What you will notice:

- **A flag decision never varies with something the key cannot read.** Without the scope behind a
  cohort or an account property, that targeting condition simply does not match — the same answer
  an unmatched condition has always given. Give SDK keys that target on cohorts or account
  properties the matching scope; leave it off keys that do not.
- **A member count reads as `0`** when the key lacks the scope, and a member list is refused. A
  cohort of "everyone whose email is X" has one member or none, so showing the count would be
  showing the answer. Zero alone therefore does not prove an empty cohort. MCP refresh receipts
  mark this ambiguous value as unknown and omit the count change; an authorized member-page
  response can establish the exact count for the returned generation.
- **Per-entity alerts hide which customer they are about** from a key without the scope — the id,
  the label, the deep link and the customer's name in the alert text all go together. The alert
  itself, its severity and its value are unchanged. Cohort-derived alert identities require
  `analytics:read`, `agents:content:read` and `persons:profile:read`, because the alert mirror does not carry the precise cohort predicate.

**One limit worth knowing: changing a key's scopes changes what it can read, not what has already
been sent.** The **notification** you configured still names the customer — you chose that
destination deliberately, and a Slack message or email cannot be recalled. If a destination should
stop receiving customer identities, change or remove the alert rule that feeds it; narrowing a key's
scopes will not do it.
- **What you wrote is still yours to read.** Cohort definitions, segment predicates and cohorts you
  uploaded by hand are not affected — only the computed answer is.

Every role from **member** upward already holds all three scopes, so nothing changes for people
signing in. This binds **narrowly scoped API keys**, and one role: a **viewer** can no longer list
the members of a cohort built on a profile property.

**A signal read scope no longer reaches customer content.** It used to. Prompts, completions, tool
arguments and retrieved documents recorded as span *attributes* by third-party AI instrumentation
came back verbatim from the raw trace endpoints to any key holding `traces:read`. They do not any
more: those attributes are withheld unless the credential also holds `agents:content:read`, and the
response says so — the span carries an `anectico.content.withheld` attribute naming exactly which
keys were removed, so a missing body is never confused with a call that had none. Everything else on
the span is untouched: token counts, model names, latency, status and your own attributes are
metadata and stay readable on `traces:read` alone.

**The same is now true of logs and of metric exemplars, and this page previously said the opposite.**
A log record's `attributes` and `resource` maps, and the `filtered_attributes` on a metric exemplar,
carry the identical convention keys and are withheld from a credential without
`agents:content:read` — on log search, on a single log entry, on the surrounding-context window, on
correlation, and on the **live tail**. The same `anectico.content.withheld` marker names what was
removed. So a prompt recorded as a log attribute is now governed by the content tier wherever you
put it, and you no longer have to record model content on spans for that to be true.

Discovery and grouping follow the same rule as record reads. Log-field discovery omits a
content-classified field together with all of its sampled values. Metric-label discovery omits the
classified label; a metric query that filters or groups by that label is refused before reading its
values unless the credential holds `agents:content:read` and the project's current read policy
permits the class. Ordinary labels and fields are unchanged.

Two things this deliberately does **not** withhold. The log **message** itself stays readable on
`logs:read`: a log line is not automatically a model transcript, and treating every one as such
would make ordinary log reading impossible. And an error's `extra` and `tags` are still governed by
`errors:read` alone — if you put a prompt there, `errors:read` reads it.

**One route to customer content remains open by design.** If you use email addresses as customer
identifiers, those identifiers are part of the identity spine and appear wherever identifiers do —
on traces, logs, errors and every timeline. No scope withholds them, because they are the join key
rather than a field. If a credential must not see them, identify customers by an opaque id.

An AI investigation's question and title are **user-authored metadata, not derived content**: they
are what a person chose to type, so they are shown to anyone who can see the investigation and are
governed by that choice rather than by the content tier. Treat a free-text question as visible to
every role in the organization.

Scope keys accordingly: give a key the metadata-only counterpart, and add the content scope only
when it genuinely needs verbatim customer content.

### An AI investigation reads with your key, not with ours

An investigation is an **aggregation**: it gathers issues, traces, logs, customers, accounts,
incidents and LLM spend into one grounded answer. `investigations:run` grants the ability to ask for
one — it does not grant the data the answer is built from.

**An investigation includes only evidence the key that requested it could have read directly.** Each
kind of evidence needs the scope that admits it on its own page:

| Evidence in an investigation | Also needs |
|---|---|
| Issue detail, occurrences, blast radius | `errors:read` |
| Release breakdown for an issue | `releases:read` |
| Traces and spans | `traces:read` |
| Log lines around an occurrence | `logs:read` |
| Cross-signal search results and trace-to-log correlation | `search:read` |
| Customer identity, timeline and labels | `persons:read` |
| Account timeline, stats and properties | `groups:read` |
| LLM spend summary, cost series and leaderboard | `llm:read` |
| Incident state, timeline and attached signals | `incidents:read` |
| A customer's session-recording list | `replay:read` |
| Agent-run summary | `agents:read` (or `agents:content:read`) |
| Agent-run steps | `agents:content:read` or `traces:read` |
| Recorded content — session playback, transcripts, profile properties | the content scopes above |

Two things follow:

- **A key missing one of these gets a smaller investigation, not an error.** The evidence it cannot
  read is not gathered — and the answer names what was left out, so a smaller investigation never
  passes for a complete one.
- **A key that cannot read the SUBJECT is refused.** Asking to investigate an issue with a key that
  has no `errors:read` returns `403`, the same answer the issue endpoint gives.

**The answer says what it did not look at.** An investigation assembled without some kind of evidence
ends with a `## Not examined` section naming it. That line is written by Anectico, not by the model,
so it is always there — because an answer that quietly skipped your errors must never read as *"no
errors were found"*.

The conclusion also carries the same fact as structured `evidence_disclosure` data: each omission
names a closed source family and one of `SCOPE`, `POLICY`, `UNAVAILABLE`, or `LIMIT`. Use that field
for automation; the Markdown sections are for people. A present empty disclosure means nothing was
withheld, while a missing disclosure means its producer did not populate the contract. Both forms
are generated from the same omission set, so the prose and field cannot drift.

**Recorded content is disclosed separately, under `## Withheld as sensitive content`.** The three
content scopes in the table above are asked over and above the permission to read the signal
carrying the content, so a credential can hold `agents:read` and receive an agent run's outcome,
latency and cost while its transcript is withheld for want of `agents:content:read`. That answer
looks complete, which is exactly why it gets its own section rather than sharing `## Not examined`:
the run *was* examined, and saying otherwise would contradict evidence you can see cited in the same
answer. A credential holding the content scope gets the identical investigation it always did.

Nothing changes for people signing in: every role, including **viewer**, already holds every scope in
the table above. This binds narrowly scoped API keys, which is what it is for. If an automation is
getting thinner investigations than you expect, give its key the scopes for the evidence you want it
to reason over.

**Your project's content policy answers a different question, and it binds everyone.** Permissions
decide who may see recorded content. The [content policy](/docs/manage/content-policy) decides whether
that content may be *sent to a model at all* — at the `judge_transfer` boundary — and Customer
Detective is one of the two places that boundary is crossed. Content the policy does not permit is
left out of the investigation for every caller, however broadly their key is scoped, and the answer
ends with a separate `## Withheld by policy` section naming what was left out. The two sections are
deliberately separate: `## Not examined` means *your credential* could not read something, and
re-minting the key changes it; `## Withheld by policy` means *your project* does not allow that
content to reach a model, and only a policy change does.

Every project starts with a policy that permits what the product already did, so this changes
nothing until somebody edits it. One thing is worth knowing: a content policy belongs to a project,
so an investigation that spans your whole organization is asked project by project rather than once.
In the organization-wide **LLM spend** question, each expensive agent run belongs to a single
project and that project's policy decides whether the run's verbatim messages may be sent — so one
answer can carry the transcripts a permitting project allows and omit those a denying project
refuses, naming the omission.

The spend **leaderboard** is the exception, and for a different reason: it ranks people, a person is
not confined to one project, and so there is no single policy to ask about their profile label. Those
rows are labeled by customer id. The spend, tokens, calls and cost series are not recorded content
and are unaffected.

### What the viewer role can see

The three content scopes are the only **reads** a member holds that a **viewer** does not — every
other difference between the two is a write, a delete, or an administrative action. Because the
content scopes now bind everywhere, that difference is real:

**A viewer keeps** every metric, log, trace and error; dashboards, alerts, incidents and on-call;
the customer and account lists, activity timelines and per-signal counts; the recording list; and
agent-run summaries and LLM spend.

**A viewer cannot** play back a session recording or see the errors and events captured during it,
open an agent run's transcript or the written explanation on its automated quality scores, read a
customer's profile properties, or search customers by email. Where a name or email would have
labelled a row, a viewer sees the raw identifier instead. A viewer can still run AI investigations
and read their own; an investigation someone else ran with access to recorded content shows its
question and not its answer.

If someone in the viewer role needs any of that, change their role to member — the scopes are not
grantable separately. A key can only receive scopes its creator holds, so a key created by a viewer
can never carry one of the three.

## Response and collaboration

| Resource | Scopes |
| --- | --- |
| Alerts | `alerts:read`, `alerts:acknowledge`, `alerts:resolve`, `alerts:rules:read`, `alerts:rules:write`, `alerts:rules:delete` |
| Silences | `silences:read`, `silences:write`, `silences:delete` |
| Delivery channels | `channels:read`, `channels:write`, `channels:delete` |
| On-call | `oncall:read`, `oncall:contacts:write`, `oncall:write`, `oncall:delete` |
| Escalation | `escalation:read`, `escalation:write`, `escalation:delete` |
| Incidents | `incidents:read`, `incidents:respond`, `incidents:write`, `incidents:delete` |
| Dashboards | `dashboard:read`, `dashboard:write`, `dashboard:delete` |
| Connections and tickets | `connections:read`, `connections:write`, `connections:delete`, `tickets:read`, `tickets:write`, `tickets:delete` |
| Account segments | `account_segments:read`, `account_segments:write`, `account_segments:delete` |

Responder actions such as acknowledging an alert or appending to an incident are separate from
configuration and deletion. Give on-call automation the responder scopes it needs, not blanket
administration.

Treat `channels:read` as a credential-grade scope. Reading a delivery channel returns its stored
configuration as you saved it, and a webhook channel's configuration includes its URL and any
request headers you attached — which is where an authorization token usually lives. A key that only
needs to send telemetry should not carry it. Sending a test notification is `channels:write`, not
`channels:read`, because it delivers a real message to that channel's recipients; a key used for
test-sends needs both, since the test first reads the channel it is about to use.

Personal contact methods are self-service and are gated twice. `oncall:read` lists them and
`oncall:contacts:write` adds, edits, or removes them; separately, every request is confined to the
calling user's own contact methods, so no key can read or change someone else's. Both scopes are
available to every role, including viewers — being reachable when you are on a rotation does not
depend on your role. Note that an API key acts as the member who created it, so a key you did not
intend to use for this still needs `oncall:contacts:write` withheld to be unable to change that
member's paging destinations. Schedule administration stays on `oncall:write` and `oncall:delete`.

For dashboards, `dashboard:write` permits creation, cloning, and widget or layout changes. Changing
dashboard metadata or variables is limited to the dashboard owner and organization admins, and
deletion requires both `dashboard:delete` and dashboard-owner or organization-admin status.
`dashboard:read` never makes a shared URL public; dashboard links still require an authenticated
same-organization principal.

## Organization, AI, and agents

| Resource | Scopes |
| --- | --- |
| Settings | `settings:read`, `settings:write`, `settings:delete` |
| GitHub configuration | `github_config:read`, `github_config:write` |
| Projects and members | `projects:read`, `projects:write`, `projects:delete`, `members:read`, `members:write` |
| API keys | `api_key:read`, `api_key:write` |
| Audit trail | `audit:read` (owner/admin only) |
| Scores and evaluations | `scores:read`, `scores:write`, `evals:read`, `evals:write` |
| Evaluation explanations | `agents:content:read` (additionally reveals the `explanation` field on an evaluation result) |
| Customer Detective | `investigations:run` (plus the scope for each kind of evidence an investigation should gather — see "An AI investigation reads with your key, not with ours") |
| Usage | `usage:read` |
| MCP | `mcp:read`, `mcp:write` |
| Documentation | `docs:read` |
| Agent containment | `quarantine:request`, `quarantine:approve` (owner/admin only) |
| Content governance | `governance:read`, `governance:write` (write is owner/admin only) |

Settings permissions are deliberately separate. `settings:read` shows stored organization and
global LLM pricing overrides without edit controls; it does not expose the embedded fallback
catalog. Adding or editing an organization override requires `settings:write`, and deleting one
requires `settings:delete`.

The same three scopes cover the organization record and its configuration entries. Reading the
organization or its configuration requires `settings:read`; renaming the organization or writing a
configuration entry requires `settings:write`; deleting the organization or removing a configuration
entry requires `settings:delete`. A key holding none of them authenticates normally and receives
`403 PermissionDenied` on all of them — which is the point: an unscoped key must not be able to
delete your organization. Changing a plan or a usage limit is not available to any organization
role, including owner.

Listing projects and listing the member roster are reads in their own right: `projects:read` returns
each project's name, slug, id, and retention — whether you list them all or fetch one by id — and
some endpoints need it indirectly, because exporting data validates the project you name before it
accepts the job. `members:read` returns the roster including pending
invitations, which are the not-yet-accepted half of the same list. Both are available to every role,
because the project selector and the member picker appear throughout the product; creating,
renaming, or deleting a project, and administering members or invitations, remain owner/admin-only
under `projects:write`, `projects:delete`, and `members:write`. A key without the read scopes
authenticates normally and receives `403 PermissionDenied` on those endpoints, so an ingestion-only
credential cannot enumerate your projects or your people.

Key management is itself scoped. Listing the organization's keys requires `api_key:read`, and
creating, renaming, revoking, or re-scoping one requires `api_key:write`. A key without them
authenticates normally but receives `403 PermissionDenied` on those endpoints — listing exposes
every key's id, name, and granted scopes, so it is not something an unscoped credential should
reach. Granting `api_key:write` does not widen a key: a new key can still only receive scopes its
creator already holds. Nor is it a way around a containment: a key frozen by an approved quarantine
refuses a scope change until the quarantine is lifted, and lifting takes the owner/admin
`quarantine:approve`. Leave both off any key that does not administer credentials.

`audit:read` opens the organization audit trail in Settings and through the account audit endpoint. It
can be filtered by resource, action, and result; it omits network identifiers, user agents, and
arbitrary metadata. Owners and admins receive it through their role, while narrower keys must name
it explicitly.

`mcp:write` is an umbrella gate. An MCP write also requires its domain scope. For example, creating
an incident requires both MCP write access and the incident permission enforced by that tool.
Exact telemetry deletion requires `mcp:write` plus `traces:delete` or `logs:delete` on a
project-scoped key; the MCP actions do not accept a project override.

Containing a misbehaving agent takes two people. `quarantine:request` lets any member or developer
ask for an asset, an observation key, or a single API key to be frozen, revoked, or both — asking
enforces nothing on its own. `quarantine:approve` is owner/admin-only, and it is what actually
disables the credential: approving is what signs and enforces the request, and lifting a
containment afterward takes the same scope. No key can hold only `quarantine:approve` — every role
that grants it also grants `quarantine:request` — but the platform separately refuses to let one
principal both request and approve the same containment, so a key that could do both still cannot
approve its own ask.

Withdrawing a request nobody approved is the exception, and it sits with `quarantine:request`:
retracting your own ask hands capability back to nobody, because a pending request never touched a
credential. Retracting **somebody else's** takes `quarantine:approve` — it suppresses a containment
before a second person can consider it. See
[Revoke an agent's access](/docs/agents/contain-an-agent#contain-an-agent) for the full
request-approve-lift flow and
[Withdraw a request nobody approved](/docs/agents/contain-an-agent#withdraw-a-request-nobody-approved).

### Your project's content policy

Your project's **content policy** says which kinds of recorded content may cross which boundary:
model transcripts, tool arguments and results, session-replay recordings, the property bag on a
person, the separate property bag on a business account or group, and agent memory — each against
being stored at all, served to a reader, included in a bulk export, or sent to an evaluation model.
`governance:read` returns that grid for one project or for
every project in the organization. It is available to every role, including viewers, because the grid
contains no recorded content of any kind — only the decisions and the version — and the people a
disclosure control protects should be able to check it.

`governance:write` replaces a project's policy and is owner/admin-only. It is the broadest single
change the API offers: widening one cell makes every transcript in that project readable,
exportable or shippable to an evaluation model, retroactively, for every key that already holds the
matching content scope. Leave it off any key that does not administer disclosure policy.

Two things are worth knowing before you rely on it. Every project already has a policy, including
one you have never opened: the organization seeds each project with a grid matching what the product
does today. A project whose grid has not been written yet reads back as fully denied rather than as
an error — that is the shape of the *response*, not what the boundaries do in the meantime, which is
covered below. And a change takes up to 30 seconds to take effect everywhere, so a tightening is not
instantaneous.

**The four boundaries are not equally reversible, and one of them is permanent.** Refusing a class
at the reading, exporting or evaluation boundary withholds a copy of something Anectico still holds,
so widening the policy and asking again returns it. Refusing a class at the **storing** boundary
means the content is never written down: the observation is kept — timing, status, model, token
counts, cost, and the person it belongs to — and the bodies are not, for good. Widening applies
forwards only and cannot recover what was recorded while the class was refused. The 30-second
propagation therefore cuts both ways at that boundary: a tightening leaves up to 30 seconds of
content recorded (which you can delete), and a widening leaves up to 30 seconds of content
unrecorded (which you cannot recover).

**At the reading boundary, "we do not know yet" means withhold — the opposite of storing.** If
Anectico briefly cannot read your project's policy, reads return the observation with the content
left out rather than guessing that you would have allowed it. Nothing is lost: the content is still
stored, and the same request returns it once the policy can be read again. The reason the two
boundaries answer the same uncertainty in opposite directions is the reversibility above — a read
you have to repeat costs a retry, and content disclosed against a refusal you had just recorded
cannot be taken back. The **evaluation** boundary answers this uncertainty the same way reading
does — if the policy cannot be read, nothing is sent to a model — because its destination is outside
Anectico and outside your organization, which is the one place a mistaken send is worst.

**A project whose policy has not been set up yet is treated as if it already had the standard one,
at all four boundaries.** A brand-new project gets its policy from a sweep that runs every fifteen
minutes rather than at the moment you create it, so there is a short window before its grid exists.
In that window the project behaves exactly as it will once the grid is written — which is the point:
the alternative answers the same project two different ways depending on which side of the sweep a
request lands, and records a refusal against a preference you had not expressed. Nothing is decided
differently because of it; the standard posture is what your project is about to be given, and you
can tighten it the moment it exists.

Exporting was the last boundary to work this way, and the change is visible on the export itself: a
job decided this way is marked as having used the standard posture rather than a grid you set, so a
file assembled before your project's grid existed is never mistaken for one you had approved.

An export that covers a whole organization rather than one project is the exception, and still leaves
recorded content out. There is no single grid to apply — the projects it spans may each have set a
different one — so nothing is assumed on their behalf. Scope the export to a project to get its
content.

**Reading, exporting, and automatic evaluation are independent boundaries.** A class set to `deny`
at `read` and `allow` at `export` can still be exported, and the same combination with
`judge_transfer` can still be used by an automatic quality check. Each operation asks the cell for
the boundary it is actually crossing. Customer Detective is intentionally different: it assembles
a user-visible investigation first, so its evidence must pass `read` as well as the later
`judge_transfer` decision. Denying any one cell affects only operations that cross that boundary.

**A read that covers your whole organization is governed row by row.** A content policy belongs to a
project, and a request that names no project can return rows from projects whose policies disagree.
Each row is answered by the policy of the project it came from — so one organization-wide search of
traces or logs returns content from the projects that permit it and leaves it out for the projects
that refuse, in the same response, and says which rows were affected. This is what you get from the
API, the MCP endpoint and the CLI when you do not pass `project_id`.

Two things still come back without content, and they are fixed in different places. Trace exemplars
on a metric point carry no project of their own, so a metric read that names no project cannot obey
a policy and leaves their attributes out — name the project (`project_id`) and the content comes
back. And a row that carries no project at all, because the telemetry was sent with an
organization-level credential rather than a project one, has no policy that could govern it whichever
way you read it; sending that telemetry with a project-scoped credential is what fixes that one, not
the read. The dashboard always names a project for you.

**At the storing boundary, "we do not know yet" means record.** A project whose policy has not been
set up yet, and a project whose policy Anectico briefly cannot read, both keep recording on the
standard posture rather than being refused. A refusal you have already configured is not affected —
the last policy read for your project stays in force through any outage of ours. The reasoning is
the asymmetry above: content recorded in error can be deleted afterwards, and content never
recorded cannot be brought back.

**Session-replay recordings and person property bags are now enforced at the storing and reading
boundaries too.** Every class in the grid except agent memory now binds at every boundary where it
has a flow, and the two that recently did not behave like this:

- **Session replay at `store`.** A recording chunk is refused outright — no recording appears, and
  the upload is answered with an error naming the content policy rather than being accepted and
  thrown away. What a session was *linked to* is unaffected: the errors and product events captured
  during a session, and a recording's existence, length and person, are not the recording itself
  and are never withheld under this rule.
- **Session replay at `read`.** Playback is refused with an error rather than an empty timeline,
  because an empty timeline is indistinguishable from a session that recorded nothing. The decision
  follows the recording's own project, so an organization-wide credential gets the right answer without
  naming one.
- **Person properties at `store`.** The property values on an `identify()` call are not written
  down; the identity itself still is. Anectico still learns who the person is, still merges an
  anonymous visitor into an identified one, and still links their sessions, errors and traces —
  only the property values are dropped, and the response says so. Clearing a property (`unset`) is
  never refused: it removes recorded content rather than recording any.
- **Person properties at `read`.** The property bag is left off a person, per person, according to
  the policy of the project that person belongs to — so one organization-wide list can return
  properties for the projects that permit them and leave them out for the projects that do not, in
  the same response. The record says which control removed them, because only one of the two is
  something you can fix with a different key.

**The storing boundary also reaches the context you attach to an error.** Tags, extra data and
captured request headers on an error report are your own key/value bags, so a field you name after
a model-content convention — `extra.gen_ai.prompt.0.content`, say — holds the same bytes the policy
governs on a span, and is treated the same way. A refusal drops those fields from the error and from
its issue, and names them in `anectico.content.not_stored`; every other field you sent is untouched.

**Event properties are governed at BOTH boundaries.** The property bag on a product event is your
own key/value bag, so a property named after a model-content convention is the same bytes the policy
governs on a span. At the storing boundary those properties are not written down. At the reading
boundary — a customer's activity timeline, or an account's — they are left off the event and the
response names them, per event, according to the policy of the project that event belongs to. So one
organization-wide timeline can return properties for the projects that permit them and leave them out
for the projects that do not, in the same response. Every other property you sent is untouched, and
so is the event itself: its name, its time, the person and the session it belongs to.

**A promoted evaluation case is refused rather than trimmed.** When you promote a production agent
run into a dataset, the run's own recorded content is governed as usual — refused classes are left
out of the frozen case and the case records what went. The `expected` and `metadata` documents you
send with it are treated differently: if one of them carries a field named after a model-content
convention that your policy refuses to store, **the whole promotion is refused** and nothing is
written. The response names the document and the field so you can remove it and retry.

The reason is that a dataset version cannot be edited. Once written, its cases are permanent — that
is what makes an evaluation result citing a version mean something. Quietly dropping a field from an
expected answer would leave a permanent, unremovable specification you did not write, scoring
differently for ever, and nothing in the record would say so. Refusing costs you one edit; you are
still holding the document.

Reading a dataset is governed too, and separately: a case's frozen run keeps its shape with the
recorded bodies removed, while `expected` and `metadata` are withheld whole and replaced by a note
saying which field was refused and whether it was your policy or your key that refused it. That
matters because the two boundaries are separate settings — a project may record content and refuse
to hand it back — and because a policy you tighten today applies to cases promoted before you
tightened it.

Two consequences are worth knowing before you set either to `deny`:

- **People search stops matching on property values** for a project that refuses them at `read`.
  It has to: leaving the search able to match and rank on a value it will not print would answer
  the question the withholding exists to refuse. Searching by any identifier you sent with
  `identify()` keeps working. A people search that names **no** project takes the same narrower
  behavior whatever your policy says, because the search has to be built before it knows which
  projects the results will come from — name a project to keep property matching.
- **Trace and log search stops matching on withheld attribute values**, for the same reason.
  Free-text search keeps working over the parts it may still show you — the message, service and
  operation names, and identifiers — so most searches are unaffected. Searching for an exact value
  of a withheld attribute is **refused with `400`** rather than quietly answered from a narrower
  set, because a filtered result you cannot tell apart from an unfiltered one is worse than an
  error. This applies to trace search, log search, live tail, unified search and correlation. As
  with people search, a request that names **no** project takes the narrower behavior whatever
  your policy says — name a project to keep attribute matching.
- **Property-based audiences stop refreshing** for a project that refuses person properties at
  `read`, because an audience defined by a property value is computed by reading those values.
  Audiences defined by behavior are unaffected.

Agent memory has no producer at all yet, so its four cells govern nothing whichever way you set
them; that is stated rather than hidden, because a control you believe in and do not have is worse
than one we do not offer. Note that documents retrieved by a RAG or search step are recorded as part
of the model transcript and are governed by **that** class, not by agent memory. Session replays and
person profiles are still *also* governed by their own permissions (`replay:content:read`,
`persons:profile:read`); the two controls compose, and neither stands in for the other.

Records and responses affected this way say so, and the three statements are deliberately
different:

| Field on the record | What it means | What changes it |
|---|---|---|
| `anectico.content.not_stored` | The content was never written down, because your policy refused that class at the **storing** boundary | Nothing — this is permanent. Widening the policy applies forwards only |
| `anectico.content.policy_withheld` | The content exists and your project's policy refuses that class at this boundary | A change to the content policy |
| `anectico.content.withheld` | The content exists, your policy permits it, and **this credential** is not in the sensitive-content tier | A key with the matching content scope |

Where both a policy refusal and a credential gap apply to the same field, the policy is what is
reported, because a wider key would not have helped. All three fields are written by Anectico and
only by Anectico: they are stripped from anything you send — span and log attributes, product-event
properties, and error tags, extra data and captured headers alike — and re-issued from what was
actually removed, so they cannot be forged into your own telemetry. `anectico.content.*` is a
reserved key prefix for that reason; a key of your own with one of those three names is dropped
rather than stored, whatever your policy permits. See
[Prompt and completion content](/docs/instrument/llm-calls#your-project-decides-whether-content-is-recorded-at-all).

### Reading the documentation from an agent

`docs:read` lets a key read Anectico's own product documentation — these pages, byte for byte —
through the `search_docs` and `get_doc` actions on the MCP endpoint, and through the `docs` source
on `search` and the `doc` reference kind on `fetch`. It is the one scope on the surface that
discloses nothing about your organization: the pages it returns are published at
[anectico.com/docs](https://anectico.com/docs) and are the same for everyone.

It is available to **every role, including viewers**, for that reason. It exists as a scope so a key
can deliberately leave it out — a narrow ingest or cleanup key has no use for the manual — not
because the content needs protecting. Give it to any agent you expect to configure something: an
agent that can look up a scope name, an alert expression or an SDK call is an agent that stops
guessing them.

## Safe key patterns

- Browser or mobile SDK: project-scoped `ingest:write` plus `analytics:write`; add `replay:write`
  only when recording browser replay. Browser flag evaluation additionally needs `flags:read`;
  treat all flag configuration and payloads as client-visible.
- Server SDK: project-scoped `ingest:write`, adding `analytics:write` when it calls identify, group,
  screen, or diagnostic-event methods.
- OpenTelemetry collector: project-scoped `ingest:write` only.
- Any agent you expect to configure something: add `docs:read` so it can look up a scope name, an
  alert expression or an SDK call instead of guessing one. It reads published documentation and
  nothing about your organization.
- Read-only agent: `mcp:read` and only the read domains it needs. `services:read`, `agents:read`,
  and `llm:read` are each sufficient on their own for service health, agent-run summaries, and
  spend — a read-only agent does not need `traces:read` for them. Add `persons:profile:read`,
  `replay:content:read`, or `agents:content:read` only when the agent must read customer content
  verbatim; withholding them is enough to withhold that content, including the prompts and
  completions third-party AI instrumentation records as span attributes. `traces:read` on its own
  now returns those spans with the bodies removed and an `anectico.content.withheld` attribute
  naming what was removed, so an agent can investigate latency and errors on traces without ever
  reaching a transcript. Your project's content policy can withhold the same bodies from a key that
  *does* hold the content scope; those responses carry `anectico.content.policy_withheld` instead.
- Telemetry-cleanup agent: dedicated project key with `mcp:write` and only `traces:delete` and/or
  `logs:delete`; both actions preview the signed project and exact target before confirmation.
- CI symbol upload: dedicated project key with `errors:write`; add `releases:write` when the same job
  also registers a release.
- Release pipeline registering agent inventory: dedicated project key with `agents:write`, minted by
  an owner or admin. It covers the deployment, the effective configuration and the bill of materials
  — all three legs of one declaration. Add `agents:read` only if the same job reads the registration
  back or checks the review queue it feeds; the write does not admit any read on its own.
- Operations automation: explicit responder or configuration scopes, fixed project, no unrelated
  deletes.

The wildcard platform-admin permission is not grantable to API keys.

- [Authentication and API keys](/docs/reference/authentication)
- [Permissions and safe actions](/docs/agents/permissions-and-safety)

`persons:correct` permits an audited alias separation with a current preview revision. Granted to owners/admins only; API keys may request it explicitly. `persons:read` permits preview and mapping history without profile property values. SDK `analytics:write` does not permit corrections.
