# The agent contract (Review Loop)

{/* // REVIEW LOOP: OPEN CONTRACT (D14) */}

The **Review Loop** is the review system for agent email. An agent submits a draft, and a human or
linked review agent approves, edits, rejects, or escalates it. Human edits can become reusable writing
rules. A mature category can later graduate to auto-send. Extrovert stores the state and computes the
diff. The composing agent applies the writing rules and makes drafting decisions.

This page defines the versioned JSON shapes used by the SDK, MCP tools, and agent skills.
**Provisional: contract v0.1.0-pre.6, pin it:** This is contract **`0.1.0-pre.6`** (`CONTRACT_VERSION`), **versioned with the SDK**. It is **open and
  documented**, but it is **pre-1.0** and may still evolve additively. Pin
  `CONTRACT_VERSION` or `CONTRACT_MANIFEST` and expect additive 0.x changes.
**A contract, not a protocol:** The open contract is published as an **SDK + skill contract**, **versioned with the SDK**: it is
  explicitly **not** a wire protocol and there is **no** `/v1/contract` endpoint. Formal protocol
  standardization is deferred. You consume the contract through the typed SDK shapes, the
  [MCP tools](https://docs.extrovert.dev/mcp/overview/), the [TypeScript SDK](https://docs.extrovert.dev/sdk/typescript/), and the agent skills.

## Version provisional 0.x

```ts
import { CONTRACT_VERSION, CONTRACT_MANIFEST } from "@extrovert.dev/sdk";

CONTRACT_VERSION;            // "0.1.0-pre.6"
CONTRACT_MANIFEST.stability; // "provisional"
CONTRACT_MANIFEST.kind;      // "sdk+skill-contract"  (NOT a protocol)
```

`CONTRACT_VERSION` is **`0.1.0-pre.6`** and is reconciled across every surface: the SDK package
version, the MCP server version, and the OpenAPI `info.version` all read `0.1.0-pre.6`. Pin it.

It is **provisional and pre-1.0**: open and documented, but it MAY still evolve (additively)
before 1.0. There are no external users yet, and a platform-wide shared-pool auto-send governor is
a prerequisite before there are. Treat 0.x as "code against it, but expect additive change."

`CONTRACT_MANIFEST` is the machine-readable list a harness pins against: `core_shapes` (the five
canonical shapes below), the full `shapes` surface, and the `skills` that are part of the contract.

## The five canonical shapes

These are the core review-contract shapes: the highest-traffic agent-facing JSON. Every shape keys on
**opaque, typed ids** (`rr_`, `turn_`, `cat_`, `rule_`, `rln_`, `ndg_`, …) and never on names -
renaming a category or a rule never breaks a reference.

### Intent: submitted with every outbound message

The agent's "for the human reviewer" summary. **Required** whenever the resolved mode is `review` -
which, under the `require_review` policy every account starts on, is every `send`, `reply` and
`forward`. Without it the request is rejected `422 intent_required` and **nothing is sent and nothing
is queued**; the problem's `detail` carries the whole fix. Keep `summary` to one sentence
(8–200 characters): it is the only context the human gets, so a boilerplate one is worse than useless.

```json
{ "summary": "Re-engage cold lead at Acme about Q3 pilot",
  "meta": { "goal": "book_meeting", "recipient": "vp@acme.com",
            "prior_touches": 2, "urgency": "normal" } }
```

### Feedback: returned by `get_review_feedback`

The human's assembled decision: the unified + structured diff of their edit, their comments /
rejection feedback, the decision, and the `rule_` ids born from this review.

```json
{ "review_id": "rr_…", "decision": "edited",
  "diff_unified": "@@ -1,3 +1,3 @@ …",
  "diff_json": { "fields_changed": ["body_text"], "hunks": [ … ] },
  "comments": [ { "turn_id": "turn_…", "actor_kind": "human", "body": "be more pushy, we need MRR" } ],
  "new_rules": [ "rule_…" ] }
```

### diff_json: the structured proposed-vs-sent diff

Computed deterministically in Go ($0 LLM). `fields_changed` names the changed fields; `hunks` carry
the per-field before/after. Published in the SDK as the named `DiffJson` / `DiffHunk` types.

```json
{ "fields_changed": ["subject","body_text"],
  "hunks": [ { "field": "body_text", "op": "replace",
               "before": "Let me know your thoughts.",
               "after": "Can you do Thursday 2pm?" } ] }
```

### Rule: an element of `get_rules`

A learned writing preference, either house style (`scope:"general"`) or category-specific. Append-only by
supersession. Your agent reads the ordered ladder at compose/redraft time; **we never apply it**.

```json
{ "id": "rule_…", "lineage_id": "rln_…", "rev": 3,
  "rule_layer": "project", "org_id": "org_…", "project_id": "prj_…",
  "scope": "category", "category_id": "cat_…", "kind": "soft", "priority": 0,
  "rule_text": "Always propose a concrete time, never 'let me know'.",
  "source_review_id": "rr_…", "author_kind": "human", "status": "active" }
```

`rule_layer` is the ownership layer: `org` (house-style inherited by every project in the org) or
`project` (layered on top). `get_rules` returns the effective stack for your key's `{org_id,
project_id}` with the precedence ladder applied server-side (per-agent project rule → project category
rule → project general rule → org house-style rule). Agent-plane `save_rule` always writes a
`project`-layer rule; org-wide house-style is authored from the console. These fields are **additive
and optional**: older harnesses that only read `scope` / `category_id` keep working.

An unfiltered `get_rules` response also carries `house_style_version`, `category_rules_version`,
`rule_high_water`, and a short-lived `composition_token`. Keep that token with the exact rule snapshot
used to compose a draft, and pass it to `send_email`, `reply_to_email`, `forward_email`, or
`submit_revision`. If the rules change or the token expires, the mutation is rejected without sending;
fetch the full stack again, re-apply it, and retry with the new token. Scope-filtered rule queries are
for browsing and do not return a composition token.

### Nudge: an element of `list_review_events`

A durable, idempotent work item drained from the authoritative liveness queue. `seq` is the per-review
monotonic ordinal your ack cursor advances against. The `propagate_general_rule` nudge carries the
sibling pending set + a `suggested_batch` so your agent front-runs the human a few drafts at a time -
never the whole queue.

```json
{ "seq": 4127, "id": "ndg_…", "reason": "propagate_general_rule",
  "review_id": "rr_…", "category_id": "cat_…",
  "payload": { "rule": { "id": "rule_…", "rule_text": "…" },
               "siblings": { "category_id": "cat_…", "pending_count": 499 },
               "suggested_batch": 3 } }
