# REST API

> Use Anectico's public API conventions and find every supported customer resource family.

Canonical page: https://anectico.com/docs/reference/rest-api/


The public REST API base is:

```text
https://app.anectico.com/api/v1
```

Use an SDK for telemetry and the Anectico CLI for ordinary investigation or automation. Use REST when
building a direct integration. Anectico is in early access, so pin the client and Anectico release you
verified when request-schema stability matters.

## Authenticate and select a project

Send an API key in `X-Anectico-API-Key`. `X-API-Key` and `Authorization: ApiKey an_...` are also accepted,
while MCP uses bearer authentication. Prefer one documented header per client.

```bash
curl --fail-with-body \
  -H "X-Anectico-API-Key: $ANECTICO_API_KEY" \
  "https://app.anectico.com/api/v1/persons?project_id=$ANECTICO_PROJECT&search=buyer%40acme.example"
```

For organization-level credentials, many data reads accept `project_id`. A project-scoped key may
omit a query-string `project_id` or repeat its exact signed project. Any non-empty conflicting query
value, including one hidden in a repeated parameter, returns a stable `403 permission_denied` before
the product handler runs. The requested project is never silently replaced with the key's project.
The same fail-closed response applies when a non-empty `project_id` field is malformed in a way the
standard query parser would otherwise discard, such as an unescaped semicolon or invalid percent
escape. A malformed unrelated query field does not change an otherwise exact or omitted selector.
Organization-level credentials retain request-time project selection. Organization identity always
comes from the authenticated principal, not from request input.

Browser logout is `POST /auth/logout`. When a refresh cookie is present, success means its whole
refresh family was revoked before the browser cookies were cleared. If that revocation is
temporarily unavailable, logout returns `503` with `Retry-After: 1` and leaves the cookies unchanged
so the browser can retry; it never reports success while another copy of the refresh token remains
usable.
A browser refresh credential remains bound to the organization selected when that login session
was created. Selecting another organization re-authenticates and creates a new session; an older
refresh credential can renew only its original organization. If membership or plan validation is
temporarily unavailable during refresh, retrying does not consume the presented credential.

Non-2xx JSON responses use `{"error":"...","message":"..."}`. A recognized failure that
changes the safe retry action also includes a machine-readable `details` object. For example, a
ticket create that may have reached the external provider but was not recorded in Anectico carries:

```json
{
  "error": "internal_error",
  "message": "internal server error",
  "details": {
    "reason": "PROVIDER_EFFECT_UNRECORDED",
    "provider": "jira",
    "external_id": "PROJ-9",
    "external_url": "https://jira.example/browse/PROJ-9"
  }
}
```

The reason and facts remain available even when a server-fault message is redacted. Treat
`external_url` as provider-authored data. On `PROVIDER_EFFECT_UNRECORDED`, retry the identical
create with the same idempotency key; do not mint a new key.

Telemetry reads — traces, logs, metrics, errors and the searches over them — distinguish a fault
from a capacity limit. A read the deployment could not answer at that moment answers `503`, not
`500`: the request was well formed and the same request will usually succeed on a retry, so retry it
with backoff. A `500` means the opposite — something is wrong with the request or the service, and
retrying it unchanged will not help. Narrowing the time range and the filters of a `503`-ing search
makes it cheaper and more likely to succeed.

Every `503` carries `Retry-After` (in seconds) and an `unavailable` error code, so the two are
distinguishable without reading prose: a `503` body says `"error": "unavailable"` while a `500` says
`"error": "internal_error"`. Wait at least `Retry-After` before retrying. A `503` is also the answer
when a request is shed because your organization already has too many reads in flight at once —
retrying after the interval is the correct response to that, and issuing fewer concurrent reads is
the way to avoid it.

**A malformed value is refused, not ignored.** If you send a query parameter we cannot parse — a
misspelled boolean, a non-numeric `limit`, an unparseable timestamp — you get a `400`. Leaving a
parameter out still means "no filter"; the distinction is between absent and present-but-invalid,
because a typo that silently drops your filter returns a wider answer you have no way to recognize as
wrong. For the same reason a `PATCH` whose body changes nothing — empty, or naming only fields that
do not exist or cannot be edited — is refused rather than recorded as an update.

A read that ran out of time answers `504`, and one you cancelled answers `499` — neither is a `500`,
because the service is healthy and the query was simply too expensive to finish. Narrow it before
retrying; the same request will exceed the same deadline. A read whose result set is too large to
answer honestly answers `429` rather than returning a truncated one: the trend matrix above 100,000
bucket-and-series rows, and the evidence and step-tree ceilings described later. In every one of
these cases you are told the answer is incomplete rather than handed a smaller answer that looks
whole.

Person profiles and statistics follow the same rule for their activity, replay-session, and group
measurements. A failed measurement returns an error; it is never rendered as a measured zero or an
empty membership list. Release detail, release lists, per-Issue release breakdowns, and merged-Issue
metadata likewise fail when a required measurement or enrichment is unavailable. On a successful
response, zero counts and blank optional metadata therefore describe the measured data rather than
a dependency outage.

Issue reads follow that rule. `GET /errors/groups` accepts exact `platform`, `service_name`,
`mechanism`, and `release` occurrence filters alongside `project_id`, environment, and time bounds.
It also accepts `query`, a free-text search: a case-insensitive substring of an error's message,
emitting service, or error class. Like every other filter there, it selects the individual errors
recorded inside the window, so an Issue is returned when at least one of its errors in that window
matches — and the per-Issue counts, first/last timestamps, ordering, and `pagination.total_count`
all describe that same searched set. Search the whole matching set by following the returned cursor
rather than filtering a page you already hold.
`GET /errors/groups/{group_id}/affected` also requires the intended `project_id` for an
organization-level credential; a missing group and a group owned by another project both return the
same `404`.

## Typed product measurements

`POST /analytics/query`, `GET /analytics/results/{result_id}` and
`GET /analytics/results/{result_id}/participants` execute typed trends, ordered person funnels or
exact-period retention and read frozen evidence. Query/get responses contain
exactly one payload — `result` for trends, `funnel` for funnels or `retention` for retention — and
only when the status is
`PRODUCT_RESULT_STATUS_READY`. Funnel step/outcome selections and retention cohort/return selections use
the same participant route. `GET /analytics/results/{result_id}/contribution` accepts exact
`snapshot_id`, `selection_id`, positive decimal `ordinal`, `contribution_ref`, and `project_id`
query parameters. It returns bounded frozen event references — funnel chain steps, or a retention
cohort's entry and exact-period returns — with first-entry attribution and one-person
outcome/maturity facts under current source permissions. It does not return arbitrary
event properties or a live timeline. Each event reference carries a `replay` field exactly when it
has a session: `available` when a recording exists and this credential may open it, `none_recorded`
when the occurrence was captured in a session with no saved recording, or `withheld` when this
credential lacks session-replay read access; the field is absent entirely for an occurrence with no
session at all. Query and get responses carry a `status` (`PRODUCT_RESULT_STATUS_PENDING`,
`PRODUCT_RESULT_STATUS_RUNNING`, `PRODUCT_RESULT_STATUS_READY`, `PRODUCT_RESULT_STATUS_FAILED`,
`PRODUCT_RESULT_STATUS_CANCELED`, `PRODUCT_RESULT_STATUS_EXPIRED`,
`PRODUCT_RESULT_STATUS_INVALIDATED`) and, when failed, a `failure_reason`
(`PRODUCT_RESULT_FAILURE_REASON_EXECUTION_BUDGET`, `PRODUCT_RESULT_FAILURE_REASON_TOO_MANY_RUNNING`,
`PRODUCT_RESULT_FAILURE_REASON_SOURCE_UNAVAILABLE`,
`PRODUCT_RESULT_FAILURE_REASON_IDENTITY_UNAVAILABLE`, `PRODUCT_RESULT_FAILURE_REASON_INTERNAL`,
`PRODUCT_RESULT_FAILURE_REASON_KIND_UNAVAILABLE`,
`PRODUCT_RESULT_FAILURE_REASON_SOURCE_UNADMITTED`, or
`PRODUCT_RESULT_FAILURE_REASON_STORAGE_EXHAUSTED`). A ready response carries its payload under the
key for its kind — `result` for a trend, `funnel` for a person funnel, `retention` for a retention
grid — and every one of the three carries the same `manifest`, so the coverage verdict for a trend
is at `result.manifest.current.coverage` and for the other two at `funnel.manifest.current.coverage`
and `retention.manifest.current.coverage`. Read it under the key your request asked for; the field
is never omitted for one kind and present for another. A measurement runs in the background:
`POST /analytics/query` returns as soon as
it is accepted, with a `result_id` and a status, and `wait_millis` (0–25000) is how long the call
blocks for `PRODUCT_RESULT_STATUS_READY` before answering with the status that stands. A response
that is not ready
carries no payload and is a correct answer, not a timeout; poll
`GET /analytics/results/{result_id}` for a final status. Four measurements may be pending or
running per project — a fifth answers `PRODUCT_RESULT_STATUS_FAILED` with
`PRODUCT_RESULT_FAILURE_REASON_TOO_MANY_RUNNING` and no `result_id`.
`POST /analytics/results/{result_id}/cancel` stops one that has not completed; it needs the same
permission as running a measurement, and the stopped measurement publishes nothing.
See [measure product events](/docs/investigate/product-analytics) for the shared definition,
retry key, exact selections, current source permissions and MCP/CLI equivalents.

## Resource catalog

| Area | Resource prefixes | Main purpose |
| --- | --- | --- |
| Customers and accounts | `/persons`, `/groups`, `/group-types`, `/account-segments` | Profiles, timelines, group accounts, watched segments |
| Raw observability | `/traces`, `/logs`, `/metrics`, `/services`, `/search`, `/stream/logs` | Search, correlation, aggregation, live logs, RED metrics, service map |
| Errors and delivery context | `/errors/groups`, `/releases`, `/sourcemaps`, `/symbols` | Issue triage, impact, lifecycle, deploys, readable stacks |
| Replay | `/replay`, `/replay-for-trace` | Recordings, snapshots, related evidence, trace lookup |
| Events and rollout | `/events`, `/analytics`, `/flags`, `/decide` | Event discovery, trends and triggerers, cohorts, flag configuration and evaluation |
| AI execution | `/agent-runs`, `/agent-sessions`, `/agent-turns`, `/agent-events`, `/telemetry-completeness`, `/llm`, `/evaluation`, `/ai` | Runs and their step graph, conversations and turns, step-level agent evidence, coverage by normalizer, scores, cost, pricing overrides, quality settings, quality evaluations and their provenance, natural-language query |
| Agent inventory and evidence | `/assets`, `/review-signals`, `/action-receipts`, `/receipt-chains`, `/abom-manifests` | Declared and discovered agents, what still needs a human, what an agent did and under whose authority, tamper-evidence checks, signed bills of materials |
| Customer Detective | `/investigations` | Start, continue, stop, follow, list, and read grounded investigations |
| Agent control and provenance | `/quarantines`, `/content-policies`, `/authority-edges`, `/memory-items`, `/deployments`, `/config-snapshots` | Approval-controlled containment, content boundaries, authority, memory labels and propagation, immutable deployment/configuration provenance |
| Fleet and controlled execution | `/fleet`, `/agent-drive-attempts`, `/agent-session-replays` | Fleet posture, drive-attempt evidence, and recorded-session replay results |
| Detection and response | `/anomalies`, `/alerts`, `/slo`, `/incidents`, `/oncall`, `/escalation-policies` | Findings, rules, SLO burn, silences, alert history and acknowledgements, response, schedules, routing |
| Saved work and export | `/dashboards`, `/search/saved`, `/export` | Dashboards and widgets, saved searches, background exports |
| Organization and access | `/projects`, `/account`, `/invitations`, `/settings`, `/usage`, `/users/me` | Projects, members, API keys, configuration, usage, contact methods |
| Integrations and delivery | `/connections`, `/notifications`, `/tickets`, `/issues` | Provider authorization, delivery channels and their message templates, Issue ticket links |

## Connections and provider actions

Connection create and patch requests may set customer-facing configuration, but provider-routing
identity and outbound API destinations are server-managed. Requests containing `base_url`,
`installation_id`, or `team_id` in connection metadata return `400`; use the provider's verified
connect or reauthorization flow to establish those values. A nested identity-mapping delete is also
parent-exact: `DELETE /connections/{connectionId}/identity-mappings/{mappingId}` returns `404` when
the mapping does not belong to that connection.

Slack buttons and modal submissions use the mapped member's permissions at click time. Removing
`alerts:acknowledge` or `errors:write` takes effect for later clicks even when the Slack identity
mapping still exists. A transient failure does not consume the action: the callback returns a
retryable `503`, and a repeated signed delivery may try the action again. Once the action succeeds,
the same delivery is acknowledged without applying it twice. During a rate-limit dependency outage,
both `/webhooks/{provider}` and `/slack/interactivity` fail closed with `503`.

## Groups

`GET /groups/{group_type}/{group_key}?project_id=` combines the saved group profile with activity
statistics. If those statistics cannot be measured, the request returns a temporary error; it never
substitutes `event_count: 0` or empty first/last-seen values. A successful zero therefore means the
group was measured and had no activity.

## Anomaly findings

`GET /anomalies` returns findings newest first. `sort=actionability` returns the same findings in a
different order: the ones a ranking model judged most likely to be real faults first, and findings it
has not ranked last. `sort=detected_at` is the default and restates it explicitly. Any other value is
rejected with `400`. Neither order filters — the set of findings a request returns does not depend on
`sort`.

A ranked finding carries two extra fields. `likely_real` is `true` when the ranking is confident the
finding is a real fault, and `actionability_score` is where it placed the finding on a 0–3 scale.
`likely_real: false` means the finding was not promoted; it never means the finding is noise, and a
finding that has not been ranked at all reports `false` for the same reason.

An unranked finding omits `actionability_score` entirely rather than reporting `0` — a zero would be
indistinguishable from a finding that was measured and placed at the bottom. When the reason a
finding could not be ranked is known, `actionability_unavailable_reason` carries it: `no_provider`
(ranking is not configured for the organization), `budget_exhausted` (the plan's daily ranking cap is
spent — see [limits](/docs/reference/limits)), or one of several transient values meaning the ranking
service could not answer. The field is absent when a finding is simply waiting to be ranked.

Ranking is an aid to ordering, not a filter or a lifecycle state. Acknowledge and resolve behave
identically for ranked and unranked findings.

## Project configuration

`POST /projects` and `PATCH /projects/{id}` distinguish an omitted `sampling_rate` from an explicit
zero. Omit it to use the create default (`1.0`) or preserve an existing project value. Send `0` to
disable ingestion for that project.

## Tickets

`/issues/{group_id}/tickets` lists or creates ticket links for an Issue; it is not the Issue-list
surface. Use `/errors/groups` and `/errors/groups/{group_id}` for Issue triage and detail.

Organization-level ticket creation requires `project_id`; every caller supplies an absolute
`issue_url` and an `Idempotency-Key` header (or `idempotency_key` body field). The key is scoped to
one project — the same key in another project is a separate create. Reusing the key with
the same request returns the existing provider ticket link; reusing it with a different request is
rejected. When the provider accepts the request but returns no usable answer, the response is a
retryable `503` and the create is briefly held while the provider's issue search catches up, so a
retry inside that window reports that creation is already in progress rather than filing a second
ticket. Its error details carry `reason=PROVIDER_EFFECT_UNRECORDED`; keep retrying the unchanged
request with the same key. A create that already reached the
tracker stays bound to the connection and destination it reached, so retries go there rather than to
whichever connection is active now; if that destination is gone or repointed, the retry is refused
with `400` naming the destination it is waiting for, instead of filing elsewhere. That refusal does
not clear on its own — restore the connection to that target and retry with the same key. A provider
4xx that definitively rejects creation is also a non-retryable `400`; repair the provider
authorization, permissions or target before sending the request again. Provider timeouts,
throttling and 5xx responses remain retryable. `GET /tickets/{id}` and Issue ticket
lists expose truthful sync state and safe retry errors. Every ticket operation resolves to one
explicit project. Organization-level credentials must send one consistent `project_id`;
project-scoped credentials may omit it or repeat their exact signed project. A different non-empty
query value returns the shared `403 permission_denied` before routing. A conflicting create-body
value, when the query is omitted or exact, retains the same generic `404` for
real, foreign, and unknown projects before ticket creation is attempted. Jira/Linear
OAuth start accepts non-secret `metadata`. Reauthorization also sends the exact existing
`connection_id`; the callback refreshes that `needs_reauth` row with an optimistic version check
rather than creating a duplicate. The callback is `/api/v1/connections/oauth/callback` and returns
to `/configure/connections`.

Connection metadata accepts only the documented fields for the selected provider. Custom outbound
`base_url` values must be public HTTP(S) destinations: URLs with embedded credentials and addresses
that resolve to local, private, link-local, multicast, or cloud-metadata networks are rejected.
Provider routing identities are set only by a verified provider callback and cannot be replaced by
a create or update request. A connection revoked by an organization administrator cannot be
reactivated with ordinary connection-write access. If a first Jira ticket request discovers setup
metadata while an administrator edits the same connection, the newer edit wins and the request can
be retried; discovery never overwrites that concurrent change.

## Customer Detective

`POST /investigations/{id}/cancel` stops the running answer and settles the investigation in the
canceled state. It returns the settled investigation and a `last_event_cursor`. Retrying the same
cancel returns the same settled result; an investigation that already finished or already failed
cannot be canceled and is refused with `400`. A live start/continue stream ends with the
`investigation_cancelled` terminal outcome after cancellation, not a generic internal error.

`GET /investigations/{id}/watch` follows one investigation's progress as a Server-Sent Events stream
of lifecycle updates — turn started, stage changed, turn completed, turn failed, canceled — plus a
`caught_up` checkpoint that reports the status and whether it is final. It never re-sends answer
text; read the current answer with `GET /investigations/{id}`.

Every progress frame carries an opaque cursor as its SSE event id. To resume, send that value back
in the `Last-Event-ID` request header (a browser `EventSource` does this automatically on reconnect)
or in the `after_cursor` query parameter; when both are present and disagree, the header wins.
Resumption is exclusive — you receive only what happened after that position. Cursors are opaque:
preserve them unchanged and never construct one. A cursor that is malformed, issued for a different
investigation, or no longer available is refused with `400` before any frame is sent, so a refusal
is always an HTTP status and never a stream that quietly starts in the wrong place. Recover by
reading `GET /investigations/{id}` and resuming from the `last_event_cursor` it returns.

Every newly produced conclusion includes `evidenceDisclosure`, so software does not have to parse
the answer's Markdown to learn what the model did not examine. Its `omissions` array contains closed
`source` and `reason` enum values. A present disclosure with an empty array means disclosure was
evaluated and nothing was omitted; a missing disclosure means the producer did not populate this
contract, so do not interpret it as success.

An omission means at least some evidence in that source family was not examined; other evidence
from the same family may still be cited. Reasons are
`EVIDENCE_OMISSION_REASON_SCOPE`, `EVIDENCE_OMISSION_REASON_POLICY`,
`EVIDENCE_OMISSION_REASON_UNAVAILABLE`, and `EVIDENCE_OMISSION_REASON_LIMIT`. Sources are the closed
families `ERRORS`, `RELEASES`, `TRACES`, `LOGS`, `CROSS_SIGNAL_SEARCH`, `PERSONS`, `ACCOUNTS`,
`LLM_SPEND`, `INCIDENTS`, `SESSION_RECORDINGS`, `AGENT_RUNS`, `AGENT_RUN_STEPS`, `SESSION_CONTENT`,
`AGENT_TRANSCRIPT`, `PERSON_PROFILE`, `ACCOUNT_PROFILE`, `INCIDENT_TIMELINE_CONTENT`, and
`INVESTIGATION_CONCLUSION`, each with the `EVIDENCE_SOURCE_` prefix on the wire. The human-readable
sections remain in `markdown` and are generated from the same omissions.

