# Feature-flag exposure verification

> Prove that your application recorded the exact flag decisions of a controlled test, under which definition version, and whether a local and a remote decision are comparable.

Canonical page: https://anectico.com/docs/reference/flag-integration-verification/


After your agent wires feature flags into an application, it runs a controlled test and asks
Anectico: did the application record **these exact flag exposures**, with **this value**, under
**this flag definition version**, and are the local and the remote decision of one test case
**comparable**? The verification read answers for up to 20 exposures at a time. It changes nothing,
stores no report and runs no model.

An exposure is a `$feature_flag_called` event your application recorded when it used a flag value.
It is **application-reported use**: it never proves that a person saw the treatment, and it says
nothing about code paths that record no exposure. A decision your agent fetches itself (for example
with `decide_feature_flags` or `anectico flags decide`) checks Anectico's evaluation, not your
application; it is never evidence here.

| Surface | How |
| --- | --- |
| REST | `POST /api/v1/instrumentation/flags/verify` with the JSON request as the body |
| MCP | `execute_read_action` with action `verify_flag_integration` and the JSON request as arguments |
| CLI | `anectico doctor flags --file request.json` (plus the usual `--project` and `--json`) |

Project-bound keys may omit `project_id`; naming a different project is refused. The CLI refuses a
file whose `project_id` contradicts `--project`.

## What the application records

Each served flag read records one exposure with these reserved properties. The JavaScript SDK
(server-side, with a flag-read key held only in server configuration) records them for you; an
application without an SDK sends them itself with the capture API.

| Property | Meaning |
| --- | --- |
| `$feature_flag` | The flag key. |
| `$feature_flag_response` | The value used: `true`, `false` or a variant string. `false` and an ordinary default are values. |
| `$feature_flag_version` | The exact flag definition version the value was evaluated under, for example `2026-10-15T09:30:12.123456Z` (UTC, six fractional digits). Only for a successful evaluation; never for a fallback or a bootstrap-only value. It versions the flag definition, not person properties, groups, cohorts or audiences. |
| `$feature_flag_evaluation_status` | `evaluated`, `evaluation_error`, `unavailable_flag` or `unsupported_targeting`. Missing or unknown is never treated as success. |
| `$feature_flag_is_fallback` | `true` when the value is an error or dependency fallback. Missing means unknown, not `false`. |
| `$feature_flag_evaluation` | `remote` or `local`. |
| `$feature_flag_snapshot_version` | The local snapshot the value came from (local only). |
| `$feature_flag_test_case_id` | Your opaque test-case ID, at most 200 printable bytes. It correlates; it proves nothing about execution or inputs. |
| `$feature_flag_input_context` | `{ "basis", "required_inputs_present", "input_binding" }`, at most 2 KiB, never a property value. |

`basis` is `explicit_inputs` when the evaluation used only inputs your application supplied,
`hydrated_context` when a remote evaluation also used stored person properties, groups, cohort or
audience membership, and `unestablished` when unknown. A remote decision reports its own basis per
flag; it is `hydrated_context` whenever stored context was consulted, even if your supplied values
match it. `required_inputs_present` is `true` when your application supplied every targeting input
the flag names.

`input_binding` is your application's own declaration of the explicit inputs it evaluated with:
`sha256:` followed by 64 lowercase hex characters, the SHA-256 of this UTF-8 JSON text with object
keys sorted by code point at every level, arrays in order, no whitespace, absent keys omitted and
`null` kept:

```json
{"distinct_id":"test-person","flag_key":"checkout_v2","format":"anectico.flag_inputs.v1","groups":{},"person_properties":{"plan":"pro","seats":3,"trial":null},"project_id":"22222222-2222-4222-8222-222222222222"}
```

Absent `person_properties` or `groups` are written as `{}`. Anectico compares two bindings for
equality only; it never recomputes or authenticates them.

### Where the version comes from

`POST /api/v1/decide` returns, besides `feature_flags`, `project_id`, `feature_flag_versions` (the
version per evaluated flag) and `feature_flag_results` (per flag: `evaluation_status`,
`is_fallback` and `input_context` with `basis` and `required_inputs_present`). A flag whose stored
context could not be read takes its error path: its default value with `evaluation_error`,
`is_fallback: true` and no version. `errors_while_computing_flags` remains an aggregate and does not
describe one flag. The local-evaluation snapshot carries `feature_flag_versions` too.

## The request

```json
{
  "window_start": "2026-10-15T09:00:00Z",
  "window_end": "2026-10-15T10:00:00Z",
  "exposures": [
    {
      "message_id": "test-run-42-flag-local",
      "source_id": "7f1e2d3c-4b5a-4968-8776-5544332211aa",
      "flag_key": "checkout_v2",
      "expected_value": true,
      "expected_flag_version": "2026-10-15T09:30:12.123456Z",
      "evaluation_mode": "local",
      "test_case_id": "case-42",
      "expected_evaluation_status": "evaluated",
      "expected_is_fallback": false,
      "expected_input_context": {
        "basis": "explicit_inputs",
        "required_inputs_present": true,
        "input_binding": "sha256:a3a347b524720b40b8f3ccf93b65a347f7d558d302905652a2949a85fbbaa92a"
      },
      "distinct_id": "test-person"
    }
  ]
}
```