```

#### Every `reason` that is emitted

Your drain switch should cover all eleven, plus a `default:` arm that **acks and ignores** anything it
does not recognize: this enum is additive across 0.x releases.

| `reason` | Emitted when | What your agent does |
|---|---|---|
| `redraft_requested` | A draft came back to `needs_review`: a reviewer rejected or escalated it, a breaker fired, or the sweep returned it. | Redraft via `submit_revision`. |
| `feedback_added` | A **human** posted a comment on the review thread. (Your own question to the human emits nothing.) | Answer, or redraft. |
| `rejected` | A human rejected the draft. | Learn from the rejection; redraft or stop. |
| `rule_changed` | A rule that governs this draft moved: including a born-stale redraft that was refused. | `get_rules`, re-apply, then `submit_revision`: or `restamp_review` if nothing genuinely changed. |
| `recheck_category` | The draft's category graduated or was reconciled, so its routing may have changed. | Re-check the category assignment and resubmit. |
| `propagate_general_rule` | A new general rule applies to sibling pending drafts. | Apply it to `suggested_batch` siblings: never the whole queue. |
| **`sent`** | The message was delivered. Covers **both** flavors: `payload.state` is `sent` (a human or reviewer released it) or `auto_sent` (direct / graduated). | **Done.** Record `payload.message_id`, ack, stop polling this review. |
| **`send_failed`** | The provider refused the send (`approved → failed`). | **Done with this review.** Compose and submit a **new** message; `payload.agent_retryable` is `false` because nothing can move this row back to `approved`. |
| **`cancelled`** | The review reached `cancelled`: withdrawn by you, by a human, or as the close-out of a failed send. | Done. |
| **`front_run_next`** | You hit a `409 terminal` chasing a review a human already closed. | **STOP RETRYING.** The payload carries what actually happened. |
| `staleness` |: | **RESERVED. Never emitted today**: the inbound-reply correlation detector that would produce it is not built. Ack and ignore. |
| `approved` |: | **RESERVED. Never emitted, by design.** `approved` is transient (milliseconds before the provider call) and would be actively misleading if that call then failed. Terminal success is `sent`. |

#### Terminal events: how an agent knows it is done

> **Every review that reaches `sent`, `auto_sent`, `failed` or `cancelled` emits exactly one terminal
> nudge, and it is the last and highest-`seq` nudge that review will ever produce.**

It is emitted **unconditionally**: including for your own inline direct send and your own cancel.
That uniformity is the point: an agent that crashed between issuing the request and reading the
response learns the outcome from the durable queue, which is exactly the window in which "the agent
already knows" is false. (The one review that emits two is a failed send you then close out: it emits
`send_failed`, then `cancelled`.)

```json title="reason: sent"
{ "seq": 12, "id": "ndg_…", "reason": "sent", "review_id": "rr_…",
  "payload": { "state": "sent", "message_id": "msg_…",
               "send_path": "human_reviewed", "decision": "edited",
               "sent_at": "2026-08-31T09:14:02Z", "thread_ref": "thread_…" } }
