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

# Callback gate

> Pause for one structured answer through a replaceable router.

A callback gate records a question that code cannot answer by itself. A router
claims the pending request, asks a person or service, and submits one structured
answer. The callback client records each step so another process can rebuild the
same state.

## Run the example

From an Obversa checkout, run:

```bash theme={null}
pnpm example:callback-gate
```

The example proves that only one router can hold a claim, a released request can
be claimed again, an accepted answer survives replay, and changing the question
bytes creates a new request ID.

```json theme={null}
{
  "requestId": "release-approval#1#3a32b8f32a48c2b1ddc28ecec2c3035d4619311acf96364200e8c25efe94cd05",
  "digest": "3a32b8f32a48c2b1ddc28ecec2c3035d4619311acf96364200e8c25efe94cd05",
  "sameQuestionId": true,
  "changedQuestionId": true,
  "blockedKind": "claimed",
  "released": true,
  "submitted": true,
  "events": [
    "callback-requested",
    "callback-claimed",
    "callback-released",
    "callback-claimed",
    "callback-submitted"
  ],
  "replayedPending": 0
}
```

## Source

```ts theme={null}
import {
  createCallbackClient,
  createCallbackGate,
  directRouter,
  replayCallbackClient,
  type CallbackGateDefinition,
} from '@obversa/runtime';

const definition: CallbackGateDefinition = {
  gateId: 'release-approval',
  gateVersion: 1,
  decisionText: 'Approve release abc123?',
  responseSchema: {
    type: 'object',
    properties: { approved: { type: 'boolean' } },
    required: ['approved'],
  },
  input: { revision: 'abc123' },
};

const client = createCallbackClient();
const request = createCallbackGate(definition);
client.post(request);

const firstClaim = client.claim(request.requestId, 'router-a');
const blockedClaim = client.claim(request.requestId, 'router-b');
if (!firstClaim.ok) throw new Error('The first router did not claim the request.');
const released = client.release(request.requestId, firstClaim.claimToken);
const submitted = await directRouter(
  client,
  request,
  'router-b',
  () => ({ approved: true }),
);

const sameQuestion = createCallbackGate({
  ...definition,
  presentation: { theme: 'dark' },
});
const changedQuestion = createCallbackGate({
  ...definition,
  input: { revision: 'def456' },
});
const replayed = replayCallbackClient(client.history());

console.log(JSON.stringify({
  requestId: request.requestId,
  digest: request.digest,
  sameQuestionId: sameQuestion.requestId === request.requestId,
  changedQuestionId: changedQuestion.requestId !== request.requestId,
  blockedKind: blockedClaim.ok ? null : blockedClaim.kind,
  released: released.ok,
  submitted: submitted.ok,
  events: client.history(request.requestId).map((event) => event.kind),
  replayedPending: replayed.listPending().length,
}, null, 2));
```

The clean-consumer check compiles and runs this exact source from the packed
package with TypeScript 6 and TypeScript 7.

## Request identity

The request digest covers the gate ID and version, the question text, the
response schema, and the input bytes. The request ID contains that digest. The
same question therefore keeps the same ID across process restarts. Changed
question bytes produce a new ID. Presentation hints do not change either value.

## Claim, submit, and release

1. **Post:** `post` records the request as pending.
2. **Claim:** `claim` assigns it to exactly one router and returns a claim
   token. The token is recorded in the replayable event history.
3. **Submit:** `submit` accepts an answer only from that router with that token
   and the posted digest.
4. **Release:** `release` returns a claimed request to the pending list. The
   `directRouter` helper also releases the claim if its responder throws or
   submits an invalid response.

Claim refusals report `missing`, `claimed`, `answered`, or `superseded`.
Submission refusals report `missing`, `not-claimed`, `not-owner`, `stale`, or
`invalid`. Release refusals report `missing` or `not-owner`.

## Validation and storage limit

The built-in validator checks required top-level fields and the standard JSON
types declared for top-level fields. It accepts a single type or a type union.
It is not a full JSON Schema validator. A host that needs nested constraints
must validate them before it submits the answer.

`createCallbackClient` keeps its event history in memory. Replay proves the
history contains all callback state, but the host must store that history when
requests must survive a process exit.

## Store callbacks with a run

`createStoredCallbackClient(storage, runId)` writes callback history into the
run's event stream. Opening a new client on the same run rebuilds pending,
claimed, released, submitted, and superseded state from those events.

The stored client uses the event stream revision when it changes state. Two
routers that race for one request therefore produce one winning claim. A
storage failure does not leave an in-memory answer that was never recorded.

Stored replay accepts only the exact fields for each callback event. A
`callback-submitted` event contains `kind`, `requestId`, `requestDigest`,
`routerId`, and `response`. The request digest must be 64 lowercase
hexadecimal characters and match the posted request. A submission without
`requestDigest`, or with an extra field, does not replay.
The runtime does not convert that stored history.

The stored client records the answer. The host must turn that answer into a
graph node result. The graph executor does not do this.

## Bind approval to exact bytes

`createApprovalCallbackGate(definition, subject)` adds the subject digest to
the request input before it creates the request ID. The subject names the input
artifact hashes, proof scope and artifact, proposed output bytes, effective
permissions, and a workspace anchor or `null` when no workspace is bound.
Changed subject bytes therefore create a different request.

Post that request with the same subject:

```ts theme={null}
const request = createApprovalCallbackGate(definition, subject);
await callbacks.post(request, subject);
```

The stored client refuses a missing or different subject with
`ApprovalSubjectError`, whose code is `SUBJECT_MISMATCH`. A subject-backed
submission also needs caller-supplied actor data and an action decision of
`allow`, `wait`, or `deny`. The client validates the response, then writes the
callback submission and approval record in one event batch.

A repeated plain submission after the request is answered returns the typed
`not-claimed` result. For a subject-backed request, a missing actor or invalid
action decision returns the typed `invalid` result.

After a valid subject-backed answer, the same submission from the claim owner
returns success and adds no event. A different valid decision or actor from
that owner throws `StorageError` with `REVISION_CONFLICT`. A wrong claim owner
returns the typed `not-owner` result. A changed request digest from the claim
owner returns the typed `stale` result.

`resolveApproval(storage, runId, { request, ...subject })` returns the stored
decision only while the request, run plan, graph, proof, inputs, proposed
output, permissions, and workspace anchor still match. Otherwise it returns a
`wait` decision. The actor data records what the caller supplied; this API does
not authenticate that actor. An approval record does not apply the proposed
change.