Telemetry deletion is project-exact and durable — not a display filter — on every signal that has it:
`DELETE /traces/{trace_id}`, `DELETE /logs/entry`, `DELETE /metrics/{metric_name}`,
`DELETE /errors/groups/{group_id}`, and `DELETE /persons/{person_id}/errors`. Each needs `project_id`
as a coordinate rather than a filter — an organization-level credential must supply it and no route
falls back to an organization-wide delete — plus the matching `*:delete` scope. The last two require
`errors:delete`; the person route accepts a person ID or a distinct ID and resolves it to that
person's full identity set before erasing, returning `person_id` and the `distinct_ids` it acted on
so the reach of the erasure is visible rather than assumed. Deleting an Issue or a person's errors
also suppresses matching occurrences that arrive later, and cannot be undone. Erasing a person's
errors does not touch their traces, logs, metrics, events, or recordings; there is no single call
that removes a person from every signal.

A successful delete means the suppression is **durable and already in effect**: from that response
onward every read of the target is empty, including reads of copies still in flight when you called.
It does not mean the underlying storage has finished being reclaimed — that happens in the
background, and it is not something a read can observe. This is why the call answers promptly and
does not hold while a large deletion is processed. Repeating a delete is safe and returns the same
answer; it never reveals whether anything matched.

Telemetry ingestion uses the separate `https://api.anectico.com` host with OTLP
`/v1/traces`, `/v1/logs`, and `/v1/metrics` plus diagnostic-event
`/api/v1/capture`; it is documented separately because it does not follow normal resource CRUD.

Platform-operator `/admin` routes and provider callback/webhook routes are not customer integration
surfaces and are intentionally excluded from this catalog.

## Trace and unified search

Exact trace reads by ID do not add a hidden recent-time window; they search the retained data in the
selected project. Detail responses carry `partial`, `returned_span_count`, and `total_span_count`.
Both span-count fields are emitted as JSON numbers.
When `partial` is true, the server returns the bounded span prefix but withholds whole-trace timing,
statistics, and critical-path answers rather than computing them from incomplete data. Agent-run
detail uses the same three completeness fields for its enriched span list.

Trace aggregates require the complete input and return HTTP 429 `ResourceExhausted` when the 100,000-span
window limit is exceeded. Narrow the time range and retry. Trace and service-map critical-path
analysis also returns HTTP 429 `ResourceExhausted` if its graph requires more than 100,000 path visits;
no partially searched path is presented as the answer. Raw trace spans remain available through
the span endpoint. Matching trace/span IDs in different projects are separate identities.

`POST /traces/search` evaluates trace-level filters before cutting the requested page. This
includes minimum and maximum duration and both single- and multi-value service filters. Duration
is the wall clock from the trace's earliest span start to its latest span end; service matching is
an OR across the trace's complete service set. Both are evaluated over all spans for that trace in
the requested organization, time window, and project, even when another filter selected the trace
through one particular service, environment, or identity.

Follow a search result with the same window when opening its detail routes:
`GET /traces/{trace_id}`, `GET /traces/{trace_id}/spans`, `GET /traces/{trace_id}/stats`, and
`GET /traces/{trace_id}/critical-path` all accept `project_id`, `start_time`, and `end_time`.
Omitting the times searches all retained data; supplying them reuses the narrower historical
interval that produced the search result.

`POST /search/unified` fans a query out across the selected signal families. Its trace leg uses one
case-insensitive union: trace ID, service name, operation name, `distinct_id`, or any span attribute
value. A match in any one qualifies the trace before the per-source limit is applied, including an
attribute-value match in an older trace. Attribute keys are not part of this free-text union; use
the trace-search `attributes` object when an exact key/value pair is required.

Every field in that union is matched **per span**, and one matching span qualifies the whole trace.
`distinct_id` is the one worth stating: a trace whose root span carries one person and whose child
span carries another is returned for either, and the hit's own fields still describe the root span.
Leading and trailing whitespace is stripped from the query before any of this, so a padded term
matches exactly what the trimmed one does, and a query that is only whitespace applies no free-text
filter at all rather than matching nothing.

`GET /metrics/names` returns at most 1,000 names. When more exist, `truncated` is true and
`total_count` is `0` (unknown); when `truncated` is false, `total_count` is exact. Do not interpret
the returned page length as the full metric-name cardinality when `truncated` is true.

Affected-customer search is available at
`GET /errors/groups/{group_id}/affected?project_id=&search=`. The `distinct_id` half is evaluated
before the 1,000-identity cap, so an older matching identity is prioritized into the candidate set.
If an alias matched, that matching alias is returned as the row's `distinct_id`; when only the
profile label matched, the match is visible in `display_name` and `distinct_id` remains the ordinary
representative. Display-name matching happens only after permission-gated profile hydration and is
therefore bounded by the 1,000-identity candidate page. `totals_are_capped: true` means the people,
account, and pagination totals are floors over that bounded set and must be rendered with a `+`.

## Log patterns

`GET /api/v1/logs/patterns` clusters a window's log messages into normalized templates and returns
them largest first. Normalization replaces the variable parts of a message with placeholders —
`<UUID>`, `<IP>`, `<HEX>`, `<NUM>`, `<FLOAT>` — so one template stands for every line that shares
its shape. It takes `logs:read`.

Query parameters: `project_id`, `start_time` and `end_time` (RFC3339; omitting both searches all
retained data), `service_name`, `level`, `min_count` and `limit`. `min_count` is the smallest
cluster worth returning and is clamped to 1–1000, default 10; `limit` is clamped to 1–100, default
20. A value outside a range is clamped rather than refused; a value that is not an integer is
ignored and the default applies.

The response carries `patterns`, `total_patterns`, `total_logs` (the records scanned in the window,
which is what the per-cluster `percentage` is of), `min_count` echoing what was applied, and
`retrieved_at`. Counts are JSON strings, as everywhere else in this API.

Each entry in `patterns` has `template`, `count`, `percentage`, `service_name`, `level`, `sample`
(one representative raw message), `first_seen` and `last_seen`, plus four **label** fields:

| Field | Meaning |
| --- | --- |
| `category` | One of `request`, `auth`, `database`, `cache`, `queue`, `external_call`, `config`, `lifecycle`, `job`, `security`, `client_error`, `other`. Absent unless the label was confident enough to be worth having. |
| `severity_score` | `0`–`3`, where 0 is ordinary traffic and 3 is an outage. Absent on an unlabeled template. |
| `needs_attention` | `true` when `severity_score` cleared the "degraded or worse" bar. Always present. |
| `label_unavailable_reason` | Why the last labeling attempt produced no label, for example `no_provider` or `budget_exhausted`. Absent both once labeled and before the first attempt. |

**Labels are produced after a read.** A template returned for the first time carries none of the
four and picks its label up on a later read, so treat every one of them as optional and absent
rather than as a field that is merely empty.

Two readings the fields do not support. `needs_attention: false` means "not flagged", never
"routine": an unlabeled template is `false` too, so the flag may promote a template and must never
demote one. And a template with no `category` and no `label_unavailable_reason` has not been
labeled yet — it is not a template that was examined and found uninteresting. Ordering is by
`count` descending regardless of labels; nothing is filtered, hidden or reordered by them.

## Replay and group read boundaries

Replay reads are project-scoped even when two projects reuse the same session id. A snapshot
response sets `has_gaps: true` if playback is incomplete, including when the recording exceeds the
1,000-chunk reconstruction cap. Treat its events as a usable partial stream, not as proof that every
recorded chunk was returned. Chunk upload times are bounded by receipt time, and a reversed interval
is normalized to a non-negative interval; client clocks cannot extend server-side retention.

Group property policy applies to filtering as well as returned fields. If access to group properties
is denied, `GET /groups?search=` still searches visible identifiers but does not match a hidden
property such as an account name. `DELETE /groups/{group_type}/{group_key}` removes the current group
from subsequent account-property reads after its dependency check succeeds; recreating that key
creates a new current version.

## Event and flag reads

Event discovery and triggerer reads are deterministic, project-scoped pages:

- `GET /events/names?project_id=&prefix=&property_key=&property_value=&limit=&cursor=` returns
  `events` plus `pagination`. `limit` is 1–50 when present.
- `GET /events/triggerers?project_id=&event=&since=&until=&property_key=&property_value=&limit=&cursor=`
  requires exact RFC 3339 `since` and `until` timestamps and returns canonical person IDs plus the
  recognizable distinct ID, with `pagination`. `limit` is 1–1,000 when present.

Trend measurements use `POST /analytics/query` with a typed definition and execution key;
see [Product analytics](/docs/investigate/product-analytics) for frozen results and participants.


Malformed or duplicate `limit`, `cursor`, `since`, or `until` parameters return `400` before a
query runs. Cursors are opaque and bound to the exact project and filters; preserve them unchanged.
A project-scoped principal may omit `project_id` or repeat its exact project; a conflicting explicit
query value returns `403 permission_denied` before an analytics query runs.

`POST /decide` evaluates flags remotely. `GET /flags/local-evaluation?project_id=` downloads the
equivalent local-evaluation snapshot. The snapshot body includes the full flag definitions plus
`schema_version`, `snapshot_version`, `updated_at`, `max_age_seconds`, `etag`, and `project_id`.
Its HTTP response also sends `ETag: "<etag>"` and `Cache-Control: private,max-age=<max_age_seconds>`.
Send that exact quoted value as `If-None-Match`; an unchanged snapshot returns `304` with no body.
Validators are bound to their project and must never be reused after switching projects.

## Saved searches

Saved searches are isolated to one project. Project-scoped credentials are pinned to their signed
project and may omit `project_id` or repeat that exact value. A different non-empty query value,
including one hidden in a repeated parameter, returns the shared `403 permission_denied` before
routing. A conflicting mutation-body value with an omitted or exact query retains the Saved Search
contract's same generic `404` for a real sibling project and an unknown project before the service is
called. Organization-level
credentials must send one consistent `project_id`. Within that project, list/get show the caller's
private searches plus searches shared by another owner. A hidden private search, an unknown ID, and
an ID copied from another project all return the same `404`.

- `GET /search/saved?project_id=&signal=&limit=&cursor=` lists with an opaque, scope-bound cursor.
- `GET /search/saved/{id}?project_id=` returns one search and its `version`.
- `POST /search/saved` creates a search; the JSON body requires `project_id` and
  `idempotency_key` for organization-level callers.
- `PATCH /search/saved/{id}?project_id=` requires `expected_version` in the JSON body.
- `POST /search/saved/{id}/duplicate?project_id=` requires `expected_version` and
  `idempotency_key`; the new copy is private to the caller.
- `DELETE /search/saved/{id}?project_id=&expected_version=` performs the owner-only delete.

Create and duplicate idempotency is durable. An exact retry replays the original response, while
reusing a key with different arguments returns `409`. Stale update/delete/duplicate versions also
return `409` without changing data. A malformed or wrong-scope non-empty cursor returns `400` and
never restarts at page one.

## Durable exports

- `POST /api/v1/export?project_id=` creates a CSV, JSON, or Parquet job. Send a stable `idempotency_key` in the
  JSON body or `Idempotency-Key` header. Keys are 1–128 canonical ASCII letters, digits, `.`, `_`,
  `:`, or `-`; whitespace, control characters, and Unicode lookalikes are rejected instead of
  normalized. The opaque query must name a supported signal and may carry RFC 3339 or integral
  Unix-second `start_time`/`end_time` values.
- `POST /api/v1/export/csv?project_id=` and `POST /api/v1/export/json?project_id=` are
  format-forcing convenience forms of the generic endpoint. Their request body and idempotency
  rules are otherwise the same; any body `type` value is ignored in favor of the path format.
- `POST /api/v1/logs/export?project_id=` is the logs convenience endpoint. Its optional `format` is `csv`,
  `json`, or `parquet`, and its time bounds are strict RFC 3339 values.
- `GET /api/v1/export/jobs?project_id=&status=&type=&limit=&cursor=` returns newest-first signed keyset pages.
  Preserve a non-empty cursor unchanged with the same project and filters.
- `GET /api/v1/export/jobs/{id}?project_id=` polls lifecycle, monotonic progress, attempt count, and
  expiry. It also carries `content_policy_reason` and `content_policy_withheld_classes`: the project
  content policy's answer for this export, empty until the job runs, then `allowed`, `policy`,
  `no_policy` or `resolver_error`. See
  [Save searches and export data](/docs/manage/save-and-export-data).
- `DELETE /api/v1/export/jobs/{id}?project_id=` atomically cancels a pending, running, or retrying job.
- `POST /api/v1/export/jobs/{id}/retry?project_id=` re-enqueues a failed job.
- `GET /api/v1/export/jobs/{id}/download?project_id=` resolves where an unexpired completed result can
  be fetched. It returns a `url` on this API pointing at the route below, not a storage link: the
  address confers nothing on its own and is authorized afresh on every use. Result objects expire and
  are deleted 24 hours after completion.
- `GET /api/v1/export/jobs/{id}/content?project_id=` returns the result file itself, streamed. It
  honours `Range` (answering `206 Partial Content`) and sends the export's filename as an
  attachment. Authorization is re-checked continuously while the file transfers, so a permission
  removed mid-download ends that transfer as well as the next one.

Every export job `{id}` is the exact lowercase canonical hyphenated UUID returned at creation
(`8-4-4-4-12`, 36 characters). Uppercase, compact, braced, URN, control/Unicode-contaminated, and
oversized forms return `400` before a database lookup. A well-formed unknown or sibling-project UUID
returns the same generic `404`, so UUID validation does not reveal whether a job exists.

Creation requires `export:write` and the read scope of every signal the export's rows come from — a
unified-search export carries trace and log rows and therefore needs `traces:read` and `logs:read`
alongside `search:read`. Read/list requires `export:read`. Retrying and downloading require, in
addition to `export:write` / owner-admin `export:download`, that the caller **still holds** those
source read scopes at the moment of the request: an export does not stay reachable on the strength of
the permissions its creator had when it was made. Downloading is further limited to the credential
that created the job, or to an organization owner/admin; another credential in the same project sees
the same generic `404` as an unknown job. Every customer request
resolves to one verified project in the authenticated organization. Project-scoped credentials
may omit `project_id` or repeat their exact signed project. A different non-empty URL-query value
returns the shared `403 permission_denied` before routing. With an omitted or exact URL query, a
conflicting top-level create value, nested export query, or logs-export body retains Export's same
generic `404` without checking whether that project exists or calling Export. Organization-level
credentials retain explicit project selection. Sibling-project job IDs return the same `404` as
unknown IDs for read, cancel, retry, download, and content. Unknown
formats, malformed or reversed times, tampered or foreign cursors, and expired downloads fail
closed.

## Capture acknowledgements

Before dispatching a structurally valid capture batch, the service records durable
attempt evidence. If that cannot be recorded, it returns `DEPENDENCY_UNAVAILABLE`
with HTTP 503 and no events are dispatched; retry with backoff and original message
IDs. Queue acceptance still does not establish query visibility or complete source
coverage. Request-level counts describe attempts, including retries.
After capture processes a project or organization deletion, later capture for that
scope is refused with `FORBIDDEN` (HTTP 403), without dispatch or a retry instruction.
Deletion propagation and physical cleanup are asynchronous.

`POST /api/v1/capture` returns a version 1 acknowledgement. HTTP 200 means a batch
ledger was returned; inspect every item before treating it as delivered. The
`batch.received` count includes all original inputs, including invalid ones.
Each zero-based index appears once, and `received = queued + rejected + unknown`.

```json
{"version":1,"request_id":"d561b395-f87f-4e8b-bf54-6c077551ab21","batch":{"received":1,"queued":1,"rejected":0,"unknown":0,"items":[{"index":0,"delivery":"CAPTURE_DELIVERY_QUEUED","code":"CAPTURE_FAILURE_CODE_UNSPECIFIED","retry":"CAPTURE_RETRY_NONE"}]}}
```

| Item delivery | Meaning and action |
| --- | --- |
| `CAPTURE_DELIVERY_QUEUED` | The queue publisher acknowledged this attempt. Do not retry it. This does not prove storage/query visibility or a completed identity update. |
| `CAPTURE_DELIVERY_REJECTED` | `INVALID_EVENT` or `QUOTA_EXCEEDED` refused the item before publication. Fix the input or quota condition; do not retry unchanged in a background loop. |
| `CAPTURE_DELIVERY_UNKNOWN` | `PUBLISH_UNCERTAIN` means some or all attempted records may already be queued. Back off and retry only this item with its exact original message ID, timestamp, identity and payload. |

Failure codes use the `CAPTURE_FAILURE_CODE_` prefix. Retry values use
`CAPTURE_RETRY_`: `NONE`, `AFTER_CHANGE`, `SAME_MESSAGE_ID`, or
`UNSAFE_WITHOUT_MESSAGE_ID`. The last value means the original event lacked a
message ID; assigning one now cannot deduplicate the uncertain attempt. Assign
message IDs and timestamps before first delivery. A malformed/unrecognized
response or a lost HTTP response also leaves delivery uncertain; a 2xx alone
must never cause a client to discard events.

Failures before dispatch use a `failure` object containing `code` and `retry`
instead of `batch`: invalid input is HTTP 400, invalid credentials 401, denied
permission 403 where distinguished, oversized input 413, rate limiting 429,
and unavailable dependencies 503. The last two use `CAPTURE_RETRY_BACKOFF` and
`Retry-After`. These failures never claim any event was dispatched. Authentication
paths may intentionally treat insufficient credentials as 401.

In quota `degrade` state, ordinary product events are explicitly refused when
pooled-event usage reaches 125% of the limit. They are not independently sampled.
Valid `identify` and `$groupidentify` controls remain exempt. This enforcement is
best-effort; unavailable quota state retains fail-open admission. A successful
ledger cannot establish complete instrumentation or quantify unique loss across
retries.

The endpoint and Go/JavaScript/Python/iOS/Android adapters (including bundled native bridge sources) are implemented in the current pre-production tree.
The coordinated cutover still requires complete package/runtime qualification; this is not
a claim that published SDK packages implement this response version.

## Direct customer-property updates

SDK `identify()` is the usual profile-update surface. A CRM or batch importer that
needs explicit source times, initialization, or deletion can send the complete raw
capture wire with an `analytics:write` key:

```json
{
  "events": [
    {
      "event": "crm_profile_updated",
      "distinct_id": "user_8842",
      "timestamp": "2026-07-30T12:30:00Z",
      "message_id": "crm-contact-8842-v17",
      "properties": {"source": "crm", "revision": 17},
      "set": {"plan": "enterprise", "locale": null},
      "set_once": {"signup_source": "web"},
      "unset": ["legacy_region"]
    }
  ]
}
```

`properties` belongs to the named product event and appears in its customer
timeline payload. The top-level `set`, `set_once`, and `unset` fields mutate the
canonical customer:

- `set` stores literal JSON values. `null` is a present value; it does not delete.
- `unset` is the only deletion operation.
- For `set`/`unset`, the greatest `(timestamp, message_id)` wins independently per
  key. A lexicographically greater message ID breaks an equal-time tie.
- `set_once` is deterministic initialization. The earliest candidate wins until
  any `set`/`unset` exists; an authoritative mutation always supersedes it.

The endpoint defaults an omitted timestamp to receive time and supplies an
internal event ID when `message_id` is absent, but importers should provide both
so retries and out-of-order delivery converge predictably. A key may appear in
only one mutation field in an event. Empty or duplicate names and more than 250
combined mutation keys return a per-event validation error. `unset` is rejected
on `$groupidentify`; v1 account properties do not support deletion.