```

```json title="reason: sent (graduated / direct)"
{ "seq": 3, "id": "ndg_…", "reason": "sent", "review_id": "rr_…",
  "payload": { "state": "auto_sent", "message_id": "msg_…",
               "send_path": "graduated_auto", "gate_outcome": "",
               "sent_at": "2026-08-31T09:14:02Z" } }
```

```json title="reason: send_failed"
{ "seq": 9, "id": "ndg_…", "reason": "send_failed", "review_id": "rr_…",
  "payload": { "state": "failed", "error": "<vendor-scrubbed reason>",
               "from_state": "approved", "agent_retryable": false,
               "next_action": "compose_and_submit_a_new_message" } }
```

```json title="reason: front_run_next"
{ "seq": 14, "id": "ndg_…", "reason": "front_run_next", "review_id": "rr_…",
  "payload": { "state": "sent", "sent_message_id": "msg_…",
               "your_parent_revision": 3, "current_revision": 5,
               "diff_available": true } }
```

`decision` on a `sent` payload is `"edited"` when the reviewer changed your draft and `"approved"`
when they released it as-is: derived exactly as `get_review_feedback` derives it, so the two can
never disagree. `send_path` is `human_reviewed` | `reviewer_approved` | `graduated_auto` |
`agent_direct`. `thread_ref` is present only when the review actually threads one: a bare send has
none, and an empty string would read as "there is a thread and it is unnamed". `front_run_next` is
keyed on `(review, terminal state, your parent_revision)`, so a retry loop hammering the same `409`
collapses to **one** row.

#### Three ways to know a review is finished

1. **Push (primary)**: drain `list_review_events` / `wait_for_review_event` and stop on the terminal
   nudge. An acked terminal nudge can never re-surface.
2. **Poll (definitive, per review)**: `GET /v1/reviews/{id}` carries `closed`, `send_error` and
   `send_path`:
   - `closed: true` for `sent`, `auto_sent`, `cancelled` **and `failed`**. `failed` is included
     deliberately: nothing re-approves a failed review, so treating it as open means waiting forever.
   - `send_error`: the vendor-scrubbed delivery failure, present on a failed review.
   - `send_path`: how the message was released, without fetching the turns.
3. **After a crash, re-derive**: your un-acked frontier is durable, so drain first. For anything still
   open, `list_reviews?state=needs_review&state=rejected&…`, filter client-side on
   `agent_id == self`, then `get_review(id)` and read `closed`. Every send now returns a `review_id`
  : including the direct-send `202`: so there is always a handle to come back to.

## The rest of the surface

The contract is **one** provisional-0.x surface (no tiers). Beyond the five core shapes it covers
the whole built loop. The four most common are shown below with example JSON; the full set is
enumerable at runtime via `CONTRACT_MANIFEST.shapes`.

### Chat: `post_review_chat` request

Posts an `agent_question` turn on the review thread (the first chat flips `in_review → chatting`).
Idempotent on the `Idempotency-Key` header so a retry never doubles the turn.

```json
{ "text": "Do you want me to lead with the pilot ROI, or the case study?" }
```

### Category: an element of `list_categories`

The shared customer registry your agent fuzzy-matches a draft against (the matching is
**yours**: no model on our side). Keyed on the opaque `cat_…`; the `name`/`description` are mutable
display metadata. `state` is the graduation rung; the maturity counters (`clean_approval_count`,
`drift_count`) drive the gate.

```json
{ "id": "cat_…", "name": "Sales Outreach",
  "description": "cold outbound to prospects about the pilot",
  "scope": "org_shared", "state": "supervised",
  "author_kind": "agent", "rule_high_water": 12, "rules_version": 7,
  "never_graduate": false, "clean_approval_count": 18, "drift_count": 1 }
