> ## 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.

# A Person Decides

> One question to a person as a step. Yes passes, no goes to the step that owns the fix with the note, and silence pauses the run.

Put a person's yes or no into the run as a step, so the work waits for the
answer and carries on from it. Use it before anything irreversible: a
release, a migration, money leaving an account. When the judgement can be
a model's, use [a review panel with a threshold](/patterns/review-panel)
instead and keep the person for the last word. This is human-in-the-loop as
a step that can say no, not a slide: yes passes the step, no carries the
person's note to the step that owns the fix, and no answer pauses the run
until one arrives.

## Shape

```mermaid theme={null}
flowchart LR
  analyse["analyse"] --> implement["implement: writer"]
  implement --> approve{{"approve: Ship this change?"}}
  approve -.->|"answer: yes"| done((pass))
  approve -->|"target, note as finding"| implement
  approve -.->|"no answer"| paused((paused))
```

## The step

`approval` asks one question through the run's callbacks client, and
`target` names the step a no goes to:

```ts examples/approval.ts (excerpt) {2-3} theme={null}
const approve = approval('approve', {
  question: 'Ship this change?',
  target: 'implement',
  answer: (request: CallbackRequest) => {
    const approved = JSON.stringify(request.input).includes('header');
    decisions.push(approved ? 'yes' : 'no');
    return approved
      ? { approved: true }
      : { approved: false, note: 'the ticket asked for a header row' };
  },
});
```

The question is about what came before: by default the request's input is
the outcomes of the steps this one depends on, so a changed attempt upstream
is a new question, and the same question about the same thing is the same
request. Give `input` to say otherwise, or bind the approval to the exact
files it approved with [proof acceptance](/reviewing/proof-acceptance).

A no needs a home and a budget. With `target`, the refusal goes to that
step as a revision request with the note as its one finding, and the
graph's `maxKickbacks` must allow it, or the run fails with the note
instead. Without a target, a refusal fails the step with the note as its
summary:

```ts examples/approval.ts (excerpt) {8} theme={null}
export const shipIt = pipeline(
  'ship-it',
  [
    { name: 'analyse', job: analyse },
    { name: 'implement', job: implement },
    { name: 'approve', job: approve },
  ],
  { maxKickbacks: 1 },
);
```

Nobody answering is a pause, not a failure. Without `answer`, the step posts
the request and returns `paused` with the request as its data. A router
claims the request and submits `{ approved, note? }`. Run the job again
with the same client and the step finds the answer in the client's history.
`run` takes that client as `callbacks`: the in-memory one that lives for
the run, the default, or the [stored client](/reviewing/callback-gates)
over a directory, which lets a paused run find its answer after a restart.
Use one approval label per workflow; two steps with the same label ask the
same question and supersede each other. A question that is neither pending
nor answered fails the step plainly instead of waiting for nothing.

## What the run did

The writer here is a function that leaves the header row out once, and the
person answers from the file itself, so it runs offline. Run it with
`npx tsx approval.ts`:

```json Output theme={null}
{
  "status": "pass",
  "implementRuns": 2,
  "decisions": [
    "no",
    "yes"
  ]
}
```

The person said no to the first attempt and yes to the second. `implement`
ran twice: the refusal went there as a revision request with the note as
its finding, the writer ran again with the note in hand, and the question
was asked again about the new attempt. In a real team the answer arrives
through the callbacks client, from a [surface](/concepts/surfaces) in a
browser or a host.

<Accordion title="Full file">
  ```ts examples/approval.ts theme={null}
  /**
   * A person decides.
   *
   * The last step of a delivery is a question to a person: ship this change?
   * `approval` asks it through the run's callbacks client. A yes passes the
   * step. A no goes back to the step that owns the fix with the person's note
   * as the finding, and the work comes round again. With nobody answering, the
   * run pauses with the question pending and carries on, when it runs again
   * with the same client, from the answer. This runs offline, with no model and
   * no network: the writer is a small function that leaves the header row out
   * once, and the person answers from this file.
   */
  import { approval, fnJob, pipeline, run, type CallbackRequest } from '@obversa/runtime';

  const analyse = fnJob('analyse', () => 'the ticket asks for a report export with a header row');

  /** The writer. In a real team this is an agent; here it forgets the header once. */
  let implementRuns = 0;
  const implement = fnJob('implement', () => {
    implementRuns += 1;
    return implementRuns === 1 ? 'report.csv, rows only' : 'report.csv, header and rows';
  });

  /**
   * The person. The question is about what came before (the outcome of
   * `implement`), so a second attempt is a new question. In a real team the
   * answer arrives through the callbacks client; here it is decided in place.
   */
  const decisions: string[] = [];
  const approve = approval('approve', {
    question: 'Ship this change?',
    target: 'implement',
    answer: (request: CallbackRequest) => {
      const approved = JSON.stringify(request.input).includes('header');
      decisions.push(approved ? 'yes' : 'no');
      return approved
        ? { approved: true }
        : { approved: false, note: 'the ticket asked for a header row' };
    },
  });

  export const shipIt = pipeline(
    'ship-it',
    [
      { name: 'analyse', job: analyse },
      { name: 'implement', job: implement },
      { name: 'approve', job: approve },
    ],
    { maxKickbacks: 1 },
  );

  const result = await run(shipIt);
  console.log(JSON.stringify({
    status: result.outcome.status,
    implementRuns,
    decisions,
  }, null, 2));

  /**
   * Part of the documentation proof: it must fail when the behaviour it shows
   * stops happening. A refusal that quietly stopped returning the work would
   * still print a passing run, and `implement` running once is the tell.
   */
  const faults: string[] = [];
  if (result.outcome.status !== 'pass') faults.push(`the run ended ${result.outcome.status}`);
  if (implementRuns !== 2) faults.push(`implement ran ${implementRuns} time(s), so the refusal did not run implement again exactly once`);
  if (decisions.join(',') !== 'no,yes') faults.push(`the person decided [${decisions.join(', ')}], not a no and then a yes`);
  if (faults.length) {
    for (const fault of faults) console.error(fault);
    process.exitCode = 1;
  }
  ```
</Accordion>

## Next steps

* [Feature delivery](/workflows/feature-team): a person's decision as the
  eighth stage of a real team, where the run pauses on the record.
* [Callback gates](/reviewing/callback-gates): the client, the routers and
  the stored history the step is built on.
* [Runtime](/packages/runtime): `approval`, `pipeline` and `maxKickbacks`.