Before an accepted event is published, Anectico recursively replaces detectable
PII and credentials in `properties`, `set`, and `set_once` string leaves with
`[REDACTED]`, including values inside nested arrays and objects. A field whose
separator-delimited or camel-case name ends in a credential concept (for
example, `initial_password`, `customer.api-token`, or `privateKey`) has its
complete value replaced with one placeholder, including numeric, boolean, null,
object, and array values. Substring near-misses such as `tokenizer`,
`token_count`, `passwordless`, and `public_key` remain available.

Run, unit-ID, and correlation properties preserve calendar-valid compact timestamp tokens
such as `20260804-195200`, even when those digits coincidentally satisfy a card
checksum. The exception is token-specific and key-aware: actual card numbers,
emails, and credentials in the same value are still redacted, ordinary free-text
properties do not receive the exception, and credential-classified properties
are always replaced as a whole.

Stable identity and correlation keys (`distinct_id`, `anon_distinct_id`,
`session_id`, `message_id`, `group_type`, `group_key`, and the exact top-level
`properties.$groups` map) are not rewritten; they must instead be printable and
at most 200 bytes. A nested `$groups` field receives the normal redaction policy.
Property keys are at most 512 bytes, string leaves are at most 32,768 bytes, maps
and arrays contain at most 250 entries, and nesting is at most 16 levels. Control
characters are rejected throughout. Event names and property keys whose text
itself contains detectable sensitive material are rejected rather than renamed.
Validation remains best-effort per batch: valid siblings are sanitized and
queued while each invalid event receives an indexed refusal, and error/log paths
never echo the rejected property content.

The reserved `identify` event performs the same canonical-person mutation and
alias linking but remains a rowless control event. Use an ordinary named event,
as above, when the source payload must also be visible in the customer timeline.

## Agent inventory and evidence

Every endpoint below requires a project, and the `agents:read` scope except where a section says
otherwise — the two registration endpoints require `agents:write` instead. They answer five
questions about the agents running against your systems.

### Fleet health

```http
GET /api/v1/fleet?project_id={projectId}&limit=100&cursor={nextCursor}
```

This is the server-owned Fleet view: agents, workflows and A2A peers ordered by 24-hour run count,
then name and asset id. Each row carries declared/discovered status, environment, last sighting,
run and non-completed-run counts, last run, open review-signal count, and optional live-containment
metadata. Runs are attributed by stable observed identity rather than display name, so same-named
agents remain separate. Each row's `agent.observation_keys` is the sorted set of immutable observed
identities currently attributed to that asset. Use those values for run drill-through; `name` is a
display label and is not a unique selector.

The first page fixes `window_start` and `window_end`; every opaque continuation preserves that
activity window. `limit` defaults to 100 and may be at most 200. `has_more` is derived from the same
ordered population as the returned rows, and `next_cursor` is present only when it is true.
`total_agents`, `total_shadow_agents`, and the run totals describe the complete measured population,
not only the current page. `unmatched_runs` and `unnamed_runs` report activity that could not be
assigned rather than silently dropping it.

A successful response with `total_agents: 0` means the fleet was measured and is empty. If an
inventory or activity source cannot be measured, the endpoint returns `503`; it never substitutes a
healthy-looking empty page. The endpoint requires `agents:read`. Live-containment metadata is read
only with `quarantine:request` or `quarantine:approve`; otherwise
`containment_visibility` is `FLEET_CONTAINMENT_VISIBILITY_WITHHELD`, not "none contained". A fleet
request does not accept an environment filter because observed identity already includes the
environment boundary.

### Outbound A2A drive

These three authenticated endpoints provide the explicit one-message outbound control plane:

```http
PUT  /api/v1/assets/{assetId}/driver
POST /api/v1/assets/{assetId}/drive
GET  /api/v1/agent-drive-attempts/{attemptId}?project_id={projectId}
```

The two writes require `agents:write`; the exact attempt read requires `agents:read`. `PUT` accepts
`scope.project_id`, `endpoint`, `connection_id`, and `enabled`. Enabling atomically binds an absolute
HTTPS endpoint on that agent asset to an active saved A2A bearer credential. Disabling sends
nothing and leaves the pair off.

`POST .../drive` accepts `scope.project_id`, a required `message_text` of at most 65,536 UTF-8 bytes,
and optional A2A `context_id` and `task_id` of at most 1,024 UTF-8 bytes each. It has no endpoint field
and rejects unknown fields. The endpoint and selected credential are held stable for the call;
redirects are not followed. Calls have a 15-second deadline, a 1 MiB response limit, a 64 KiB limit
on the complete JSON-encoded A2A request, and a per-project limit of 10 per minute.

The response carries the SDK-decoded A2A `reply_json` bytes on success (base64 in the REST JSON
representation) and an attempt record in every expected outcome. Not enabled, missing credential,
rate limited, invalid target, encoded request too large, agent protocol error, and unreachable are
distinct outcome enum values. A reflected saved bearer is discarded as an agent error with no reply
bytes or digest. A failure to read or record a prerequisite
is an unavailable API response, never one of those refusals. The exact `GET` returns actor, asset,
resolved endpoint, outcome, content digests, and timestamps; it returns neither message content nor
credential material. See [Drive one registered agent](/docs/agents/drive-an-agent) for the complete
workflow and response table.

### Recorded-person session replay

These endpoints replay a real recorded session against an enabled agent and read its durable,
turn-by-turn comparison:

```http
POST /api/v1/assets/{assetId}/session-replays
GET  /api/v1/agent-session-replays/{replayId}?project_id={projectId}
```

The `POST` requires `agents:write` and `agents:content:read`. It accepts `scope.project_id`,
`session_key`, and `confirm_real_person_content_export`, which must be `true` on every request. It
has no destination, persona, prompt override, or turn-limit field. The recording is limited to eight
turns, and each project may start three replays per hour. Turns also consume the outbound drive
limit. A recording that cannot be replayed exactly is stopped rather than shortened or rewritten;
this includes fragmented content, conflicting identities, a final run not explicitly recorded as
completed, and an absent or ambiguous final response.

The result reports the source session, canonical person, target asset, every recorded turn, original
and replayed response text, exact comparison details, and total/attempted/sent/compared counts.
`AGENT_SESSION_REPLAY_STATUS_COMPLETED` means every turn was sent and compared;
`AGENT_SESSION_REPLAY_STATUS_STOPPED` and `AGENT_SESSION_REPLAY_STATUS_REFUSED` always carry a
reason. Target containment and export/storage decisions bind before the first replay-progress
record; they are checked again before each send. The person's identity links the result but is not
sent to the target agent. A result-recording failure is returned as
`AGENT_SESSION_REPLAY_STATUS_STOPPED` with a stage-specific `persistence_*_failed` reason, not as a
generic availability response.

The `GET` requires `agents:read`. Message content additionally requires `agents:content:read` and
the current read policy. When content is withheld, the status, counters, comparison kinds, digests,
and sizes remain visible. See [Replay a real recorded session](/docs/agents/replay-a-recorded-session)
for the workflow and result semantics.

### Assets — declared and discovered agents

#### What agents exist, and which ones did you declare?

```http
GET /api/v1/assets?kind=agent&shadow=true
GET /api/v1/assets?kind=tool&related_asset_id={agentAssetId}
GET /api/v1/assets/{assetId}
PUT /api/v1/assets/{assetId}/ownership
POST /api/v1/assets/{assetId}/reviews
PUT  /api/v1/assets/{assetId}/data-classification
```

`kind` accepts `agent`, `workflow`, `tool`, `model`, `skill`, `mcp_server`, `a2a_agent`,
`model_router`, `prompt`, `retrieval_corpus`, `memory_store`, `sandbox`, `gateway`,
`workload_identity` and `policy`.

`agent`, `workflow`, `tool` and `model` are the four kinds an inventory row can have: every asset is
recorded from telemetry, and telemetry records exactly those four. The other eleven names are valid
as component kinds in a signed bill of materials (`POST /api/v1/abom-manifests`), where you declare
what an agent depends on, but nothing records them into the inventory. Filtering the inventory on
one of them is therefore refused with `400` and a message naming the kind, rather than answered with
an empty page that could be mistaken for "none were seen".

`model` is the model that served a call — a provider plus a model name. `model_router` is a
different thing: whatever chose the model. The two are separate kinds on purpose, and a call whose
served model differs from the requested one records the served model without asserting a router.

An asset with `shadow: true` was created from telemetry rather than from a declaration you made —
something ran that you never registered. `shadow_evidence_ref` points at the observation that
created it. Omitting the `shadow` parameter returns both kinds; passing `false` returns only the ones
you declared. A shadow record's `name` is evidence of what a workload called itself, never an
identity you should authorize against.

`related_asset_id` narrows to the assets an observation linked to the one you name. It works in both
directions from a single parameter: an agent's id lists the workflows, tools and models that agent
uses; a tool's or model's id lists the agents using it. The link is recorded only where one span
named both sides, so an empty result means "nothing linked them in telemetry", never "unused". An id
that is not a UUID is rejected with `400`.

`kind`, `lifecycle_state`, `approval_state` and `risk_tier` may each be repeated or
comma-separated. Values inside one parameter are OR-ed and different parameters are AND-ed, so
`?kind=agent&approval_state=unreviewed` means "unreviewed agents". Omitting a parameter does not
narrow on that field, and an unrecognized value is rejected with `400` rather than quietly ignored —
so a misspelled filter never widens the page into the whole inventory without saying so. Ordering is
newest first by `last_seen_at`, paging is cursor-based, and the page limit defaults to 100 with a
maximum of 200.

Assign or clear the current owner with:

```http
PUT /api/v1/assets/{assetId}/ownership
Content-Type: application/json

{
  "scope": { "project_id": "project-id" },
  "owner_team": "platform",
  "owner_principal": "user:owner-42"
}
```

Both owner fields are replaced. An empty value clears that assignment, and both empty explicitly
leave the asset unassigned. The organization and asset ID come from the authenticated principal and
URL; values for either in the body cannot redirect the write.

Record or reopen an approval decision with:

```http
POST /api/v1/assets/{assetId}/reviews
Content-Type: application/json

{
  "scope": { "project_id": "project-id" },
  "approval_state": "APPROVAL_STATE_APPROVED",
  "risk_tier": "RISK_TIER_HIGH",
  "reason": "Matched the reviewed deployment and tool inventory"
}
```

`APPROVAL_STATE_UNREVIEWED`, `APPROVAL_STATE_APPROVED` and `APPROVAL_STATE_REJECTED` are accepted;
`APPROVAL_STATE_UNSPECIFIED` is rejected. `risk_tier` is required and is the reviewed blast radius:
`RISK_TIER_UNASSESSED`, `RISK_TIER_LOW`, `RISK_TIER_MEDIUM`, `RISK_TIER_HIGH` or
`RISK_TIER_CRITICAL`. `RISK_TIER_UNSPECIFIED` is rejected — send `RISK_TIER_UNASSESSED` to record
that the blast radius has not been assessed, which is also the value an asset carries until its
first review. The response returns the updated asset with `approver_principal`, `approval_reason`,
`reviewed_at` and `risk_tier`. The approver is derived from the authenticated credential and cannot
be supplied in the body, and a reason is required. Reviewing an asset changes its approval decision
and risk tier only: approving a quarantined asset does not activate or otherwise un-quarantine it.

This review is the only writer of `risk_tier`, so `?risk_tier=high` returns the assets a reviewer
has actually placed in that tier — never an inferred one.

Record what data an asset handles with:

```http
PUT /api/v1/assets/{assetId}/data-classification
Content-Type: application/json

{
  "scope": { "project_id": "project-id" },
  "data_classification": "DATA_CLASSIFICATION_RESTRICTED",
  "reason": "Reads the cardholder corpus"
}
```

`DATA_CLASSIFICATION_UNCLASSIFIED`, `DATA_CLASSIFICATION_PUBLIC`, `DATA_CLASSIFICATION_INTERNAL`,
`DATA_CLASSIFICATION_CONFIDENTIAL` and `DATA_CLASSIFICATION_RESTRICTED` are accepted;
`DATA_CLASSIFICATION_UNSPECIFIED` is rejected. Send `DATA_CLASSIFICATION_UNCLASSIFIED` to record
that you looked and found nothing to classify — that is a decision, and it is stored with your name
on it. It is also the value every asset carries until somebody classifies it, which is why the
response distinguishes the two: an asset nobody has examined reports
`data_classification: "DATA_CLASSIFICATION_UNCLASSIFIED"` with an empty `classified_by`.

The response returns the updated asset with `data_classification`, `classified_by`,
`classification_reason` and `classified_at`. The classifier is derived from the authenticated
credential and cannot be supplied in the body, and a reason is required. Classification is an
independent axis: it changes no approval state, risk tier, owner or lifecycle state, and reviewing
an asset does not change its classification.

All three writes require `agents:write`. Reads and filters continue to require `agents:read`.

These endpoints are scoped by project, not by environment: they take no `environment` parameter, and
supplying one is rejected rather than ignored. Each asset record still reports the `environment` it
was seen in.

#### What still needs somebody to look at it?

```http
GET /api/v1/review-signals?status=open
GET /api/v1/review-signals?asset_id=...
```

A review signal is the other half of a discovery. `GET /api/v1/assets?shadow=true` tells you an
undeclared agent is running; the review queue tells you whether anybody has dealt with it — the
`status` (`REVIEW_SIGNAL_STATUS_OPEN`, `REVIEW_SIGNAL_STATUS_ACKNOWLEDGED`,
`REVIEW_SIGNAL_STATUS_RESOLVED`, `REVIEW_SIGNAL_STATUS_DISMISSED`), who resolved it and when, and
`last_seen_at`, which moves forward while the thing is still active.

Each signal names its `kind` — `REVIEW_SIGNAL_KIND_SHADOW_ASSET` for something running that you
never declared, `REVIEW_SIGNAL_KIND_ABOM_DRIFT` for a deployment whose configuration in the field no
longer matches the manifest that attests it, `REVIEW_SIGNAL_KIND_CONFIG_UNREGISTERED` for an
attested deployment with no registered configuration at all, so nobody can tell whether it drifts,
`REVIEW_SIGNAL_KIND_KILLED_ASSET_CREDENTIAL` when telemetry from an API key resolves to an agent
under an active `kill` containment but that key was outside the containment's approved set,
`REVIEW_SIGNAL_KIND_POISONED_MEMORY_ITEM` when somebody has labelled a memory item `quarantined`,
`REVIEW_SIGNAL_KIND_UNREVIEWED_ASSET` / `REVIEW_SIGNAL_KIND_UNOWNED_ASSET` for an asset that has
stood in the inventory past the review window with no approval decision, or with neither an owner
team nor an owner principal, and `REVIEW_SIGNAL_KIND_REVOKED_AUTHORITY_EXERCISED` when a mediated
action evidenced a delegation edge that had already been revoked — plus a `severity`, a
`subject_ref` identifying what the signal is about, and a `detail_code`: a short lowercase code you
can group by, never a provider message. For the killed-credential kind, `subject_ref` is the
non-secret API-key id, `asset_id` is the agent, `evidence_ref` is the containment id, and
`opened_at` / `last_seen_at` are the source sighting times.

You do not have to watch this queue to hear about any of these: every kind's open and clear
transitions are sent to every enabled notification channel in the organization. You can also create
a rule with the `review_signals` source to target a project, kind, severity, or subject and choose
its notification or escalation path. Repeated sightings refresh one signal and do not send another
opening.

Every kind clears by itself once somebody deals with it, so a rule filtered on `status=open` stops
without anybody clearing the alert by hand. `shadow_asset` and `unreviewed_asset` clear when
`POST /api/v1/assets/{assetId}/reviews` records an approval or a rejection; `unowned_asset` clears
when `PUT /api/v1/assets/{assetId}/ownership` sets either owner field; a `poisoned_memory_item`
signal clears when the item is labelled `trusted` again; the drift kinds clear when a matching
configuration is registered; and a killed-credential signal clears when the key is revoked or the
containment lifted — it still never contains or revokes anything automatically.

`kind`, `status` and `severity` may each be repeated or comma-separated. Values inside one parameter
are OR-ed, and different parameters are AND-ed, so `?kind=shadow_asset&status=open` means "open
shadow-asset signals". Omitting a parameter does not narrow on that field; an unrecognized value is
rejected rather than quietly ignored, so a filter that does not apply is never silent. Ordering is
newest first by `last_seen_at`, paging is cursor-based, and the page limit defaults to 100 with a
maximum of 200 — the same rules as `/assets`.

A resolved signal stays in the queue as history. A later sighting of the same still-undeclared agent
refreshes an open signal but never reopens one somebody closed.

#### Which telemetry belongs to which agent?

```http
GET /api/v1/assets/attribution?observation_key=...&observation_key=...
GET /api/v1/assets/{assetId}/attribution
```

The first direction maps observation keys from your telemetry onto their current asset; the second
returns every observation key that currently maps to one asset. The response carries an
`availability` field, so a lookup that could not be answered is distinguishable from one that was
answered with no match.

Each mapping also carries `credentials`: which API key produced that telemetry, when it was first
and last seen doing so, and how many sightings carried it. This is what turns "quarantine this
agent" into something you can act on — the key named here is the one to revoke.

Each entry states its `source`, and the distinction is the point:

| `source` | What it means |
|---|---|
| `api_key` | An API key produced this telemetry, and `api_key_id` names it. This is the only entry a revocation can act on. |
| `bearer_token` | A signed-in session produced it. There is no key to revoke; the control is the person's access. |
| `dev_org_header` | A development-only trust setting accepted it, with no credential at all. Nothing can revoke it — if you see this outside development, that setting is on where it should not be. |

An **empty** `credentials` list is not "no credential was used". It means nothing was recorded for
that observation key — telemetry that arrived before this was tracked, for instance. Telemetry that
provably carried no key is a present entry naming `bearer_token` or `dev_org_header`, never an
absent one, so "we know there was none" and "we do not know" are never the same answer.

### Containing a misbehaving agent

#### How do I stop it?

```http
GET  /api/v1/quarantines
POST /api/v1/quarantines
GET  /api/v1/quarantines/{quarantineId}
POST /api/v1/quarantines/{quarantineId}/approve
POST /api/v1/quarantines/{quarantineId}/reenforce
POST /api/v1/quarantines/{quarantineId}/lift
POST /api/v1/quarantines/{quarantineId}/withdraw
```