```

### Graduation status: `get_graduation_status`

Read-only progress toward the next rung (`supervised → auto_notify → auto_silent`). Agents
**propose** graduation; only a human flips the bit (D16). `can_graduate` reports whether a human
graduate would succeed right now; the maturity gate (`approvals_met` + `age_met`) is the
`auto_silent` precondition.

```json
{ "category_id": "cat_…", "state": "supervised", "next_state": "auto_notify",
  "never_graduate": false,
  "clean_approval_count": 18, "graduate_min_approvals": 20, "approvals_met": false,
  "age_hours": 73.5, "graduate_min_age_hours": 48, "age_met": true,
  "maturity_gate_met": false,
  "drift_count": 1, "drift_demote_after": 3, "can_graduate": false }
```

### Risk dial: `get_risk_dial`

The resolved brand-risk dial: the account default plus each category's overrides, every value
already in force. Agents **read** it; flipping the dial is a console (human) action. A `null`
override field means the category inherits the account default.

```json
{ "account": { "min_confidence": 0.7, "first_contact_gate": true,
               "drift_demote_after": 3, "canary_rate": 0.1,
               "graduate_min_approvals": 20, "graduate_min_age_hours": 48,
               "auto_send_cap_per_day": 200 },
  "categories": [ { "category_id": "cat_…",
                    "min_confidence": 0.85, "first_contact_gate": null,
                    "effective": { "min_confidence": 0.85, "first_contact_gate": true,
                                   "drift_demote_after": 3, "canary_rate": 0.1,
                                   "graduate_min_approvals": 20, "graduate_min_age_hours": 48,
                                   "auto_send_cap_per_day": 200 } } ] }