| Field | Meaning and bounds |
| --- | --- |
| `window_start`, `window_end` | Event-time window `[start, end)`, millisecond precision, at most one hour. |
| `exposures` | 1 to 20; the answer keeps their order. Each `(message_id, source_id)` appears once. |
| `message_id`, `source_id` | The exact `message_id` the exposure was sent with and the source that admitted it (as for [event verification](/docs/reference/integration-verification)). |
| `flag_key` | The exact flag key. |
| `expected_value` | Required: `true`, `false` or a variant string. `false` is an expectation; `null` is refused. |
| `expected_flag_version` | Required exact version in the six-digit form above. |
| `evaluation_mode` | `remote` or `local`. |
| `test_case_id` | Required. All exposures with the same `flag_key` and `test_case_id` must name the same `distinct_id` and the same `session_id` expectation. |
| `expected_evaluation_status`, `expected_is_fallback` | Required; `false` must be written. |
| `expected_input_context` | Required `basis`; `required_inputs_present` and `input_binding` are compared exactly, including absence. |
| `distinct_id`, `session_id` | The declared identity (checked as an alias) and an optional session (present means check it). |

## The answer

Per exposure, every check has a `state` (`observed_match`, `mismatch`, `not_observed`, `withheld`,
`unavailable`, `ambiguous`) and a `reason`: `event`, `distinct_id`, `session_id`, `identity`,
`flag_key`, `value`, `definition_version`, `evaluation_mode`, `test_case_id`, `evaluation_status`,
`is_fallback`, `input_basis`, `required_inputs_present` and `input_binding`. An exposure without a
recorded version is a `definition_version` mismatch with reason `unversioned`. The `outcome` is
`verified` only when every check matched.

`recorded` shows what the exposure recorded: the value's `value_presence` and `value_type` (the
value itself is never returned), the `definition_version`, `evaluation_mode`, `snapshot_version`,
`evaluation_status`, `is_fallback` and `input_context`, each only when well formed. The recorded
test-case ID is never returned. `current_definition` gives the flag's version now, as context for
a version mismatch; a current definition never certifies an older evaluation.

`comparisons` has one entry per `(flag_key, test_case_id)`:

- `state` is `established` only for exactly one selected `local` and one selected `remote`
  exposure, both fully matched, both `evaluated` with `is_fallback` recorded as `false`, both under
  the same version, both with basis `explicit_inputs`, `required_inputs_present: true` and equal
  `input_binding`. `agreement` then says whether the two recorded values are equal (`agree`) or not
  (`disagree`).
- Otherwise `state` is `not_comparable` with `reasons`: `different_definition_versions`,
  `missing_authoritative_version`, `missing_inputs`, `unsuccessful_evaluation`,
  `unsupported_local_targeting`, `comparison_pair_incomplete`, `comparison_pair_ambiguous` or
  `context_not_established`. These explain why the decisions cannot be compared; they do not mean
  the treatments differ.
- `local_index` and `remote_index` name the exact pair; `input_binding_assurance` is always
  `sender_declared`.

**What an established comparison means:** your application recorded, for both decisions, the same
definition version, successful evaluation and the same declaration of explicit inputs. It does not
authenticate your application, its inputs or any stored context, and two equal fallbacks, a
snapshot digest, a version or a test-case ID alone never establish it. A remote decision that used
stored person, group, cohort or audience context is `context_not_established`; today's context is
never reread as proof of the context at evaluation time.

Local evaluation supports person-property targeting only. Flags that target groups, cohorts or
pinned audiences return `unsupported_targeting` locally (no value, no exposure); evaluate those
remotely.

## Scopes

| Scope | What it adds |
| --- | --- |
| `flags:read` | Required. (MCP also needs `mcp:read`.) |
| `analytics:read` | Required. The exposure read. |
| `persons:read` | Canonical identity. Without it identity is `withheld`. |
| `agents:content:read` | The recorded flag fields. Without it every flag-field check is `withheld` and nothing recorded is returned. The project's content policy, sensitive declarations and stored redactions can still withhold a field. |

A withheld check never lets an exposure verify or a comparison be established. If a scope, the
content policy or the selected exposures change while the read runs, no report is released.

Keep the keys apart: the application uses a project-bound key with only `flags:read` for decisions
and a separate capture key for exposures, both held in server configuration. Your agent's
verification key is a third credential; it grants no capture.

## Limits

The limits are those of [event verification](/docs/reference/integration-verification#limits) (one
hour, 20 exposures, the same scan, memory, time and concurrency caps) and are returned under
`limits`. A reached limit makes every exposure `unavailable`, never `not_observed`. Flag and event
verifications of one project share the two concurrent reads.

## Using it in a test

1. In the application, read each flag with a test-case ID (`getFeatureFlagDetails(key,
   { testCaseId })` remotely, `evaluate(key, distinctId, properties, { testCaseId })` locally) and
   keep the exposure's `message_id`.
2. For a comparison, evaluate locally and remotely with the same explicit inputs and record both.
3. Verify with the versions and bindings your application recorded. If an exposure is
   `not_observed` right after the test, wait briefly and verify again with the same request.
4. Report exactly what was verified and what an established or not comparable result means.

To verify flag exposures as the `flags` job with a single verdict, see
[integration job verification](/docs/reference/integration-job-verification). What a verified exposure
does not prove, and how long to wait, are on [What integration verification proves and what it does
not](/docs/reference/integration-verification-limits).