`POST /quarantines` requires `quarantine:request` and asks for an asset, an observation key, or one
API key to be `freeze_writes` (the default), `revoke_credentials`, or `kill` — see
[Permission scopes](/docs/reference/permissions#organization-ai-and-agents) for what each one does.
Send exactly one of `asset_id`, `observation_key`, or `api_key_id`, plus a required `reason`; an
optional `environment` narrows the target and an optional `request_id` makes a retried request
idempotent instead of opening a second one. A request touches no credential by itself and answers
`201` with `status: "pending"`.

`POST /quarantines/{quarantineId}/approve` requires `quarantine:approve` and takes no body — the
target and action were fixed by the request, and an approver who could edit either would be
approving something other than what was reviewed. The platform refuses an approval made by the same
principal that made the request. Approving is what resolves the target to its exact credential set
and disables it, and answers `200`.

`POST /quarantines/{quarantineId}/reenforce` requires `quarantine:approve`, takes no body, and
re-runs the parts of an **active** containment recorded as `failed` — a credential that could not be
reached, or a mint ban that could not be installed. It approves nothing: the action, the target and
the credential set are the ones the two principals already agreed, and only rows marked `failed` are
re-attempted, because re-issuing a freeze over an already-frozen credential reports a weaker outcome
than the one already recorded. The response carries `retried`, the number of things re-attempted;
`0` means there was nothing left to repair and is a success. Use this rather than lifting: a lift
restores the credentials and re-opens the mint path, which for a `kill` re-opens the agent in order
to repair the thing meant to keep it shut. Anything that is not `active` answers `400`.

`POST /quarantines/{quarantineId}/lift` also requires `quarantine:approve`, takes a required
`reason`, and restores whatever a freeze reduced. It does **not** restore a revoked credential —
`revoke_credentials` and `kill` have no undo; mint a replacement key instead. Nor does it free a
credential another **active** quarantine still covers: that credential comes back on the last lift
that releases it, and until then it is reported with `detail_code` `held_by_another_quarantine`.
Two lifts issued at the same moment are safe — whichever of them ends up last still releases the
shared credential, so simultaneous lifts cannot leave an agent contained by nothing.

Both `lift` and `withdraw` return the reason they were given, as `lift_reason` and `withdraw_reason`
on the quarantine, alongside `lifted_by`/`lifted_at` and `withdrawn_by`/`withdrawn_at`. Each is
capped at 1024 characters.

`POST /quarantines/{quarantineId}/withdraw` retracts a request nobody has approved yet, takes a
required `reason`, and answers `200` with `status: "withdrawn"`. It reaches **no** credential —
there was nothing to reach — and its point is that the request stops occupying its target: while a
containment is pending, no second one can be raised against the same agent, and a request that is
never approved would otherwise block it indefinitely. It is refused with `400` on anything that is
not pending; an approved containment ends with a **lift**, which restores credentials.

The scope it needs depends on whose request it is. Retracting **your own** takes only
`quarantine:request`, because a pending request enforced nothing and retracting it restores
capability to nobody. Retracting **somebody else's** takes `quarantine:approve`: it suppresses a
containment before a second principal has considered it, which is the same grade of decision as
refusing one.

The withdrawn request keeps its `request_id`. Re-sending that id returns the withdrawn record rather
than opening a new containment — the same idempotency that protects a retry — so raising the request
again means choosing a new `request_id`.

Two refusals are worth knowing before you script this. A `request_id` already used by a different
principal answers `409` and tells you nothing about their request — pick another id. And an
`api_key_id` target that names no key **in your organization and in the containment's project**
answers `404` at **approval**, when the credential is checked, rather than producing a quarantine
that claims to have contained it. A key created with `org_wide` belongs to no project and stays
reachable from any of them.

`GET /quarantines/{quarantineId}` and `GET /quarantines` (repeatable `status`, plus `limit`) accept
either scope — a requester has to be able to see the state of their own ask. Both return the same
shape: `status` (`pending` / `active` / `lifted` / `withdrawn`), the per-credential outcomes under
`credentials`,
and `unenforceable_sightings` — telemetry sightings that carried no credential the platform could
act on, which is why a fully-approved quarantine can still report `unknown` rather than `verified`
for a resolved credential's own enforcement state.

See [Contain an agent](/docs/agents/contain-an-agent#contain-an-agent) for what each action
actually does to a credential — freezing stops a typical SDK key's telemetry, revoking has no
undo, and `kill`'s "no new credential ever again" reading is not something the platform enforces —
and for a worked request → approve → verify → lift walkthrough.

### Content policy

#### What may this project's recorded content be used for?

```http
GET /api/v1/content-policies
GET /api/v1/projects/{projectId}/content-policy
PUT /api/v1/projects/{projectId}/content-policy
```

A project's **content policy** is a grid: six kinds of recorded content —
`model_transcript`, `tool_arguments`, `replay_dom`, `person_properties`, `memory_item`,
`group_properties` — against
four boundaries — `store` (recorded at all), `read` (served to a reader), `export` (included in a
bulk export or sent to a customer-controlled task callback), `judge_transfer` (sent to an evaluation
model). Each cell is `allow`, `allow_metadata` (you may know the content exists and how large it is,
never its bytes) or `deny`.

The two `GET`s require `governance:read` and return **every** cell, denied ones included, so you can
render the whole grid without knowing the vocabulary in advance. `GET /content-policies` returns one
policy per project in the organization, newest-changed first, and accepts `limit` and an opaque
`cursor`; follow `next_cursor` until it is empty. A credential restricted to one project sees only
that project, including on organization-wide policy and audit listings.

A project-scoped credential is pinned to its signed project on every policy, retention,
refusal-audit, and erasure route, including routes whose project is written in the path. It cannot
select a sibling project by replacing `{projectId}`. The policy list is narrowed to that same
project; an organization-scoped credential retains the organization-wide list and explicit path
selection.

Every project has a policy, including one you have never opened, and a project with no policy at all
is answered as a fully-denied grid rather than a `404` — "no policy" and "denies everything" are the
same state. A policy carries a `version` that increases on every change, plus `updated_by`.

`PUT` requires `governance:write` and **replaces** the grid; it does not merge. Send the complete set
of cells you want to hold — a cell you omit, and a cell you send as `deny`, are the same thing.
Pass the `expected_version` you last read to have the write refused with `409` if somebody changed
the policy meanwhile; omit it to write unconditionally. An unrecognized class, boundary or decision
is refused with `400` rather than dropped, so a typo can never quietly remove a row.

```json
{
  "expected_version": 3,
  "rules": [
    { "content_class": "model_transcript", "sink": "read", "decision": "allow" },
    { "content_class": "model_transcript", "sink": "judge_transfer", "decision": "deny" }
  ]
}
```

A change takes up to 30 seconds to apply everywhere.

#### How long is each kind of recorded content kept?

```http
GET /api/v1/projects/{projectId}/content-policy/retention
PUT /api/v1/projects/{projectId}/content-policy/retention
```

A **retention window** is a per-kind number of days, using the same six `content_class` values as
the grid above. `GET` requires `governance:read`, `PUT` requires `governance:write` — the same two
permissions, because this is the same control along a different axis.

Unlike the grid, the `GET` returns **only the kinds that have a window**. A kind with none is
genuinely absent, and that means "kept for as long as the organization keeps it anyway". No default
number is synthesized, because a number here is an instruction to delete and you must never be
shown one you did not choose.

`retention_days` is at least `1` and at most `365` — the platform storage ceiling. Your organization’s
effective history may be shorter; this setting does not extend it. A window
only ever brings deletion FORWARD; a longer value is refused with `400` rather than stored as a
promise that cannot be kept. `0` is refused too: it is what an omitted field looks like, and it
must never be read as "keep nothing".

`PUT` **replaces** the whole set. A kind you omit has its window removed and returns to the
default — which is how you undo a tightening, and the reason this is not a `PATCH`. An empty
`retention` array clears every window. `set_by` and `set_at` are response-only and ignored on
input.

```json
{
  "retention": [
    { "content_class": "model_transcript", "retention_days": 7 },
    { "content_class": "replay_dom", "retention_days": 30 }
  ]
}
```

Shortening a window is the one change on this page that cannot be undone: widening a denied cell
brings content back, and widening a window brings nothing back. Every change is recorded with what
it was before, who made it and when.

A window that has passed is enforced on a schedule, so content can outlive its window by a few hours
before the next pass removes it. What the pass removes is the CONTENT, not the record: for
transcripts and tool arguments the prompt, completion, arguments and results are erased from the
trace and the trace itself — its timing, status, service and token counts — is untouched. The same
erasure reaches the other places that kind of content is kept: agent steps, a metric's exemplars and
labels, a log line's attributes and resource, an error's tags, extra and request headers, the tags on
the issue it rolls up into, and a product event's properties. For session replay the recording is the
content and is removed outright. Windows on the two customer-property kinds are accepted and audited
and are **not yet enforced**; they are reported as windows the organization could not act on rather than
silently ignored.

#### What has this project's policy refused?

```http
GET /api/v1/projects/{projectId}/content-policy/access-audit
```

Requires `governance:read`. Lists the times this project's policy WITHHELD content, newest first.
Optional filters: `content_class`, `sink`, `principal_id`, `since`, `until` (RFC3339), `limit`, and
the opaque `cursor` returned by the preceding page
(default 200, maximum 1000). An omitted filter means every value rather than the unset one, so a
bare call returns the whole project; an unparseable `since` or `until` is `400` rather than
silently ignored. Preserve the cursor unchanged with the same filters; `next_cursor` is empty on
the final page. The cursor includes a stable tie-breaker, so refusals sharing one timestamp are not
skipped.

```json
{
  "records": [
    {
      "record_id": "9c1f…",
      "scope": { "org_id": "…", "project_id": "…" },
      "content_class": "CONTENT_CLASS_MODEL_TRANSCRIPT",
      "sink": "CONTENT_SINK_EXPORT",
      "decision": "CONTENT_DECISION_DENY",
      "reason": "policy",
      "default_posture": false,
      "principal_kind": "CONTENT_ACCESS_PRINCIPAL_KIND_API_KEY",
      "principal_id": "…",
      "subject_ref": "",
      "occurred_at": "2026-08-28T09:14:02Z",
      "recorded_at": "2026-08-28T09:14:03Z",
      "count": 1
    }
  ],
  "next_cursor": "eyJ…"
}
```

`count` is how many identical refusals the entry stands for, and it is `1` for everything a person or
a credential did. Refusals taken while **recording** content are different in kind: that decision is
taken once per event on the ingest path, so a project whose policy refuses transcripts would produce
one entry per event forever. Those are grouped by the minute, keeping the kind, the boundary and the
reason, and `count` is how many there were. An entry with no `count` means one. If you are adding up
what your policy has refused, add up `count` rather than counting entries.

`reason` says whose decision it was, and only one of its values means the control working as you
configured it: `policy` is your own stored decision, `no_policy` means the organization default
answered because this project had not been set up yet, `resolver_error` means the policy could not
be read at all, `no_project_scope` means the request named no project, and `unclassified` means the
caller could not say what it was sending. `default_posture` is the same distinction as a flag.

`decision` is `CONTENT_DECISION_DENY` or `CONTENT_DECISION_ALLOW_METADATA`. The second is still a
refusal: existence, size and digest travelled and the bytes did not.

`subject_ref` is present only where the refused call named one person; it is empty for a decision
about a payload rather than about a subject. `occurred_at` is when the decision was taken and
`recorded_at` is when it was written down — they differ by a second or two because recording never
delays the request it describes.

**This is not an access log.** A permitted read writes no entry, because a permitted read is every
successful read in the organization. So an empty page means nothing was withheld — never that nothing
was read — and this endpoint cannot answer who has viewed a given person's content.

### Erasing a person's recorded content

#### Erase one person's content now

```http
DELETE /api/v1/projects/{projectId}/content/{class}/{subjectRef}
```

Requires `governance:write`. Supported classes are `model_transcript` and `tool_arguments`;
`subjectRef` is the person's `distinct_id` inside the project. The body requires a reason and a
canonical operation UUID:

```json
{"reason":"Account owner requested removal, ticket 4192","operation_id":"649ebca1-0452-45b7-aad3-29411ad0f6cd"}
```

```json
{"rows_affected":12,"action_id":"erasure:649ebca1-0452-45b7-aad3-29411ad0f6cd","already_erased":false,"receipt_missing":false,"operation_id":"649ebca1-0452-45b7-aad3-29411ad0f6cd"}
```

The clear removes that person's class-specific attributes from subject-addressable telemetry and
preserves surrounding records and other people's content. The original decision has a durable audit
record, including a first zero-row clear. Retrieve its receipt with
`GET /api/v1/action-receipts/{actionId}`.

Exact retries reuse the operation UUID and return the original count and action identity.
`already_erased` means the original operation cleared zero rows; retries do not change it to true.
Reusing the UUID with changed scope, subject, class, requester or reason is a `409` conflict.
A new deliberate clear requires a new UUID. After durable completion, an exact retry does not
clear later arrivals. Pending recovery can clear arrivals before completion.

`receipt_missing: true` means the physical clear completed but its receipt acknowledgement remains
pending. Recovery retries the same frozen receipt without repeating the completed clear. The stable
action identity remains available; persistent failures require operator attention.

`replay_dom` answers `501` here: recordings are selected by age across a project with no per-person
selector, so a subject-scoped request is refused rather than deleting every recording in the project.
Delete the individual recordings instead. `memory_item`, `person_properties` and `group_properties`
are also unsupported here and return `501`. An unknown or omitted `class` is `400`; a `reason` longer
than 400 bytes is `400`.

All four boundaries have enforcement points. Eighteen of the twenty-four cells bind today, and six
cells have nothing to gate. The product marks each cell with which of those states it is in.
[Decide what recorded content may be used for](/docs/manage/content-policy) carries the whole grid
and is the page to read before relying on a cell. In short:

- **`store`** and **`read`** enforce `model_transcript`, `tool_arguments`, `replay_dom`,
  `person_properties` and `group_properties`. A denial at `store` is irreversible — the bytes are
  never written. A denial at `read` is not: the content stays recorded and a later change serves it
  again. A `replay_dom` denial refuses the chunk upload or the playback outright rather than
  returning an empty recording; a `person_properties` denial drops the property values from
  `identify()` while leaving the identity itself intact, and leaves the property bag off a person
  read, per person, according to the project that person belongs to.
- **`group_properties`** is independent from `person_properties`. It defaults to `allow` at
  `store`, `read` and `judge_transfer`, preserving the existing account experience, and to `deny`
  at `export`. Its model-transfer cell is enforced: an account investigation omits the property bag
  unless both the caller's account access and this class permit the transfer. Its `store` and `read`
  cells enforce the same property bag. Nothing exports that bag today, so its `export` cell has
  nothing to gate.
- **`export`** applies to `model_transcript`, `tool_arguments`, `replay_dom` and
  `person_properties`. Bulk telemetry exports carry model transcripts and tool arguments; content
  the policy refuses is withheld from the file and the job says which classes and why. A configured
  task callback may carry an investigation conclusion derived from those classes, a session
  snapshot, or customer properties, so it rechecks this boundary before every delivery attempt and
  sends task metadata without the conclusion when the transfer is denied or cannot be confirmed.
  See [Save and export data](/docs/manage/save-and-export-data) and
  [Connect an agent over A2A](/docs/agents/connect-a2a).
- **`judge_transfer`** enforces those two, `person_properties` and `group_properties`. An automatic quality check on a
  run whose content is not permitted is recorded as withheld by policy rather than scored or
  silently skipped
  ([Automatic quality checks](/docs/manage/usage-and-ai-settings#your-content-policy-decides-what-a-judge-may-see)),
  and a Customer Detective investigation leaves that content out of what it shows the model, then
  says so in a `## Withheld by policy` section of the answer
  ([Investigate with Customer Detective](/docs/investigate/customer-detective#what-detective-may-send-to-a-model)).

`memory_item` has no producer at all, so editing its cells records your intent without changing
behavior. Documents pulled in by a retrieval step are recorded as part of the model transcript and
are governed by `model_transcript`.

Two knock-on effects of `person_properties: deny` at `read` are worth knowing: people search stops
matching on property values for that project (searching by an identifier you sent with `identify()`
still works), and audiences defined by a property value stop refreshing. A people search that names
no project takes the narrower behavior whatever your policy says — pass `project_id` to keep
property matching.

**`read`, `export`, and automatic `judge_transfer` decisions are independent.** An export asks the
`export` cell even when `read` is denied, and an automatic quality check asks `judge_transfer`.
Customer Detective evidence still passes through `read` first because it forms a user-visible
investigation, then through `judge_transfer` before anything is sent to a model.

### Conversations

#### What happened in a conversation?

```http
GET /api/v1/agent-sessions
GET /api/v1/agent-sessions/{sessionKey}
GET /api/v1/agent-turns?session_key=...
```

A **session** is one conversation, a **turn** is one exchange within it, and a **run** is one
execution the agent performed to answer a turn — a turn can span several runs, and `run_ids` lists
them in start order. Sessions are newest-activity-first and cursor-paged; turns require a
`session_key`, because a turn list with no conversation is a scan of everything you own.

A session's `turn_count` counts only turns that carry an identifier. Telemetry that reports no turn
id contributes none, so a session showing `0` turns and non-zero activity means the instrumentation
is not reporting turns — not that nothing was said.

`GET /agent-sessions/{sessionKey}` answers `404` for a session with no activity in scope, rather than
an empty body, so "no such conversation" is never confused with "a conversation that did nothing".

### Telemetry completeness

#### How complete is my agent telemetry?

```http
GET /api/v1/telemetry-completeness
```

Two answers about one set of spans. `buckets` gives exact row counts per normalizer version and
source schema, newest first — deliberately buckets rather than one percentage, because "94%
complete" cannot distinguish a fleet that is uniformly current from one where a tenth of your data
came from a version behind and means something subtly different. `defects` gives the
telemetry-quality verdicts: seven detectors over the same runs, each an exact count.

| `kind` | Counts | What it means |
|---|---|---|
| `PARTIAL_TRACE` | runs | Nothing in the run is a root: the span that started it never arrived, so what you can see is a fragment |
| `ORPHANED_WORK` | spans | A step points at a parent that is not in its run — the work was recorded, the context it belongs to was not |
| `INVALID_TIMING` | spans | A step ends before it starts, or one of its timestamps is the Unix epoch, which is what an exporter that never set the field sends |
| `MISSING_TERMINAL_OUTCOME` | runs | The run never declared how it ended, so its `status` is inferred. This is the same population as `status_explicit: false` |
| `ABSENT_SESSION_ATTRIBUTION` | runs | No step carries a session key, so the run cannot be placed in its conversation |
| `ABSENT_PERSON_ATTRIBUTION` | runs | No step carries a distinct id, so the run cannot be attributed to a customer |
| `ABSENT_VERSION_ATTRIBUTION` | runs | No step carries an agent version, so a regression cannot be attributed to the change that caused it |

On the wire each value carries its enum prefix — `TELEMETRY_DEFECT_KIND_PARTIAL_TRACE`, and likewise
`TELEMETRY_DEFECT_STATUS_*` and `TELEMETRY_DEFECT_UNIT_*` — as in the example below. `affected` and
`evaluated` are 64-bit counts and are encoded as JSON strings.

**All seven are always present**, whether they found anything or not, so you can read the list as a
checklist without having to guess whether a missing entry means "clean" or "not looked for".

**`affected: 0` is not automatically good news**, and `status` is what tells you which it is:

- `DETECTED` — some of what was examined carries the defect.
- `CLEAN` — something was examined and none of it carries the defect.
- `NOT_MEASURED` — **nothing was examined.** The window, the filters, or the agent selector matched
  no runs, so the zero says nothing about your fleet. Treat this as "ask a different question", never
  as a pass.

`evaluated` is the population each count was taken over, and it is why `status` can say that:
`affected: 2` out of `evaluated: 14` and out of `evaluated: 14000` are not the same report. Note the
two units — run-level detectors and span-level detectors have different denominators, so compare a
count only against the `evaluated` beside it.

```json
{
  "defects": [
    {
      "kind": "TELEMETRY_DEFECT_KIND_PARTIAL_TRACE",
      "status": "TELEMETRY_DEFECT_STATUS_DETECTED",
      "unit": "TELEMETRY_DEFECT_UNIT_RUN",
      "affected": "1",
      "evaluated": "8"
    }
  ]
}
```

The window selects **runs**, not spans: a run that started inside it is examined whole, including
steps that began earlier. That is deliberate — narrowing the window must never make a run look like
it is missing its first steps.

**Each bucket also says what its rows can still be read to mean.** Knowing that a tenth of your data
came from an older normalizer is only useful next to the answer "and here is which fields of it no
longer mean what you would mean". Every bucket carries a `resolution`:

```json
{
  "normalizer_version": 5,
  "source_schema": "openinference",
  "row_count": "1204",
  "resolution": {
    "reader_version": 6,
    "recognized": true,
    "unresolved": true,
    "unresolved_fields": ["agent_observation_key"],
    "absent_fields": []
  }
}
```

| Field | What it tells you |
|---|---|
| `unresolved` | the verdict. `false` means these rows can be read exactly as the newest rows are |
| `unresolved_fields` | a later normalizer **changed what these fields mean**. The stored values are not comparable with the current ones, and cannot be recomputed — treat them as a different measurement, not a stale one |
| `absent_fields` | a later normalizer **started reading** these fields. They are empty in these rows because nothing looked for them, which is not the same as your producer having sent nothing |
| `recognized: false` | these rows predate the normalizer history this API knows, or were written by a newer one. No claim is made about them at all |
| `ahead` | the rows are newer than the reader — you are reading during a rollout; ask again shortly |
| `mixed` | only on aggregates: the rows behind one number were not all written by the same normalizer |

The same `resolution` appears inside `provenance` on runs, steps and events, so any single result
carries its own verdict. A run's is a *range*: a run whose steps straddled a normalizer change comes
back with `mixed: true` rather than quietly reporting the newer version for all of it. If
`resolution` is absent from a response, that endpoint did not measure it — it never means the row is
current.

Two defects have a fix on your side rather than ours. `PARTIAL_TRACE` and `ORPHANED_WORK` are almost
always a dropped or never-flushed export: check that your exporter flushes on shutdown and that its
queue is not silently discarding batches. `INVALID_TIMING` at the epoch means a span was exported
with its timestamps never set.

### The agent run graph

#### What did an agent actually do?

```http
GET /api/v1/agent-runs?agent_observation_key=...&person_id=...&group_type=...&group_key=...
GET /api/v1/agent-runs/{runId}/graph
GET /api/v1/agent-events?run_id=...
GET /api/v1/agent-events?run_id=...&category=context&context_source_id=...
GET /api/v1/agent-events?memory_item_key=...
GET /api/v1/action-receipts?chain_id=...
GET /api/v1/action-receipts/{actionId}
```

The run list accepts repeated or comma-separated `agent_observation_key` values and matches any of
them. It also accepts a canonical `person_id`, or an account selector supplied as the pair
`group_type` and `group_key`; sending only half of that pair is invalid. These are whole-run
selectors: when any step carries the selected identity, the response retains every step in that
run. Display names are labels and should not be used to distinguish two fleet records.

**Comparing releases.** The run list also accepts `release=<version>`, which keeps runs whose earliest
declared `gen_ai.agent.version` matches exactly, and `without_release=true`, which keeps the runs that
declared no version at all. The two cannot be combined; sending both is invalid.

Every response — including one with no runs in it — carries `release_coverage` for the population
your **other** filters select, with the release filter itself removed. It reports `run_count`,
`released_run_count` and the sorted `releases` observed, and a `state` of
`AGENT_RELEASE_COVERAGE_STATE_NO_RUNS` (nothing to measure), `AGENT_RELEASE_COVERAGE_STATE_NONE`
(runs exist, none declared a version), `AGENT_RELEASE_COVERAGE_STATE_PARTIAL` or
`AGENT_RELEASE_COVERAGE_STATE_COMPLETE`. Read it before you conclude anything from an empty page: a
release filter over a project that never sets the attribute returns no runs, and without the
coverage beside it that is indistinguishable from a release with nothing wrong. Each run also
carries `release_declaration`, so an empty `release` is reported as
`AGENT_RELEASE_DECLARATION_UNDECLARED` rather than left ambiguous.

`/agent-runs/{runId}/graph` returns the run, every step in it, the non-span evidence attached to
it, and the action receipts it produced, in one response. Steps are in start order and each carries
`parent_span_id` — that field alone defines the shape of the run; there is no separate edge list to
reconcile it against.

Every model step also carries a `context` summary. `instrumented: true` means at least one valid
assembly, compaction, or cache event was accepted for that step; `assembled_tokens`, `overflow`,
`compactions`, and `cache_outcome` are its fold. A model step with no accepted context event still
returns the object with `instrumented: false`, including older rows and rejected-only telemetry.
Non-model steps omit it. Never infer instrumentation from zero-valued counters or the normalizer
version: an instrumented assembly may measure zero tokens.

Each step also carries `outcome` and, when it failed, a bounded `error_type` code — so the graph
names *which* step failed rather than only how many did. `outcome` is what the step itself declared
about its response; it is never derived from a span's status, so `OUTCOME_UNSPECIFIED` means the
step declared no outcome and never that it succeeded. The same two fields are on the agent-facing
`get_agent_run_graph` capability, spelled there as the bare lowercase member (`error`, `ok`,
`unspecified`).

`error_type` is the `error.type` attribute your instrumentation set on that step — the error
*class*, such as `RuntimeError` or `java.lang.IllegalStateException`. It is a code, not a message:
values longer than 64 bytes, or carrying anything but letters, digits, `.`, `_`, `-`, `:` and `/`,
are dropped rather than shortened, because a truncated class name would name an error you never
raised. A step's free-text status message is never served here for the same reason.

When a value **is** dropped, the step says so: `error_type_rejection` names the reason —
`too_long` for a value over the 64-byte bound, `not_a_code` for one carrying characters no error
class or code does. It is `unspecified` (and omitted from the MCP capability's JSON) when nothing was
dropped, which includes the ordinary case of a step that declared nothing at all.

So an empty `error_type` has two readings and the sibling field tells you which: with no rejection
beside it, the step declared no error class — set `error.type` on the failing span if you want the
graph to name it. With a rejection beside it, your instrumentation **is** setting the attribute and
this platform will not serve the value it sent; send the class rather than the message, and the graph
will name it. The two need different fixes, which is why they stopped sharing one answer.

The run and each model step also carry `cost_nanos` beside a `cost_source` that says how the number
was arrived at — a cost is not readable without it:

- `COST_SOURCE_CLIENT` — you reported the charge (`anectico.gen_ai.cost_usd`, or a per-token price
  override). A zero here is a **measured** zero: a free tier, a cached completion, a call that
  genuinely cost nothing.
- `COST_SOURCE_TABLE` — this platform priced the call from token counts and the model's rate.
- `COST_SOURCE_UNPRICED` — a priced model call for which no rate is known here. Set a pricing
  override for the model, or report the cost yourself.
- `COST_SOURCE_CLIENT_INVALID` — you reported something that is not a cost: a negative charge, a
  NaN, an infinity, or a per-token price that is one of those. Each stated per-token price is judged
  on its own, so an honest output price does not rescue a negative input price, and a negative price
  applied to zero tokens is still refused. The cost reads 0 because nothing is known about it, and
  the call is excluded from every spend total — fix the instrumentation that sends the value.
- `COST_SOURCE_MIXED` — **run only**: at least one call has a valid measured `CLIENT` or `TABLE`
  cost and at least one other call has `CLIENT_INVALID`. The run's `cost_nanos` is the subtotal of
  only the valid calls; the refused claim contributes nothing and remains visible regardless of
  which call happened first. Inspect the model steps for each call's exact source.
- `COST_SOURCE_NONE` — the step reported no cost and none was derived.

The distinction between `COST_SOURCE_UNPRICED`, `COST_SOURCE_CLIENT_INVALID`, and `COST_SOURCE_NONE`
matters: `COST_SOURCE_UNPRICED` is a gap here that you close with a pricing override,
`COST_SOURCE_CLIENT_INVALID` is a gap in what your instrumentation sent, and `COST_SOURCE_NONE` is
neither. A refused claim is never served as `COST_SOURCE_CLIENT` with a zero, because that would be
indistinguishable from a call that really was free. At run level provenance is an order-independent
fold: `COST_SOURCE_MIXED` takes priority for valid-plus-invalid calls; otherwise the precedence is
`COST_SOURCE_CLIENT_INVALID`, `COST_SOURCE_UNPRICED`, `COST_SOURCE_CLIENT`, `COST_SOURCE_TABLE`,
then `COST_SOURCE_NONE`. This makes an incomplete subtotal visible while each model step continues
to state the exact source of its own cost.

`action_receipts` is the run's ledger of consequential actions, in the same folded form
`/action-receipts` returns: `requester_principal` for the principal that asked for it,
`approver_principal` and `approved_at` for an action that was approved,
`delegator_chain` for the authority it was taken under (outermost first), `policy_id` and
`policy_version` for the policy it was authorized against, and `final_state`, where `COMPENSATED`
means the effect was deliberately undone rather than merely completed. None of this is a step — an
approval and a compensation are transitions in an append-only chain, not spans — so the graph is
where you read what a run was permitted to do, and the step tree is where you read what it did.

Each receipt carries its `entries`, and **each entry carries its own `authority`**. The receipt's
`authority` is the folded value; when you need to know whether Anectico performed an action or
merely received a report that it happened, read the entry. An empty `action_receipts` means the run
took no receipted action; a ledger that cannot be read is an error, never an empty list. A run whose
ledger is past the size ceiling answers `429`, exactly as an oversized step tree does.

**The graph is whole or it is refused.** There is no page size. A page of a graph is a graph with
missing parents or evidence, which is indistinguishable from a genuinely small run. A graph with
more than 2,000 evidence events therefore answers `429` instead of returning the first 2,000 as if
they were complete; the step tree and the other whole-graph collections have their own ceilings for
the same reason. The CLI exits with the request error and the `get_agent_run_graph` capability
returns a tool error, so neither surface emits a plausible partial result. If you hit the ceiling,
the run is almost certainly a loop, and `/agent-events?run_id=...` will page through its evidence.

A run's `status` is a producer declaration or `undeclared`; the platform does not turn child errors
into a run verdict. `status_explicit` is true only when the run supplied a trustworthy terminal
outcome. `error_count` remains the number of errored spans and can be non-zero on a successfully
recovered run, so use it as step-level evidence rather than as the run result. An undeclared run has
an empty `terminal_reason`.

`outcome_vocabulary` records how the run can declare its outcome. `AGENT_RUN_OUTCOME_VOCABULARY_ANECTICO`
means it carries the Anectico agent-observability vocabulary,
`AGENT_RUN_OUTCOME_VOCABULARY_DECLARING_VENDOR` means its root agent span came from a recognized
framework integration that explicitly sets OpenTelemetry status, and
`AGENT_RUN_OUTCOME_VOCABULARY_NON_DECLARING` means no declaring integration could be identified
confidently. The terminal-status and
terminal-reason fields are excluded from the Anectico vocabulary test, so a run cannot qualify for
an instrumentation-coverage denominator merely by already carrying the outcome being measured.
An `UNSET` OpenTelemetry status remains `undeclared` in every vocabulary.

A run's `release` comes from the SDK-declared `gen_ai.agent.version`. Read
`release_declaration` with it: `AGENT_RELEASE_DECLARATION_DECLARED` means the value was present,
while `AGENT_RELEASE_DECLARATION_UNDECLARED` means the run was measured and sent none. Agent-run
list responses additionally carry `release_coverage` with the run count, released-run count, sorted
declared releases, and one of `AGENT_RELEASE_COVERAGE_STATE_NO_RUNS`,
`AGENT_RELEASE_COVERAGE_STATE_NONE`, `AGENT_RELEASE_COVERAGE_STATE_PARTIAL`, or
`AGENT_RELEASE_COVERAGE_STATE_COMPLETE`. In particular, `AGENT_RELEASE_COVERAGE_STATE_NONE` means
runs exist but this population declares no releases; it is not an empty-result guess.

`GET /api/v1/evaluation/results` returns stored evaluation results. Every result carries a
`provenance.execution` object recording how it came to exist: `sampling`
(`SAMPLING_DECISION_SAMPLED`, `SAMPLING_DECISION_NOT_SAMPLED` or `SAMPLING_DECISION_WHOLE_DATASET`)
with `sampling_rule_id` and `sampling_rate` present only for `SAMPLING_DECISION_SAMPLED`;
`cost_state` (`COST_STATE_CHARGED`, `COST_STATE_FREE`, `COST_STATE_UNPRICED`,
`COST_STATE_UNREPORTED`) with `charged_cost_nano_usd`, `input_tokens` and `output_tokens`;
`judge_model_state` (`JUDGE_MODEL_STATE_RECORDED`, `JUDGE_MODEL_STATE_NOT_APPLICABLE`,
`JUDGE_MODEL_STATE_UNREPORTED`) with `judge_model`; and `job_status` (`JOB_STATUS_COMPLETED`,
`JOB_STATUS_PARTIAL`, `JOB_STATUS_FAILED`, `JOB_STATUS_CANCELED`) with `job_failure_class`. Read the
state before the value in each pair: a charge of zero means a free evaluator under
`COST_STATE_FREE` and an unknown price under `COST_STATE_UNPRICED`, and `judge_model` is the model
that answered rather than the one the evaluator version requested. `JOB_STATUS_FAILED` and
`JOB_STATUS_CANCELED` appear only on the immediate reply to `POST /api/v1/evaluation/evaluate`,
where the score exists and its storage is still owed; no stored result carries them, and a release
gate refuses both. A result written before this record existed returns empty values here and is
refused by release gates for the same reason a result with no evaluator version is.

`GET /api/v1/evaluation/release-series` attributes one exact evaluator version and metric to those
observed releases. It requires `evaluator_id`, positive `evaluator_version`, and `metric_name`; accepts
optional inclusive RFC3339 `start` and `end` subject-time bounds; and returns declared releases oldest
first with counts, means, and the delta from the preceding release. If every identity is semantic
versioning the order uses semantic precedence; otherwise the entire series uses first-observed time,
and `ordering_strategy` states which rule won. Explicitly unreleased results appear in a separate
aggregate instead of disappearing or acquiring an invented release identity.

`counts.handoffs` is measured. A step is counted as a handoff when your instrumentation declared
`anectico.agent.handoff.target` on it, so a `0` here means no handoff was recorded in that run — it
is an answer, not a gap. The count reads the declared target and not the step's operation: recording
a handoff on your agent-run span annotates that span and does not rename it, so the step — and the
run — keep `CANONICAL_OPERATION_INVOKE_AGENT` and are still counted here.

There are TWO guardrail counts and they are different numbers. `counts.guardrail_checks` counts
every step on which a guardrail ran — one your instrumentation named, or one reported by a framework
that says a guardrail ran and nothing more — counted once however it qualified. Recording a guardrail
on your agent-run span leaves that step categorized as agent work and still counts it here.
`counts.guardrail_decisions` counts only the steps whose instrumentation declared
`anectico.guardrail.name`. The gap between them is how much of your guardrail surface reports what
it decided.

A step carries a `guardrail` object when it declared that name. It reports `name`, the guardrail's
own `result`, a `finding_class`, the `policy_id` and `policy_version` in force, and the `decision`
the policy reached — one of `allow`, `deny`, `modify`, `escalate`. **A step with no `guardrail`
object means the guardrail half was not instrumented; it never means the guardrail passed.** A
`decision` of `GUARDRAIL_DECISION_UNSPECIFIED` means the same thing about the decision alone, and is
never a synonym for `allow`. Every field is bounded and a value outside its bound is refused rather
than truncated — see
[Agent span attributes](/docs/reference/agent-attributes) for the exact shapes and for the reason
`result` and `decision` are two fields.

`counts.a2a_calls` and a step's `a2a` object report Agent-to-Agent protocol work: the
`method`, and the `task_id` and `context_id` when the call carried them. Today the calls you drive
against Anectico's own A2A endpoint are reported this way; your own agent-to-agent traffic appears
only if your agents emit the `a2a.*` attributes themselves, and streamed A2A calls are not yet
observed.

`counts.approvals` is still always `0`. Nothing reported on a step expresses an approval, so that
zero means "not reported", never "none happened". Every other count is measured.

A step carries a `handoff` object when it declared a handoff to another agent. It reports the
`target` — the agent the work was handed to, exactly as you declared it — and an optional `reason`.
Both are bounded: the target must be spelled as an identifier (letters, digits and `. _ - : /`, up
to 128 bytes) and the reason must be a short lowercase code such as `specialist_required`, never a
sentence. A value outside either bound is refused rather than truncated, and the field simply does
not appear. A reason declared with no target records nothing at all: there is no handoff for it to
annotate.

A handoff does **not** change the step's `category`. The span is still the agent invocation it was,
so it keeps `OPERATION_CATEGORY_AGENT` and continues to count as one; what changes is
`canonical_operation`, which becomes `CANONICAL_OPERATION_HANDOFF`.

Downstream application steps are classified. A step that is not agent work but carries stable
OpenTelemetry evidence of an HTTP request, a database query, an RPC or a messaging operation — one
of `http.request.method`, `db.system.name` (or the older `db.system`), `rpc.system` or
`messaging.system` — comes back with `category: OPERATION_CATEGORY_APPLICATION`. This is what lets
one run's graph span the boundary between the agent and the services it called, with parentage
intact.

`OPERATION_CATEGORY_UNSPECIFIED` is a distinct answer and not a weaker version of that one: it means
the step carried none of those markers and nothing else classified it either. Read it as "not
classified", never as "ordinary application work" — connection details such as `server.address` or
`url.full` are deliberately not enough, because they appear on model calls too.

Model steps carry `requested_model` and `served_model` separately, so a provider that serves a
different build than you asked for is visible. Both are empty on runs recorded before this was
captured; that means unknown, not that they matched.

Model steps also report `streamed` and `stream_abandoned`. `stream_abandoned` is true when your own
code stopped consuming a streaming response before it ended — a `break`, a close, a cancelled
request. That is deliberately **not** an error and is never counted as one, because a caller
stopping early is ordinary control flow and booking it as a failure would raise alerts on code that
is working. What it does tell you is that the token counts on that call are the provider's last
reported values and are partial by construction. Both fields are `false` when nothing was reported
about streaming at all, so read a `false` as "not stated" unless you know your instrumentation sends
it.

Tool steps carry `mcp_session_id` and `mcp_protocol_version` when the step was an MCP call, so a
misbehaving tool can be traced back to the session and the protocol version it ran under.

A tool step's `arguments` and `result` describe recorded tool content **without disclosing it**.
Each is present only when something was recorded about that channel, and each carries a `sha256`, a
`byte_size`, a `disposition` and a `truncated` flag — never the value itself. `disposition: FULL`
means the content is held; `disposition: ABSENT` with a non-zero `byte_size` means it was captured
by your application and not kept, which is what your project's `tool_arguments`
[content policy](/docs/reference/permissions#your-projects-content-policy) decides. The absence of
the field entirely means the tool call recorded nothing. `byte_size` is the size **before** any
truncation your SDK applied, so `truncated: true` tells you the held value is a prefix of a larger
one rather than the whole answer.

See [Memory provenance and quarantine](/docs/agents/memory-provenance) for the concept behind the
fields below — item identity, the two trust labels, and what a quarantine opens — before the exact
shapes here.

Memory steps carry a `memory` object when your instrumentation declared **which item** a memory
operation touched. It reports the `store` the operation targeted, a bounded `memory_operation`, an
`item_key`, an `item_provenance` and a `declared_trust_label`.

`item_key` is derived by the platform from your declared item id together with your organization,
project and store — the declared id itself is never stored or returned. That derivation is what
makes the same item recognizable across two conversations, which is the question the field exists to
answer: pass it as `GET /api/v1/agent-events?memory_item_key=...` to get every recorded read and
write of that one item. The filter is a complete selector on its own — you do not need a run or a
session beside it — and an item nobody has recorded returns an empty page rather than an error.

**No memory item content is stored or returned, by this or any other endpoint.** `item_provenance`
is a bounded reference — a trace id, a `trace/span` pair, or a `sha256:` digest — and a value in any
other shape is refused rather than kept, so nothing free-form reaches the field.
`declared_trust_label` is what your own instrumentation asserted about the item, and is one of
`trusted`, `suspect` or `quarantined`. It is reported separately from `trust_label`, which is the
label established through review — the one you set with the endpoints below. The two are never
folded together: your own assertion cannot become the reviewed judgement, or a poisoned item could
declare itself clean.

`trust_label` is the reviewed label **that was in force at the moment that row happened**, not the
item's label now. An item read while it was still `trusted` and quarantined an hour later reports
`trusted` on that read and `quarantined` on everything after the decision — which is what lets you
say which conversations carried an item before anybody knew it was bad. `trust_label_revision` names
the exact judgement that applied, so you can read its reason and its author out of the label history
below, and `trust_label_in_force_since` is when that judgement was recorded.

An empty `trust_label` means nobody had judged the item when that row happened, and it means nothing
else: `trust_label_revision` is `0` in exactly that case (revisions start at 1), and if the reviewed
label cannot be read at all, the whole request fails rather than returning memory rows without their
labels. A memory row is never disclosed bare.

### Agent memory

#### Reviewing a memory item

```http
PUT  /api/v1/memory-items/{itemKey}/label
GET  /api/v1/memory-items/{itemKey}/label
GET  /api/v1/memory-item-labels?memory_item_key=...&label=quarantined
```

`{itemKey}` is the `item_key` above. The PUT body carries `label` — `MEMORY_TRUST_LABEL_TRUSTED`,
`MEMORY_TRUST_LABEL_SUSPECT` or `MEMORY_TRUST_LABEL_QUARANTINED` — and a `reason`. Both are
required: an omitted label is refused rather than treated as "no opinion", and a decision with no
reason is not reviewable. The PUT takes `agents:write`; both reads take `agents:read`.

**You do not say who is labelling.** The reviewer identity and the timestamp are taken from the
credential that made the request and returned as `labeled_by` and `labeled_at`; a value you send for
them is not accepted. A request made with a machine credential that carries no user or key identity
is refused, and so is one made on the platform's own behalf: labelling a memory item is a decision a
person makes.

**The history is append-only.** Relabelling adds a revision rather than replacing one, so
`GET /api/v1/memory-item-labels?memory_item_key=...` returns every judgement ever recorded about the
item, newest first, each with its own `revision`, `reason`, `labeled_by` and `labeled_at` — which is
what lets you ask who trusted an item *before* an incident. `GET /api/v1/memory-items/{itemKey}/label`
returns the current one, the highest revision. An item nobody has labelled is a **404**, never a 200
carrying an empty label: "nobody has looked at this" and "somebody looked and said nothing" are
different answers.

Labelling an item `quarantined` opens exactly one review signal of kind `poisoned_memory_item`,
whose `subject_ref` is the item key; labelling it `trusted` again resolves that same signal. A second
`quarantined` refreshes the open signal rather than opening another, and `suspect` leaves an open
signal open, because a downgrade is an unfinished review rather than an all-clear. The response says
which happened, in `review_signal_opened` and `review_signal_resolved`. You may label an item before
it has been recorded anywhere: containment often runs ahead of the next session.

**A label records a judgement; it contains nothing by itself.** Anectico does not scan memory for
poisoning and does not stop your agent reading its own store — nothing here revokes a credential or
blocks a call.

#### Where an item went, and under what label

```http
GET /api/v1/memory-items/{itemKey}/propagation?limit=200
```

This is the read a poisoning question actually needs: every recorded place one item was written or
read, oldest first, each carrying the label that was **in force at that moment**. It takes
`agents:read`.

Each entry reports the `session_key`, `turn_id`, `run_id`, `span_id` and `occurred_at` of one place
the item was, the `canonical_operation` that touched it, the `store`, the `item_provenance` and your
own `declared_trust_label` — plus `label_state`, `label_in_force`, `label_revision`, `label_id` and
`label_in_force_since`. `distinct_session_count` is how many separate conversations the item reached:
one means it never left its own, and two or more is the cross-session spread the endpoint exists to
make visible.

**`label_in_force` is historical, not current.** It is the judgement that had been recorded when that
entry happened — so an item read while it was still `trusted` and quarantined an hour later reports
`trusted` on that entry, and `quarantined` on anything after the decision. That is the point: it lets
you say which conversations carried an item *before* anybody knew it was bad. The item's label **now**
rides once on the response, as `current_label` and `current_label_state`.

`label_state` is `MEMORY_LABEL_STATE_LABELED` or `MEMORY_LABEL_STATE_UNLABELED`, and the second one
is a real answer rather than a blank: nobody had judged the item at that point. An entry never
carries an empty label word instead.

An item that was never recorded anywhere and was never labelled is a **404**. An item you labelled
before it was ever observed returns the label with no entries — containment often runs ahead of the
next session. If more entries exist than `limit` returned, `truncated` is `true`; a partly-read flow
must not be mistaken for the whole of it. `limit` defaults to 200 and caps at 500.

If the label cannot be read, the whole request fails rather than returning entries without their
labels: an item disclosed without its quarantine label is exactly the answer this endpoint must
never give.

**The `memory` object is absent whenever your instrumentation declared no item**, which is the
common case: the OpenTelemetry conventions standardise the memory operations and define nothing
about item identity, so most frameworks emit the operation alone. Read that absence as *the item is
not instrumented* — the step still reports the memory operation and it still counts in
`counts.memory_ops`. It never means no memory was read. Declaring
`anectico.memory.item.id`, `gen_ai.data_source.id`, `anectico.memory.item.provenance` and
`anectico.memory.trust_label` on your memory spans is what fills it.

A step's `outcome` is filled **only** when the telemetry stated one for that response. `completed`,
`failed` and `cancelled` become `OUTCOME_OK`, `OUTCOME_ERROR` and `OUTCOME_CANCELLED`. A response
reported as `incomplete`, `queued` or `in_progress` leaves `outcome` unset and carries the stated
value in that step's `attributes`, under `gen_ai.response.status`: none of the three means success
or failure, and labelling them either way would be a guess. A step whose telemetry stated no status
leaves `outcome` unset too, and a step's span status is never used to fill it — "something in this
span errored" is not the same claim as "this response failed". Step outcomes belong to the run
graph; `/agent-events` does not report an event-level outcome.

`/agent-events` returns step-level evidence — tool calls, retrievals, safety checks — anchored on a
run, a session, an agent identity plus a bounded window, or a window no wider than 24 hours. A
request with no anchor is rejected rather than silently scanning. Ordering is newest first and
paging is cursor-based. Its `category` and `canonical_operation` filters, and `/action-receipts`'
`authority` and `final_state`, follow the same rule as `/assets`: repeatable, OR-within and
AND-across, and an unrecognized value is rejected rather than producing a page that is quietly the
wrong one.

Context events use `category=context` and carry exactly one typed `context` detail:

- `assembly`: budgets, assembled tokens, overflow and assembly digest, plus the ordered bounded
  source metadata;
- `compaction`: trigger/strategy, before and after counts/digests, removed source IDs and the
  server-derived ratio;
- `cache`: provider, key digest, outcome and optional reuse, latency and cost savings.

`context_source_id` is a lineage **narrowing filter** over an assembly's ordered source IDs. It does
not count as an anchor by itself: supply `run_id` (or another ordinary bounded event anchor) too.
The raw span-event attribute map is never returned. Optional numeric members are omitted when the
producer omitted them, while a measured zero is returned as zero. That rule applies independently
to assembly counts, each source's tokens and position, compaction counts and ratio, cache reuse,
latency and savings, and a model step's assembled-token summary. The step summary's `instrumented`
bit remains the authoritative distinction between an uninstrumented step and one carrying an
accepted context event.

`/action-receipts` also accepts repeated `normalizer_versions`, which narrows the page to receipts a
given normalizer version produced — the filter to reach for when you are reproducing an earlier
answer and need the population pinned. Unlike `authority` and `final_state` it is an open number
rather than a closed vocabulary, so a version this deployment has never produced is a legitimate
question with an empty answer rather than a `400`. Omit it for every version.

> **`outcome` is not an agent-event field.** The evidence an event is derived from does not carry
> an outcome of its own, and this API will not infer one — a safety check that *denied* inside an
> otherwise successful step must not be labelled as having succeeded. The deprecated
> `?outcome=` selector is therefore rejected instead of returning an empty page that could be
> mistaken for "none happened". Filter on `category` or `canonical_operation` instead, and use the
> containing run's status (`/agent-runs/{runId}`) when you need success or failure.

> **`category` accepts only values telemetry can report.** Thirteen categories are derived today:
> `model`, `agent`, `workflow`, `planning`, `tool`, `mcp`, `retrieval`, `memory`, `safety`,
> `evaluation`, `context`, `application` and `a2a`. Any other value is refused with `400` rather
> than answered with an empty page, so an empty page for a category always means "none happened".
> Approvals, authority changes and compensations are not categories at all: they are action
> receipts, returned by `GET /api/v1/agent-runs/{runId}/graph` under `action_receipts`, and
> served in full by `/authority-edges` and `/action-receipts` — both of which carry more than a
> span category could.

An action receipt is the **fold** of an action's lifecycle, not one row: its `entries` array is the
complete sequence of transitions, and `final_state` is the state of the last one. `authority` bounds
what the receipt claims — `RECEIPT_AUTHORITY_MEDIATED` means the action ran through this platform,
`RECEIPT_AUTHORITY_REPORTED` means an agent told us it acted and the receipt attests only to what
was received and when. Each entry carries
its own `authority` as well, so a reader never has to infer which lines are whose claim; one chain
holds both kinds, and an action's authority cannot change halfway through its life.

`requester_principal` is the principal that **asked** for the action — a bounded identifier such as
`user:...`, `apikey:...` or a platform service name. It is taken from the first entry that carries
one, because the requester is part of what the action is and is declared when the action opens;
later transitions carry the field and may leave it empty, and taking the last would blank the name
of the principal the action was authorized for. Each entry also carries its own
`requester_principal`, so you can see which transition each name was declared on rather than
inferring it from the fold.

If the entries name **more than one** requester, the receipt reports the first and lists every
distinct name in `conflicting_requester_principals`. That list is empty in the ordinary case. It
exists because nothing prevents a chain from carrying two, and showing one principal's name over
another principal's transition in silence would be the more comfortable answer and the wrong one.

**`effect_class` folds to the WIDEST reach any transition declared**, on the order
`EFFECT_CLASS_NONE` < `EFFECT_CLASS_INTERNAL` < `EFFECT_CLASS_EXTERNAL`. A chain normally opens with
an `INTENDED` step that makes no claim about reach — nothing has happened yet — and that omission is
recorded as `EFFECT_CLASS_NONE`, so a receipt that took the first declaration would report `NONE`
for every action however far it later reached. A later transition can therefore raise the receipt's
declared reach and can never lower it, which is the same rule `destructive` follows: under-reporting
what an action touched is the dangerous direction. Each entry still carries the class it was written
with, so you can see which transition made the claim.

**Ordering: the list is by your clock, a single receipt is by ours.** `occurred_at` is a value *you*
send on every entry, and `GET /api/v1/action-receipts` orders by it, newest first — that is the
question a page of receipts answers, and the paging cursor is built on it. `GET
/api/v1/action-receipts?chain_id=...` without an `action_id` asks a different question, "the chain's
most recent receipt", and answers it in the order this platform sequenced the entries in. The two can
disagree, and when they do the single-answer read is the one that cannot be moved by a future-dated
`occurred_at`.

`approver_principal` and `approved_at` come from the **`AUTHORIZED`** transition, and only from
that one. A receipt whose chain holds no `AUTHORIZED` entry reports both as empty, whatever else
its entries carry — that is deliberate, and it is what stops an agent's own account of itself from
supplying an approver for an action nobody approved. So an action that genuinely required a second
person's sign-off records that sign-off as its own transition, and you read it from the fold.

A containment is the worked example. Approving one writes two entries in one chain: `AUTHORIZED`,
carrying the approver and the moment they signed, then `QUARANTINED`, carrying the outcome the
enforcement reported in its `detail`. Both entries name the requester, so the receipt answers "who
asked" and "who approved" as two different principals — which is what the sign-off rule required. The lifecycle position is still the last transition, so
`final_state` is `QUARANTINED`; the approval does not become the state, it becomes the authority.
The same `AUTHORIZED` transition is what puts the action into `/authority-edges`, so a contained
agent is answerable to "who authorized this, for what purpose" like every other mediated action.

**`policy_id` and `policy_version` name the rule the action was decided under.** On a
`RECEIPT_AUTHORITY_MEDIATED` receipt — one this platform performed or gated — they are always
present, and they are drawn from a
fixed vocabulary of the form `<domain>:<rule>` with a rule version of the form `v1`:

| `policy_id` | The decision it records |
|---|---|
| `mcp:write_gateway_authorization` | The scope and confirmation check applied to a consequential write made through the MCP endpoint — the rule behind both `AUTHORIZED` and `DENIED` on those actions |
| `agents:containment_approval` | The second-principal approval a containment requires before an agent's credentials are frozen or revoked |
| `content:retention_window` | The stored per-class retention window, enforced by the scheduled erasure pass |
| `content:subject_erasure` | An on-demand erasure of one subject's content, decided by a person rather than by a schedule |

The pair is folded from the first transition that carries it, and every transition of a
platform-decided action carries the same pair — so a single entry read on its own still answers
"under what rule". The version moves only when what the rule *decides* changes, which is what makes
two receipts written months apart comparable.

A platform-decided action carries **one** policy record for its whole lifecycle. A transition naming a
different rule — or a different version of the same rule — is refused with `400` when it is appended,
because the chain is append-only and a second record, once written, could never be taken back: the
receipt would assert one rule while the entries underneath it named two, and every entry digest would
still verify.

If the entries do name more than one, the receipt reports the first and lists every distinct record
in `conflicting_policies`, rendered `<id>@<version>` in sequence order. That list is empty in the
ordinary case, and it is the same promise `conflicting_requester_principals` makes: a disagreement
you can see beats a first-wins answer you cannot.

**A policy record is one value.** `policy_id` and `policy_version` are set together or not at all, on
every receipt including a `RECEIPT_AUTHORITY_REPORTED` one — a transition carrying only one of them
is refused with `400`, naming which field was sent alone. Two entries each carrying half a record are
not a record:
joining one entry's id to another's version would report a rule neither of them declared. So the
receipt's `policy_id`/`policy_version` come from a single entry that named both, they are empty when
no entry did, `conflicting_policies` lists complete records only, and each entry carrying half is
named in `malformed_policy_records` as `seq <n>: <field> without <the other field>`. That list is
empty in the ordinary case and can only be populated by entries written before this rule.

On a `RECEIPT_AUTHORITY_REPORTED` receipt the two fields are **yours**: send whatever identifies the
policy your own system applied, in whatever spelling it uses. They are free text there, bounded only
by length, and
this platform neither validates nor interprets them — including the one-record rule, which binds only
the receipts this platform writes. Two of your own spellings on one action are reported in
`conflicting_policies` rather than refused. The *pair* rule above does bind them: free text is still
one value in two fields, and sending an id with no version, or a version with no id, is refused.

Receipts written before 2026-08-28 carry no policy on the platform's own actions. The chain is
append-only and the entry digest covers these fields, so those rows cannot acquire one — an empty
`policy_id` on an older `RECEIPT_AUTHORITY_MEDIATED` receipt means "written before the platform
recorded this", never "decided by nothing".

**`compensated` and `rolled_back` are not synonyms, and the fold keeps them apart.** Compensation
says a *second* action was performed to offset the first: the original effect happened and still
stands. A rollback says the *first* action was reversed at its source: there is nothing left to
offset. The receipt reports the newest of the two in `remediation`, separately from `final_state` —
an action rolled back and then quarantined has `final_state: RECEIPT_STATE_QUARANTINED`, and "was
the effect undone" is still yes. A rollback does not retract a verification either: the effect *was*
verified, and what changed is that it no longer stands.

Both are refused (`400`) on an action whose entries hold no `EXECUTED`, `EFFECT_VERIFIED` or
`EFFECT_UNKNOWN` step. An action that was only intended, or denied, or failed has no effect that
either sentence could be true of.

`verification` is a three-state answer, not a boolean. `attempted: false` means nobody checked;
`attempted: true, verified: false` means a check ran and could not confirm the effect, and `method`
names the checker. An unconfirmable effect is a result, not a missing field — treat an
`EFFECT_UNKNOWN` step as "nobody can say", never as failure.

### Action receipts

#### Recording what your own agent did

```http
POST /api/v1/action-receipts/entries
```

Requires `agents:write`. One call records **one lifecycle step** — intended, requested, executed,
failed, effect-verified, effect-unknown, compensated, rolled-back, and the rest — not a whole
receipt; steps sharing an `action_id` fold into one. On the wire each step is its full name, `RECEIPT_STATE_`
prefixed (`RECEIPT_STATE_EFFECT_VERIFIED`); see the spelling note below. Send the `action_id`, the `state`, the
`occurred_at`, and whatever the step knows: the tool, the effect class, argument and intent digests,
the purpose, the delegation chain, the session and turn, the resource URIs.

Two fields are **derived and rejected on input**. `receipt_authority` follows from your credential —
your entries are recorded as `RECEIPT_AUTHORITY_REPORTED`, and you cannot ask for
`RECEIPT_AUTHORITY_MEDIATED`. `chain_id` is ours to
name: you have one action chain per project, and a caller who could start a fresh chain per action
would have a chain that is tamper-evident about nothing.

**Some `action_id` names are reserved.** An id beginning `quarantine:`, `erasure:` or `gateway:`
names an action this platform performed on its own authority, and an entry of yours under one is
rejected with `400`. The reason is not tidiness: an action has exactly one authority for its whole
life, so an entry of yours opening one of those ids first would make the platform's own record of the
same action a conflict — and the containment, erasure or gateway call would have happened with
nothing in the ledger saying who performed it. Every other `action_id` is yours to choose, including
ids that merely contain those words; only the prefixes are reserved.

Those prefixes are also how you **read** such a receipt. A containment's receipt is
`GET /api/v1/action-receipts/quarantine:{quarantineId}` — the quarantine id under its namespace, not
the bare id, which names no action and answers `404`. The colon is an ordinary character in a path
segment: send it as-is or percent-encoded, both reach the same receipt.

**Spell `state` and `effect_class` in full, in a request body.** They are closed vocabularies and the
body takes the full name — `"state": "RECEIPT_STATE_EXECUTED"`, `"effect_class":
"EFFECT_CLASS_EXTERNAL"` — which is also exactly how every response renders them, so a value you read
back can be sent again unchanged. The **query filters** on `GET /api/v1/action-receipts` take the
short lowercase form instead (`?final_state=effect_verified`), because a filter is a URL and a body is
a document. That asymmetry is the one thing about these fields worth remembering: `"effect_class":
"external"` in a body is not the external class, it is a value this API does not define.

A value outside either vocabulary is **rejected with `400`**, and the error names the field and lists
what it will accept. It is never quietly recorded as something else. That matters more here than on an
ordinary endpoint: a receipt is an append-only record of what an action did, `effect_class` is where a
reader establishes whether the action reached anything outside your systems, and a spelling we
answered with a guess would put a confident, unfalsifiable "no external effect" on a row that might
have been a destructive external write. Until 2026-08-21 an unrecognized `effect_class` did exactly
that, and receipts written through this endpoint before then cannot be distinguished afterwards from
ones that meant `EFFECT_CLASS_NONE` — see below.

**Omitting `effect_class` is not the same as sending an unusable one.** Leave the field out and you
are making no claim about the effect; the entry records `EFFECT_CLASS_NONE`, which is the documented
default, and that is a deliberate answer to a deliberate silence. Send a spelling we cannot read and
the call fails, because there is no honest default for a claim nobody can interpret. `state` has no
default at all — it is required, and an omitted one is rejected.

Say `EFFECT_UNKNOWN` when you could not confirm the effect, and use `EFFECT_VERIFIED` only with an
`effect_verify_method` naming how you checked — a verification that names no verifier is rejected,
because nobody reading it later could judge it. `effect_verify_method` and `detail_code` are short
lowercase identifiers (`^[a-z][a-z0-9_]{0,63}$`), not sentences: send `provider_readback`, never a
provider's error text. Both are grouped over in the product, and a free string there is unbounded
cardinality plus whatever the provider decided to echo back at you.

Send digests, never content: `arguments_sha256` and `intent_sha256` are hashes, and a receipt is
audit evidence retained for 400 days. Never put prompt text, tool arguments or retrieved documents
in one.

Events carry a content **digest** and size, never the content itself. Prompts, completions and tool
payloads are a separate permission.

### Deployments and configuration provenance

#### What did you deploy, and what configuration is it running?

```http
POST /api/v1/deployments
POST /api/v1/config-snapshots
POST /api/v1/abom-manifests
GET  /api/v1/config-snapshots/{configHash}
```

The three `POST`s require `agents:write`, which is owner/admin-only to mint; the `GET` is an ordinary
`agents:read`. This is the registration path a release pipeline uses, and it exists because the
information is not in your telemetry: every span, run, turn and receipt carries an
`effective_config_hash`, and nothing carries the configuration that hash stands for. Only the system
that resolved it can say. Register both and drift detection has something to compare; register
neither and it silently has nothing to say about any deployment.

`POST /api/v1/deployments` declares where one asset version is running. Send the asset, the
environment, your own `external_ref` for the slot (a release id, a workload name) and who deployed
it. The platform assigns the `deployment_id` and returns it — a supplied one is rejected, because a
chosen id could point a configuration or a manifest at a record you do not own — and `created` tells
you whether this was a new slot or a re-registration of an existing one. Re-registering the same
`(environment, asset, external_ref)` updates that slot rather than creating a second. Set
`retired_at` when the deployment goes away; a retired deployment stops being watched for drift.

`POST /api/v1/config-snapshots` declares one effective configuration: the `effective_config_hash`
your telemetry already stamps, plus what it stands for — enabled tools, enabled MCP servers, flag
variants, and a small `resolved` map of metadata — and the deployment and ABOM manifest it belongs
to. **A hash is immutable.** Re-registering the same hash with a *different* composition is refused
with `409`, because that hash is stamped on rows already written and one hash naming two
configurations would make all of them ambiguous. A changed configuration has a new hash.
Re-registering the same hash with the same composition is a repeat sighting and succeeds. Fields are
identifiers and are bounded: an oversized value, or one carrying a newline, is rejected rather than
truncated — this is metadata storage, not a place to send prose or prompt text.

`POST /api/v1/abom-manifests` declares the **composition** of one asset version — its tools, models,
prompts, MCP servers and skills, each as an identifier, a version and a content hash. The platform
canonicalizes exactly what you sent, signs it, stores it immutably and returns the signed artifact:
the canonical bytes, their SHA-256, the signature and the id of the key that made it. The manifest id
is assigned here and is what a configuration references.

Send `release` too, and send the same string your agents put on their spans as
`gen_ai.agent.version`. It is the label this bill of materials is **for**, it goes **inside** the
signed bytes, and it is what lets anyone holding an exported manifest check that it is the
composition for the release that actually ran — rather than for some other release of the same
agent. Two releases of one unchanged composition are then two artifacts with two signatures, which
is the point: without it, one signed document would serve every release and a label beside the
signature could be retyped by anyone.

`release` is **optional and explicitly so**. Omit it and the signed bytes say `"release": null` —
"this producer named no release" — which is a different statement from `"release": ""`, and the two
canonicalize differently on purpose. If you are not tagging releases yet, omit the field; do not
send an empty string to mean the same thing.

Two things about this endpoint are worth stating plainly, because a signature invites a stronger
reading than it earns. **What you send is what is signed**, so `canonical_form`, `manifest_sha256`,
`signature`, `signing_key_id` and `authority` are all rejected on input rather than ignored. And a
manifest you declare is recorded with `authority: "declared"` — **inside** the signed bytes, not
beside them. The signature then proves what you declared, under your organization, at that instant,
and that it has not changed since. It does not assert that the components are accurate; nobody but
you can know that. `authority: "mediated"` is reserved for a composition the platform itself
supplied, so a verifier reading only the exported artifact can always tell the two apart. Only
components, versions and hashes belong here — a manifest is exportable, so never put prompt text,
tool arguments or retrieved documents in one.

`GET /api/v1/abom-manifests/{manifestId}` returns one stored manifest and a **verdict** on it:
`valid` says whether the stored artifact still matches the byte contract this platform currently
produces, and `defect` names the reason when it does not (`canonical_form_superseded`,
`canonical_form_unreadable`, `digest_mismatch`, `column_body_mismatch`). The manifest is returned
either way — it is your artifact and the only copy of it you can reach here — and `valid: false` is
never an error status.

`canonical_form_superseded` means the manifest was signed under an **earlier** version of the byte
contract: its signature is still a true statement about the bytes it covers, and those bytes are no
longer a body this platform would produce for that manifest. Sign a fresh manifest for the asset
version and reference that one going forward. This is what you will see for anything signed before
`release` joined the signed body.

`canonical_form_unreadable` and `digest_mismatch` both mean the stored artifact is damaged rather
than merely old: the signed bytes cannot be parsed, or the digest recorded beside them is not the
digest of those bytes. Neither is expected, and a manifest reporting one should be signed again.

`column_body_mismatch` is about the record beside the artifact rather than the artifact itself. So
that manifests can be filtered and listed, `authority` and `release` are also held in a searchable
form outside the signed bytes; this defect says the two disagree. **The values reported to you are
always the ones inside the signature** — the signed bytes are the artifact, and nothing outside them
can change what a manifest declares — so the manifest you read is still the one that was signed, and
its signature still verifies. The defect is telling you that the searchable copy has drifted and that
filters over it may have missed this manifest. Report it to us; nothing on your side produces it.

`GET /api/v1/config-snapshots/{configHash}` resolves a hash back to what it stands for, and
returns every deployment that has **ever** run it. It is the one endpoint in this section that
**honors** `?environment=`, and it must: the same configuration registered in staging and in
production is two records, so a lookup that ignored the environment would answer about one with the
other. An unregistered hash answers `404`.

Each entry in `deployments[]` carries the validity interval of that binding: `first_seen_at` is when
the deployment started running this configuration, and `superseded_at` is when it moved to another
one. **An absent `superseded_at` means the binding is current.** Resolving an action to the bill of
materials that was actually in force when it ran means picking the entry whose interval contains the
action's own timestamp — not the newest entry, and not the current one. A deployment that was
upgraded and later moved back to this configuration appears once per period it ran it.

This is what makes the hash on an old trace, run, turn or receipt still answerable. Upgrading a
deployment to a new configuration does not erase what the previous one referenced, so `deployments[]`
for a configuration nothing runs any more is a list of closed intervals rather than an empty array.

### Verification and tamper evidence

#### Has the record been tampered with?

```http
GET  /api/v1/receipt-chains/{chainId}/verify
POST /api/v1/receipt-chains/{chainId}/checkpoints
GET  /api/v1/abom-manifests/{manifestId}
```

The verify response splits `entry_count` into `mediated_entry_count` and `reported_entry_count`,
which sum to it. "The chain verifies" is a statement about integrity and says nothing about who
vouches for what is in it.

`POST .../checkpoints` is an ordinary `agents:read` and returns a **signed** assertion that at this
instant the chain held exactly *n* entries ending in a given digest. Keep it. A hash chain is
tamper-evident against modification and reordering and silent about deletion of its tail; a
checkpoint you retained is what closes that, because it is not in our database and the key's private
half never leaves the platform. A chain that does not currently verify is not checkpointed — signing
a state already known to be faulty would make it look attested.

See [Verify attestations](/docs/reference/verify-attestations) for what these prove, what they do
not, and how to check a signature yourself. The short version for a manifest: the signature proves
that these exact bytes were declared, by whom, and when — and the `authority` inside those bytes is
what tells a reader whether the composition came from you or from the platform. It is never a claim
that the composition is correct.

## Credentials and their grant history

### Who holds a key, and who issued it?

```http
GET  /api/v1/account/api-keys
POST /api/v1/account/api-keys
PATCH /api/v1/account/api-keys/{id}
DELETE /api/v1/account/api-keys/{id}
```

Creating a key without `project_id` binds it to the organization's default project (or the first
available project when none is named `default`). If that project cannot be resolved, creation is
refused; omission never creates an organization-wide key. Set `org_wide: true` explicitly to create
an organization-wide key. A project-scoped key cannot set `org_wide`, select a sibling project, or
omit its way around its signed project boundary.

Every key in the listing names the principal that minted it. `user_id` is the person who issued it;
`created_by_api_key_id` is the key that issued it, when a key issued a key. `created_by_state` says
which of those applies, so an empty `user_id` is never ambiguous:

| `created_by_state` | What it means |
| --- | --- |
| `user` | `user_id` names the person who issued the key |
| `api_key` | `created_by_api_key_id` names the key that issued it |
| `system` | The platform issued it; there is no attributable principal |
| `orphaned_user` | A person issued it and their record is no longer available, so the attribution was lost. The key is still valid |
| `unknown` | The key predates this field |

### What was this key ever allowed to do?

```http
GET /api/v1/account/audit-events?target_api_key_id={id}
```

`target_api_key_id` returns one key's whole grant history — issued, scopes changed, revoked — newest
first, and never events belonging to any other key. It is matched against the key that was
*changed*, never the key that did the changing, so a key that administers other keys does not
collect their history.

Each event carries the scope set in `metadata`:

- `scopes` — the set that stands after the act, as a comma-separated list. On an issue it is what
  was granted; on a revocation it is what the credential could do at the moment it was revoked,
  which is readable nowhere else afterwards. The whole set is returned however large it is, so a key
  holding everything a role grants reads back in full. An empty value means the act granted nothing,
  which is different from the field being absent.
- `previous_scopes` — the set a change replaced. It is present on a scope change and on a freeze,
  where it is the authority the containment removed while `scopes` is what the key is left with; a
  rename replaces nothing and carries neither.

`event_type` filters the taxonomy — `api_key_created`, `api_key_updated`, `api_key_revoked`,
`membership_role_changed` — and composes with `target_api_key_id`, so "every scope change to this
key" is one request. `resource=api_key` selects credential events including their API mutations.

Revocations always name their actor. A revocation that follows a project or organization deletion
records the platform as the actor and names the deletion that caused it under `metadata.via`.

## Request and response conventions

- Send JSON writes with `Content-Type: application/json` unless an upload endpoint requires a raw
  symbol artifact.
- JSON field names are `snake_case`.
- Times are RFC 3339; common windows use `start_time` and `end_time`. Omitting an optional
  timestamp selects the endpoint's documented default, while a non-empty malformed timestamp
  returns `400 invalid_request` instead of silently selecting that default.
- `GET /persons/{person_id}/timeline` accepts `start_time` and `end_time` to bound the merged
  customer story. Keep those bounds unchanged while following its opaque pagination cursor.
- Repeated filters may accept repeated parameters or comma-separated values, depending on the
  resource.
- Paginated reads commonly accept `limit` and an opaque `cursor`. Return the next cursor unchanged;
  do not decode or construct it.
- Log, trace, Errors, and alert-rule paginated reads—including affected-customer results—reject a
  malformed non-empty pagination cursor with `400 InvalidArgument`; they never treat it as a request
  for the first page.
- Server limits clamp page sizes. Do not assume one maximum across resources.
- A paginated read reports `has_more` together with a `next_cursor`, and sets neither once the page
  is the last one. Treat a full page with `has_more` false as the complete result, and keep
  following `next_cursor` while `has_more` is true rather than assuming one page is everything.
- `total_count` is a hint, and `has_more`/`next_cursor` are the contract. Page on those. A count
  is measured a moment apart from the page it accompanies, so on data still being written it can
  lag; it will never be smaller than the results already handed to you, but treating it as the loop
  condition can end a read early. Where an endpoint does not compute a count at all it reports `0`,
  which means "unknown", not "empty".
- Those two properties hold on every paginated read, not just the busiest ones. `total_count` is
  never below the number of results in the same response — including the ones an earlier page
  already skipped — and `has_more` is decided by the result set itself rather than by the count, so
  a lagging count can no longer end your loop early. If you have been treating `total_count` as
  the loop condition, switch to `has_more`: it is the only one of the two that is exact.

The following collection routes all accept `limit` and opaque `cursor`. Their default page size is
100 and their maximum is 500. Preserve `next_cursor` exactly and continue until `has_more` is false.
API keys and alert silences expose those values inside `pagination`; the other routes expose
top-level `next_cursor`, `has_more`, and `returned_count` alongside the named item array.

| Route | Item array |
| --- | --- |
| `GET /services` | `services` |
| `GET /alerts/history` | `alerts` |
| `GET /alerts/silences` | `silences` |
| `GET /releases` | `releases` |
| `GET /errors/groups/{group_id}/releases` | `breakdown` |
| `GET /errors/groups/{group_id}/merged-sources` | `sources` |
| `GET /incidents` | `incidents` |
| `GET /evaluation/release-series` | `releases` |
| `GET /evaluation/datasets/{datasetId}/versions/{version}/items` | `items` |
| `GET /account/api-keys` | `api_keys` |
| `GET /oncall/schedules` | `schedules` |
| `GET /oncall/schedules/{id}/overrides` | `overrides` |
| `GET /users/me/contact-methods` | `contact_methods` |
| `GET /escalation-policies` | `policies` |
| `GET /flags` | `flags` |
| `GET /flags/local-evaluation` | `flags` |
| `GET /group-types` | `group_types` |
| `GET /llm/pricing` | `overrides` |
| `GET /dashboards` | `dashboards` |
| `GET /connections` | `connections` |
| `GET /connections/{id}/identity-mappings` | `mappings` |
| `GET /issues/{group_id}/tickets` | `tickets` |
| `GET /notifications/channels` | `channels` |
| `GET /notifications/templates` | `templates` |
| `GET /evaluation/evaluators` | `evaluators` |
| `GET /evaluation/sampling-rules` | `rules` |
| `GET /evaluation/datasets` | `datasets` |
| `GET /evaluation/experiments` | `experiments` |
| `GET /evaluation/gates` | `gates` |

**PATCH bodies are partial.** On these control-plane routes, an omitted mutable field keeps its
stored value; it is not replaced by that field's empty or false default:

```http
PATCH /api/v1/alerts/rules/{id}
PATCH /api/v1/alerts/silences/{id}
PATCH /api/v1/account-segments/{id}
PATCH /api/v1/notifications/channels/{id}
PATCH /api/v1/notifications/templates/{id}
```

This distinction applies to scalar, object, map, and list fields. For example, a channel body with
only `config` keeps its `name` and `is_enabled` state; `"is_enabled": false` explicitly disables it.
A template rename keeps its `subject` and `template`. A rule severity edit keeps its pause state,
labels, notification channels, template, and escalation policy. A segment rename keeps its
`group_type` and `filters`, and extending a silence window keeps its `comment`. An explicitly empty
value is still an edit when that value is valid: for example, `"notification_channels": []`
detaches every channel, while `"filters": []` deliberately changes an account segment to match
every account of its group type. Omit a field when you intend to preserve it.

Rule PATCHes must include the current `version`; a stale value is rejected rather than overwriting a
newer edit. These PATCH routes reject unknown JSON fields with `400 invalid_request`, so a misspelled
safety-sensitive field cannot look as if it was applied.

`GET /api/v1/invitations?status=` accepts the exact invitation enum names returned by the API:
`INVITATION_STATUS_PENDING`, `INVITATION_STATUS_ACCEPTED`, `INVITATION_STATUS_REJECTED`, or
`INVITATION_STATUS_EXPIRED`. An unknown non-empty spelling returns `400 invalid_request`; it never
widens into the unfiltered invitation list.

### Alerting and notification behavior

#### A forced evaluation can report that it measured nothing

```http
POST /api/v1/alerts/rules/{id}/evaluate
```

An aggregate over a window that matched no rows is not a value. `max`, `min`,
`avg` and the percentiles have no answer over an empty window, and the number
that comes back for them is a placeholder rather than a measurement — so the
result carries `"unmeasured": true`, an `unmeasured_reason`, `"rows_scanned": 0`,
and `"observed": null` on each condition. Nothing fires on such a tick, and
nothing resolves either: an alert the rule already had stays open and picks up an
`unmeasured_since` timestamp naming when its evidence went away, because "no data"
is not "the problem cleared".

A third case exists and is distinct from both: if the window held rows but the
aggregated column was NULL in every one of them, you get `"unmeasured": false`
with `"observed": null` — the platform looked, and the reducer had no answer.
Read the two fields as a pair.

**Read `observed`, not `value`.** `value` is `0` both when nothing was measured
and when the measurement really was zero; only `observed: null` tells the two
apart.

`count` and `sum` are not affected — the count of an empty window is zero and the
sum of an empty window is zero, and both are answers. Use a `count`-based
comparison, or an `absence` condition, when you want a rule that pages *because*
a window is empty.

#### An error budget is readable without being paged

```http
GET /api/v1/slo/{rule_id}/burn
```

An SLO rule states a quality objective. This answers the two questions an alert on it
cannot: how much of the error budget is left, and how fast is it going. The budget is
`(1 - target)` times the evaluations in the compliance window, reported as a count of
**events** — "18 of 30 failing runs remain" is actionable in a way that a percentage is
not. The **burn rate** of a window is that window's own failure ratio divided by the ratio
the objective allows — `(failing / total) / (1 - target)` — so a rate of `1` spends the
budget exactly over the compliance period and `2` spends it in half. It does not depend
on how many evaluations happened to land in the window, so a quiet hour and a busy one
with the same failure ratio report the same burn.

Everything defaults from the rule's own burn policy. `?window=6h&window=30m`, `?target=`,
`?compliance_window=` and `?factor=` override it when you want to ask a different
question of the same objective.

A window holding **no** evaluations comes back with `status: "SLO_BURN_STATUS_NOT_MEASURED"`
and an `unmeasured_reason`, never a `burn_rate` of `0`. Those mean opposite things: zero
says the objective is healthy, unmeasured says nothing was evaluated. `would_fire` is the
same decision the rule itself makes — both the long and the short window over `factor` —
and is `false` with a reason whenever that decision could not be made at all.

#### Alert acknowledgement is reversible

```http
POST /api/v1/alerts/{id}/acknowledge
POST /api/v1/alerts/{id}/unacknowledge
GET  /api/v1/alerts/acknowledgments?alert_id=...
GET  /api/v1/alerts/history?rule_id=...
```

Acknowledging an alert suppresses its escalation, so the ability to withdraw one matters: a
mis-click no longer permanently silences a real alert. Acknowledgements are a **sequence**, not a
flag — the same alert can be acknowledged and withdrawn more than once, and
`/alerts/acknowledgments` returns each one with its actor, time and note. The current state is the
single boolean on the alert; the history is what a post-incident review needs.

`/alerts/history` is anchored by `rule_id` or `alert_id`. Anchor on the rule to answer "how often
has this fired", which is the question that usually matters.

#### Silences can be read and edited, not only created and deleted

```http
GET   /api/v1/alerts/silences/{id}
PATCH /api/v1/alerts/silences/{id}
```

Editing a silence keeps its identity and its audit trail. Deleting and recreating one to change its
window does not, so prefer the PATCH. The silence's author is preserved across edits — editing does
not transfer authorship. Send only the fields being changed; omitted matchers, window endpoints,
name, and comment remain unchanged.

#### Notification channels can be edited without restating their alerting state

```http
GET    /api/v1/notifications/channels
POST   /api/v1/notifications/channels
GET    /api/v1/notifications/channels/{id}
PATCH  /api/v1/notifications/channels/{id}
DELETE /api/v1/notifications/channels/{id}
POST   /api/v1/notifications/channels/{id}/test
```

The PATCH is partial. Updating only a webhook URL or other `config` value leaves the channel's name
and enabled state unchanged. Send `is_enabled: false` only when the channel should stop delivering.

#### Notification templates decide what a channel says

```http
GET    /api/v1/notifications/templates
GET    /api/v1/notifications/templates?channel_type=slack
GET    /api/v1/notifications/templates/effective?channel_type=slack
POST   /api/v1/notifications/templates
GET    /api/v1/notifications/templates/{id}
PATCH  /api/v1/notifications/templates/{id}
DELETE /api/v1/notifications/templates/{id}
```

`/effective` answers a different question from `/{id}`: it is the template that **would be used**
for a channel type right now, which is what you want when checking what a Slack notification will
actually look like. Asking for a template by id tells you about that template, which is not
necessarily the one that would be sent.

Template PATCHes are partial too. A body such as `{"name":"Primary page"}` changes only the name;
omitted `subject` and `template` fields retain their stored content.

- Use IDs returned by the API for updates and deletes; do not derive them from display names.
- A write normally answers `200`: the change is committed and a read issued straight afterwards
  already reflects it. A write whose effect is served from a list that is rebuilt just behind it can
  answer `202` instead. `202` is a success and the change is committed and permanent — what it adds
  is that the corresponding read has not been confirmed to agree yet, so a read issued immediately
  afterwards may briefly still show the previous value. Re-read to see the settled state; do not
  repeat the write. `DELETE /account/members/{user_id}` is the endpoint that answers this way today,
  and repeating it returns `404` because the member is already gone.

## Errors and retries

Errors use a JSON envelope:

```json
{
  "error": "PermissionDenied",
  "message": "permission denied"
}
```

The `error` string comes from one of three families, and which one you get says where the refusal
was decided: most codes are a downstream gRPC status name forwarded verbatim in `PascalCase`
(`PermissionDenied` for ordinary insufficient-scope, `NotFound`, `InvalidArgument`, and so on); any
`5xx` always answers with a fixed, redacted lowercase code (`internal_error` or `unavailable`)
regardless of the underlying fault; and a handful of refusals Edge decides itself before calling a
downstream service — a malformed request body, a project-scope conflict (lowercase
`permission_denied`), or an API-key project-floor refusal (`forbidden`) — use their own lowercase
`snake_case` code. Match on the exact string; casing alone is not a reliable signal.

Treat `400` as invalid input, `401` as a missing or invalid credential, `403` as insufficient scope,
`404` as missing or inaccessible, `409` as a state conflict, and `429` as a rate or plan limit. Retry
`429` and temporary `5xx` responses only with bounded exponential backoff, honoring `Retry-After`
when present. Do not automatically retry validation or permission failures.

**A `409` never becomes a success on its own.** It covers two situations, and neither is fixed by
sending the same request again: something you named is already taken — a connection for that
provider, an escalation policy or on-call schedule with that name in that project, a contact method
with that address for that user — or the version you supplied is stale. Change the request (a
different name, or a re-read version) or change the state (retire what is holding the name), then
send it once. An agent that retries a `409` unchanged will loop until it gives up.

**Some `400` responses are not about your request.** A capability whose backing store is not
present in your deployment answers `400` with a message naming the capability — "error reads are not
available in this deployment", "session replay is not available in this deployment". Sending a
different request will not change the answer; the capability is absent for everyone on that
deployment until an administrator enables it. Distinguish it from a validation failure by the
message: a validation failure names a field of your request, this names a product capability.

**No error message names the software behind Anectico.** Message text describes your request or a
product capability, never a database, queue, cache, identity provider or internal component. If you
are matching on message text, match on the stable leading phrase, and prefer `error` and the HTTP
status class over free text in every case.

Every `2xx` is a success, `202` included — branch on the status class, not on equality with `200`,
or a committed write will read as a failure. See
[Request and response conventions](#request-and-response-conventions) for what `202` adds.

### When a request body will not parse

A body that cannot be read into the endpoint's shape returns `400 invalid_request` with a
diagnostic in parentheses, so a client — or an agent acting on its own — can tell a malformed
request from a server fault and correct it without a second round trip:

```json
{
  "error": "invalid_request",
  "message": "request body is not valid JSON for this endpoint (line 1, column 24; invalid_value; type google.protobuf.Timestamp)"
}
```

Read it as follows.

- The text before the opening parenthesis is stable. Match on that, or on `error`, rather than on
  the parenthetical.
- `line` and `column` locate the failure in the body you sent, counting from 1.
- The middle term names the kind of failure: `syntax` (the bytes are not JSON), `truncated` (the
  body ended early), `type_mismatch` (a value of the wrong JSON type), `invalid_value` (the right
  type, but not a value the field accepts — a timestamp that is not RFC 3339, for example),
  `duplicate_field`, `unknown_field`, or `unclassified`.
- `field` and `type`, when present, name the field that rejected the value and the type it expects.
  Either may be absent when the failure happened before a field could be identified.

**The diagnostic never contains the value you sent.** It carries only a position in your request
and names drawn from the published API schema, so a malformed body carrying customer data does not
come back in the error — and does not reach a log. If a value is being rejected and the reason is
not obvious from the field and type, compare against the field's documented format rather than
expecting the value to be echoed.

The most common cause of `invalid_value` is user-supplied data reaching a typed field: an email, an
identifier or a token placed where a timestamp or duration belongs. Validate those fields before
sending rather than relying on the rejection.

## Discover exact operations

The installed CLI mirrors the public surface and can print its current command contract:

```bash
anectico docs             # JSON manifest for tools and agents
anectico docs --markdown  # readable command and flag reference
```

Use the current CLI manifest or dashboard network contract for exact early-access request bodies.
Do not copy internal gRPC or service routes into an external integration.

- [Authentication and API keys](/docs/reference/authentication)
- [Permission scopes](/docs/reference/permissions)
- [Metrics REST API](/docs/reference/metrics-api)
- [OpenTelemetry](/docs/instrument/opentelemetry)


## Capture-attempt quality

`GET /api/v1/events/capture-quality` requires `analytics:read`. Supply `project_id` for an
organization credential; a project credential uses its signed project and cannot select another.
`start_time` and `end_time` are required RFC3339 timestamps, at millisecond precision or coarser,
forming an increasing half-open event-time window from 1970 of at most 183 days. Optional
`environment` selects one exact environment; empty selects all. Unknown or repeated parameters fail.

The response separates `product` and identity `control` positions into `pending`, `queued`,
`uncertain` and `not_attempted`, with separate `invalid` and `quota_rejected` counts. These are
**submitted positions**, including retries, not unique events or lost user actions. Valid input is
attributed by coarse UTC-hour overlap, so a short window can include adjacent events in the same
hour. Invalid input has no trustworthy time/environment and covers all retained attempts for this
project. Attempts without a project are not assigned to it.

`source_completeness` is always `unknown`: queued does not mean consumed, zero does not prove
absence of loss, and client sampling declarations do not establish actual upstream coverage,
withholding, retention availability or processing progress. `retained_project_requests` is the number of retained requests examined,
including those outside the selected window. `digest` is opaque current evidence that can change
when any retained project attempt changes; it is not a saved result ID or authorization grant.
The current whole-read ceilings are 10,000 requests, 8 MiB and 100 distinct sampling source/rate
groups in the selected window; exceeding a ceiling returns HTTP 429
without a partial count. Storage failures, deleted scopes and invalid requests also fail instead of
returning an apparently empty result. Deletion propagation and physical cleanup are asynchronous.

`sampling_provenance` is `declared`. Each entry in `sampling_declarations` contains `source`,
`rate`, `state` (`declared_sampled` for rates below one, `declared_unsampled` for one), `product`
publication counts and `quota_rejected`. `sampling_unknown` contains the remaining undeclared
product publication and quota counts. Declarations plus unknown equal the corresponding product
and quota totals; identity-control and invalid positions are excluded. Entries are ordered by
source, then numeric rate. `sampling_declaration_limit` reports 100. These are submitted positions,
not a count of unique stored events, sampled-away events or authorized source identities. The
same environment and coarse hour-overlap selection applies. See
[declaring sampling](/docs/instrument/diagnostic-events#declare-product-event-sampling) for the
reserved capture property and validation rules. The complete request evidence, including these
declarations, participates in the digest; a changed retry cannot overwrite an earlier stored event.

The same operation is available through `anectico analytics quality --start-time ... --end-time ...`
and the MCP `get_capture_quality` read action (also requiring `mcp:read`).

## Windowed event catalog

`GET /api/v1/events/catalog` requires `analytics:read` and an active project. Select
`project_id` for organization credentials; project credentials cannot select another
project. Required `start_time` and `end_time` are increasing RFC3339 timestamps with
millisecond precision or coarser, forming `[start,end)` within currently readable
history and at most 90 days. A future `end_time` is allowed, but does not establish
completeness for the future portion of the window. Optional `environment` is exact (empty selects all),
`prefix` is literal and case-sensitive, `limit` defaults to 50 (maximum 200), and
`cursor` continues a previous page. Unknown or repeated parameters are rejected.

The response includes the effective `org_id`, `project_id`, selection, and `events`
ordered by name. Each event has `name`, converged occurrence `count`, `first_seen`
and `last_seen`, all limited to the selected window. Properties, examples and person
identifiers are not returned. `next_cursor` accompanies `has_more`; preserve all
filters and page size when continuing. Cursors expire 15 minutes after the initial
read without renewal. Start a fresh read after expiry.

`ingested_before` preserves the initial receipt cutoff; `observed_from` and
`observed_until` describe this page's database read. These do not freeze the source
or prove consumption. Late consumption and deletion may change later pages.
`platform_completeness` and `upstream_completeness` are both `unknown`, even for an
empty page. Results are returned with `Cache-Control: no-store`.

Invalid selections/cursors and expired or unavailable project history fail. A
resource-budget refusal returns HTTP 429 without partial counts; narrow the window
or prefix. Use [the event catalog guide](/docs/investigate/events-and-cohorts) for
MCP `get_event_catalog` and CLI `analytics catalog` usage.


### Typed event property schema

`GET /api/v1/events/schema` requires `analytics:read`. Provide `project_id`, exact
`event`, inclusive `start_time`, exclusive `end_time`, and optional exact
`environment`. Project credentials cannot select a different project. Windows use
millisecond RFC3339 timestamps and span at most 90 days within current project history.

The version-1 JSON response echoes the selection and returns `observed_events`,
`properties` (key, present/missing, nonzero JSON type counts, finite_numbers,
window-local first_seen/last_seen), `visibility: "permitted_stored_properties"`,
receipt/read clocks and both completeness fields set to `unknown`. There are no
raw values/examples or hidden-key lists. Content-derived summaries require the
requesting content scope and current project policy/class retention. Empty schemas
and omitted fields do not prove absence. Results use `Cache-Control: no-store`.

There is no pagination or successful truncation. Current limits are 250 distinct
keys of up to 512 UTF-8 bytes, 250,000 events, 8 MiB source strings and a ten-second service deadline,
plus database limits. Exact environment strings allow up to 32,768 UTF-8 bytes and are not trimmed.
A budget error returns 429; a narrower window can reduce rows/bytes/property diversity,
but cannot shrink one event's property bag. Temporary source unavailability returns
503 with `Retry-After: 1`. Unavailable
current policy/history or a mid-read policy change returns 400 (`FailedPrecondition`); no evidence is
substituted with zero. See [event property schemas](/docs/investigate/event-schema)
for field semantics and the equivalent `get_event_schema` MCP action and
`anectico analytics schema` command.


### Tracking-plan configuration reads

`GET /api/v1/analytics/tracking-plans` lists live metadata; `GET /api/v1/analytics/tracking-plans/{plan_id}` reads a current or retained definition. Both require
`tracking_plans:read` and exact project scope. Lists accept `limit` (default 50, maximum 100) and
`after_plan_id`; detail accepts `revision` (0=current). These are non-cacheable configuration
reads, not telemetry queries. See [Manage tracking plans](/docs/investigate/tracking-plans).

`GET /api/v1/analytics/insights` lists/searches saved-insight metadata with `project_id`, optional
`search`, `limit` (0 defaults to 50; maximum 100) and `after_insight_id`.
`GET /api/v1/analytics/insights/{id}` returns current or exact retained configuration with
`project_id` and optional nonnegative `revision` (0 selects current). Both require `insights:read`
and recheck current credentials before disclosure. List returns `insights`, `has_more` and
`next_after_insight_id`; get returns `insight`. Responses use protobuf JSON with integer revisions
encoded as strings. See [Manage saved insights](/docs/investigate/saved-insights).

`POST /api/v1/analytics/insights/preview` and `/api/v1/analytics/insights/apply` provide the human
dashboard write path. They require a current user session and `insights:read` plus `insights:write`
(create/update) or `insights:delete` (retire). API keys and delegated OAuth must use the confirmed
MCP actions. No query parameters are accepted; bodies are strict `ApplySavedInsightRequest` protobuf
JSON: `project_id`, `insight_id`, `mutation_key`, exact decimal-string `expected_revision`, `title`,
`description`, `definition`, and `delete`. Create uses revision `"0"`; update/delete require the
selected positive revision. Retirement omits metadata and definition. Organization comes from the
verified principal. Responses are non-cacheable.

Preview returns the validated `mutation`, `before` or prior `committed` receipt, and
`definition_hash`; it changes nothing. Apply repeats validation and returns `insight` plus `applied`
(false for an exact durable retry). Preserve one mutation key and body through retries. CAS conflicts
and unavailable references are refused. The verified receipt supplies the semantic operation, exact
project/target/revision and applied flag for the normal account audit path; audit delivery remains
separate from the Analytics commit.
`POST /api/v1/analytics/tracking-plans/preview` and `/api/v1/analytics/tracking-plans/apply`
require a current human session, `tracking_plans:read` and the operation's write/delete scope.
Agents use confirmed MCP actions, also available through CLI. Strict protobuf JSON contains
`project_id` and `mutation` (`plan_id`, `mutation_key`, decimal-string `expected_revision`,
`name`, `definition`, `delete`). Preview changes nothing; apply returns `plan`, `mutation_key`
and `applied`. Retire with an exact positive revision and no name/definition. Sensitive
protections persist. Unknown/repeated fields and query parameters are refused. All tracking
read/write responses use exact decimal-string revisions. See [Manage tracking plans](/docs/investigate/tracking-plans).

Analytics history refusals use HTTP 400 with `error: "FailedPrecondition"` and
`details: {"reason": "ANALYTICS_HISTORY_UNAVAILABLE"}`. This applies to query, result,
participant and contribution reads. Choose a supported recent window and audience source
selection and explicitly query with a new execution key; repeating an expired frozen execution
cannot recover history. Other precondition failures do not imply this reason. See
[analytics history recovery](/docs/investigate/product-analytics#when-history-is-no-longer-readable).

A measurement kind this release cannot run uses the same HTTP 400 and `error: "FailedPrecondition"`
with `details: {"reason": "ANALYTICS_KIND_UNAVAILABLE"}`. The definition is valid, so editing it and
resubmitting cannot succeed, and no `result_id` is issued to poll. Trends, person funnels and
exact-period retention all run in this release.

A project holding stored events that were never accepted through capture uses the same HTTP 400 and
`error: "FailedPrecondition"` with `details: {"reason": "ANALYTICS_SOURCE_UNADMITTED"}`. Those events
have no record of being received, so no measurement over them can be certified. The request is
valid, so editing it, narrowing the window or retrying cannot succeed; send the events through the
capture endpoint or an SDK instead of writing, backfilling or importing them into storage directly.
A saved definition that reaches the background executor reports the same cause as
`PRODUCT_RESULT_FAILURE_REASON_SOURCE_UNADMITTED`.

A deployment with no storage capacity left answers HTTP 429 with `error: "ResourceExhausted"` and
`details: {"reason": "ANALYTICS_STORAGE_EXHAUSTED"}`. Branch on the reason, not the status: this 429
is not a rate limit, so `Retry-After` semantics and backoff do not apply and no amount of waiting
clears it. The request is valid, so editing it, narrowing the window or retrying cannot succeed;
contact whoever operates the deployment to make room. A saved definition that reaches the background
executor reports the same cause as `PRODUCT_RESULT_FAILURE_REASON_STORAGE_EXHAUSTED`.

### Frozen analytics audience preview

`GET /api/v1/analytics/results/{result_id}/audience-preview?project_id=PROJECT_UUID&selection_id=SELECTION_UUID`

Read-only, `Cache-Control: no-store`. Requires current analytics/person/source read permissions.
Only `project_id` and `selection_id` query parameters are accepted. Returns the complete selection's
exact population counts, `state` (`eligible`, `unresolved`, `unsupported`, `too_large`), provisional
`member_limit`, source scopes and original result/snapshot/computation/expiry references. It returns
no member IDs and creates no cohort. See [audience preview](/docs/investigate/product-analytics#preview-a-reusable-audience).

### Exact audience generations and dashboard writes

`GET /api/v1/analytics/audiences/{id}?project_id=PROJECT_UUID&generation=1` requires an exact
positive decimal generation and current analytics, person and source read permissions.
It returns `audience` with its immutable source references, count, lifetime and generation;
it does not return member IDs. Missing, expired, pruned or inaccessible generations are refused.

`POST /api/v1/analytics/audiences/preview` and `/api/v1/analytics/audiences/apply` require a
current human user session and the same lifecycle permissions as the confirmed MCP actions.
API keys and delegated OAuth are refused with HTTP 403 and must use MCP or CLI confirmation.
No query parameters are accepted. Strict protobuf JSON bodies contain `project_id`, UUID
`mutation_key`, `result_id`, `selection_id`, `cohort_id`, decimal-string `expected_generation`,
`name`, `description` and integer `lifetime_seconds` (1–2,592,000). Create uses empty `cohort_id`,
generation `"0"` and a unique project name. Replacement uses the exact positive generation
and empty metadata. Organization comes from the verified principal, never a different tenant.

Preview returns the validated `mutation`, full `member_count`, `source_scopes`, and `before` or
`committed` when applicable. It reserves nothing. Apply repeats validation and returns `audience`
and `applied` (false for an exact durable retry). Preserve identical arguments and the original
mutation key after an uncertain response. The full selection must contain at most 10,000 resolved
people; unresolved or account populations are refused. All these responses use `Cache-Control:
no-store`. See [save and reuse an audience](/docs/investigate/product-analytics#save-and-reuse-an-exact-audience).


### Explicit analytics remeasurement

`POST /api/v1/analytics/remeasurement/source` prepares an ad-hoc query template from an exact
original result, explicit `population_mode` (`fresh_population` or `saved_audience`), and absolute
`next_window` (`start`, `end`). Saved mode requires `audience` with exact `id` and decimal-string
`generation`. Optional `next_comparison` replaces the comparison; omission clears it.
Execute `definition_template` with the ordinary analytics query endpoint and a new execution key.
`POST /api/v1/analytics/remeasurement/compare` accepts the identical source request under `source`
and the exact `later_result_id`. Both endpoints are read-only, accept no query parameters and
require current analytics/person/source permissions. Query execution separately requires
`analytics:query`. Comparison retains separate original/later evidence and refuses a changed
question. See [remeasurement](/docs/investigate/product-analytics#remeasure-an-explicit-original-result)
for retry, saved-generation, expiry and unavailable-original semantics.


### Tracking-plan property drift

`GET /api/v1/analytics/tracking-plans/{plan_id}/drift` compares an explicitly pinned positive
`revision` with permitted stored observations for exact `event`, `start_time`, `end_time`,
optional `environment` and selected `project_id`. Requires `tracking_plans:read` and
`analytics:read`; content policy still governs disclosed property rows. The response contains
exact source coordinates, the permitted schema observation and per-property comparison rows.
Unavailable evidence has null counts. Reads are non-cacheable, repeated/unknown parameters
are rejected, and current authority/retained declaration are rechecked before disclosure.
See [tracking-plan drift semantics](/docs/investigate/tracking-plans#observe-drift-for-a-pinned-event).