```

### Reviewer decision: the BYO review-agent plane (M8)

A linked review-agent's decision request, and the result. `action` is `approve | edit | reject |
escalate`; `revision` is the optimistic CAS (a mismatch is `409` `stale` with **no** mutation: the
human always wins, D17; see [the 409 taxonomy](#the-409-taxonomy-what-each-conflict-means)). The result's `kind` is `sent` (the platform relayed with the *composer's*
creds) or `sent_to_human` (returned to the human queue); `forced_by_breaker` names the tripped
circuit breaker when one overrode the intent.

```json
// request
{ "action": "edit", "revision": 4, "version": 9,
  "subject": "Q3 pilot: quick question",
  "body": "Can you do Thursday 2pm? Pilot ROI attached.",
  "feedback": "tightened the CTA" }
```

```json
// result
{ "kind": "sent", "sent": true, "sent_to_human": false,
  "message_id": "msg_…", "thread_id": "thread_…",
  "review": { "id": "rr_…", "state": "sent", "revision": 5 } }
```

### Enumerate the full set

```ts
import { CONTRACT_MANIFEST } from "@extrovert.dev/sdk";
CONTRACT_MANIFEST.shapes;      // every published contract shape, by name
CONTRACT_MANIFEST.core_shapes; // ["ReviewIntent","ReviewFeedback","DiffJson","Rule","ReviewEvent"]
```

Beyond what's shown here, the surface also includes submit + review states, the per-message
revision (`submit_revision`) + restamp, the rules + audit/undo store, and backlog reconciliation +
pacing. Each shape is an exported, documented TypeScript type in `@extrovert.dev/sdk` (re-exported from
the SDK's `contract` module) and is mirrored on the [MCP tools](https://docs.extrovert.dev/mcp/overview/). The same shapes are
the OpenAPI component schemas served at `/openapi.yaml`.

## Where a review comes from

There is no unsupervised outbound verb on the agent plane. `send`, `reply` **and `forward`** all
create a review row, and the account's resolved
[review policy](https://docs.extrovert.dev/api/inboxes/#effective_review_policy-know-before-you-send) decides what happens to
it. Forward is included on purpose: it is an outbound message to arbitrary *new* recipients that
quotes an inbound thread, so leaving it unenforced would have made it the documented bypass: and the
worst one, because it exfiltrates a received conversation.

| Resolved policy | A call carrying an `intent` | A call carrying no `intent` |
|---|---|---|
| `require_review` | `202 queued_for_review`: a human decides | `422 intent_required`; nothing sent, nothing queued |
| `allow_direct` | Queued (you asked for review by opting in) | Released immediately; `202 {status:"sent", message_id, review_id}` |
| `auto_send_graduated` | Auto-sent if the category is graduated and every gate passes; otherwise queued with `gate_outcome: "held:<reason>"` | `422 intent_required` |

Contact-list blocks, unsubscribe suppression and the outbound quota are checked **at submit** as well
as at delivery, so a human never approves a draft that then fails on the way out. Those rejections
(`403 recipient_blocked`, `422 recipient_suppressed`, `403 quota_exceeded`) come **before** the intent
gate: an intent would not fix them.

A reply's `Re:` subject and recipients, and a forward's subject and quoted parent body, are
**materialized at submit**. The human reviews the exact bytes that will be delivered, and a reviewer's
edit to a forwarded quote is what actually goes out. `reply_to`, custom `headers` and `attachments`
ride the review row through to delivery; `submit_revision` may replace the attachments (omit the field
to keep them, `[]` to clear them). A forward is not threaded to its parent: the delivered message
carries no `In-Reply-To`.

## What a composing agent may legally do

The state machine is actor-aware. These are the edges available to the agent that **composed** the
draft; approve / edit / reject / reconfirm and the risk dial are human authority and are never agent
verbs (D17).

| Draft state | Legal composer verbs |
|---|---|
| `needs_review` | `submit_revision` (a redraft is legal **from `needs_review`**: it bumps `revision`, which is a real change), `post_review_chat` (posts your question **without** opening the draft: an agent may not conjure a reviewer), `restamp_review`, `cancel_review` |
| `in_review` | `post_review_chat` (flips to `chatting`), `cancel_review` |
| `chatting` | `submit_revision`, `post_review_chat`, `cancel_review` |
| `rejected` | `submit_revision`, `cancel_review` |
| `approved` | nothing: it is being delivered. Read, and wait for the terminal event. |
| `failed` | `cancel_review` only. This is the one legal close-out for a failed send; you cannot re-send it. |
| `sent` / `auto_sent` / `cancelled` | nothing, ever. Reads only. |

`get_review` and `list_review_events` are legal from **every** state, which is why a `409`'s
`allowed_action` hints are never empty: from a terminal state the honest answer is "nothing will
mutate this: go look at what happened".

`stale` and `stalled` appear in the state enum but have **no producer today**: nothing correlates an
inbound reply into `stale` and nothing runs the composer-deadline sweep that would write `stalled`.
Do not write a branch that waits for either, and note that the "human can reconfirm a stale draft"
affordance does not exist in the product.

## The `409` taxonomy: what each conflict means

Every review-loop error is RFC-9457 problem+json with a `code` from the
[closed enum](https://docs.extrovert.dev/api/errors/#the-closed-code-enum). Branch on `code`, never on `status` alone: three of
these used to be one indistinguishable `409 conflict`, and one of the three can **never** succeed.

| `code` | Means | Rule |
|---|---|---|
| `stale` | The `(revision[, version])` you named is no longer current: a human moved the draft. **No mutation.** | **Retry, ≤3.** Re-read, re-apply on top of theirs, resubmit with the new `parent_revision`. |
| `born_stale` | Your redraft was built against an older rule high-water. **No mutation**; you were re-nudged. | **Retry, ≤1 per rule high-water.** `get_rules`, retain its `composition_token`, re-apply, and submit with that token: or `restamp_review` if nothing genuinely changed. |
| `wrong_state` | The verb is illegal from the current state, but the draft is live. | **Never retry the same verb.** Read the `allowed_action` hints. |
| `terminal` | Already `sent` / `auto_sent` / `cancelled`. | **Stop.** A `front_run_next` event is waiting with the outcome. |
| `send_needs_reconciliation` | An unconfirmed send attempt is parked for recover-by-Message-ID. | **Do not resend.** Poll. |
| `idempotency_conflict` | Same `Idempotency-Key`, different body. | Caller bug. Do not retry with that key. |

The problem carries its recovery facts as repeated `errors[]` entries: `state`, `revision`,
`version`, and one `allowed_action` per legal verb: so a `stale` retry needs no extra `GET`. A
`terminal` problem swaps the CAS keys for `sent_message_id`. The `allowed_action` list is derived from
the transition table itself, so it can never advertise a verb that would `409`.

`restamp_review` is for the honest case only: "I re-read the rules and nothing needed to change". Do
not use it to clear a born-stale flag on a draft whose body *did* change: that makes the draft lie to
the born-stale accounting, and the reconciliation sweep will release a pre-rule draft.

## How the contract stays honest

The published shapes can never silently drift from the wire: a conformance/drift test validates the
canonical example JSON above against both the OpenAPI component schemas (Go) and the SDK types (TS),
and asserts the version is reconciled across all surfaces. A negative test proves the guard is not a
tautology. **Rename a field anywhere and the build breaks.**

## The skills are part of the contract

D14 publishes a **skill + SDK** contract. The judgment that drives the loop lives in the agent
skills, which code against exactly these shapes:

- **`extrovert-send-email`**: the monitoring loop: drain nudges → apply-rule / answer-chat /
  re-check-pending / redraft; read a diff; stop on a terminal event; process a few at a time, not all
500.
- **`extrovert-writing-rules`**: match a draft to a category; classify where a rule applies
  (house-style vs category); turn a diff + comment into a rule body; propose graduation.

Authoring house-style rules, predefining categories and setting the risk dial / review policy are
**console** actions, not a skill: they are human authority by design, and an agent can never widen its
own policy.