# GrantFlow Agent Error Contract

All error responses from agent-facing endpoints share a single structured shape.
Agents must read `detail.code` (not `detail.message` or HTTP status) to decide recovery actions.

## Error response shape

```json
{
  "detail": {
    "code": "machine_readable_error_code",
    "message": "Human-readable summary.",
    "retryable": false,
    "next_action": "Specific recovery instruction.",
    "...": "Additional fields depending on code (see below)."
  }
}
```

| Field | Type | Always present | Description |
|---|---|---|---|
| `code` | string | yes | Machine-readable error identifier. Use this for branching logic. |
| `message` | string | yes | Human-readable summary. Do not parse. |
| `retryable` | boolean | yes | `true` = identical retry may succeed. `false` = fix the request first. |
| `next_action` | string | no | Recommended recovery step. |

---

## Error codes

### Authentication and authorization

| Code | HTTP | Retryable | Recovery |
|---|---|---|---|
| `unauthorized` | 401 | false | Send a valid `X-API-Key` header. Call `POST /agents/onboarding` to obtain credentials. |
| `invalid_client_credentials` | 401 | false | Call `POST /agents/onboarding` for fresh credentials. |
| `invalid_credential_rotation_request` | 401 | false | Credential is expired, revoked, or not self-serve. Request a new one via `/agents/onboarding` or `/agents/session`. |

### Input validation

| Code | HTTP | Retryable | Recovery |
|---|---|---|---|
| `missing_donor_id` | 400 | false | Provide a non-empty `donor_id` such as `"usaid"`. |
| `missing_job_id` | 400 | false | Provide a valid `job_id` from a prior `/generate` response. |
| `missing_tenant_id` | 400 | false | Provide a non-empty `tenant_id`. Extra field: `tenant_id`. |
| `unsupported_donor_id` | 400 | false | Check `/generate/presets` for supported donor identifiers. |
| `invalid_request_id` | 400 | false | `request_id` must match `[A-Za-z0-9._:-]{1,120}`. Generate a new one. |

### Job lifecycle

| Code | HTTP | Retryable | Recovery |
|---|---|---|---|
| `job_not_found` | 404 | false | Jobs expire on process restart. Check `job_id` from your `/generate` response. Extra field: `job_id`. |

### Review and critic resources

| Code | HTTP | Retryable | Recovery |
|---|---|---|---|
| `critic_findings_not_found` | 404 | false | Job completed but critic step produced no findings. Verify the critic step ran via `GET /status/{job_id}/critic`. |
| `critic_finding_not_found` | 404 | false | The specified finding does not exist. Check `finding_id` from `GET /status/{job_id}/critic/findings`. Extra field: `finding_id`. |
| `review_comment_not_found` | 404 | false | The specified review comment does not exist. Check `comment_id` from `GET /status/{job_id}/review/comments`. Extra field: `comment_id`. |
| `checkpoint_not_found` | 404 | false | The HITL checkpoint does not exist or has expired. Check `checkpoint_id` from `GET /hitl/pending` or `GET /status/{job_id}`. Extra field: `checkpoint_id`. |

### Presets and ingest

| Code | HTTP | Retryable | Recovery |
|---|---|---|---|
| `preset_not_found` | 404 | false | Preset key not found. Check `GET /generate/presets/legacy` or `GET /generate/presets/rbm` for available keys. Extra fields: `preset_key`, `preset_type`, `available`. |
| `ingest_preset_not_found` | 404 | false | Ingest preset key not found. Check available ingest presets at `GET /ingest/presets`. Extra field: `preset_key`. |

### Tenants

| Code | HTTP | Retryable | Recovery |
|---|---|---|---|
| `tenant_not_found` | 404 | false | Tenant does not exist. Check `tenant_id` from `GET /tenants`. Extra field: `tenant_id`. |
| `tenant_id_required` | 403 | false | Server has tenant authorization enabled but no `tenant_id` was provided. Send `X-Tenant-ID` header or include `tenant_id` in the request body. |
| `tenant_id_not_allowed` | 403 | false | This server does not accept `tenant_id`. Remove `X-Tenant-ID` header and `tenant_id` field. |
| `tenant_access_denied` | 403 | false | The requested resource belongs to a different tenant. Use credentials matching the resource's `tenant_id`. Extra field: `tenant_id`. |
| `tenant_quota_exceeded` | 429 | false | Daily quota reached for this tenant. Wait until UTC midnight for the quota to reset. Extra fields: `tenant_id`, `quota_jobs_per_day` or `quota_ingest_per_day`, `jobs_today` or `ingest_today`. Requires `GRANTFLOW_TENANT_QUOTA_HARD_BLOCK=true`. |

### Filter and format validation

| Code | HTTP | Retryable | Recovery |
|---|---|---|---|
| `unsupported_filter` | 400 | false | A filter query parameter has an invalid value. Check the endpoint docs for allowed values. Extra field: filter name. |
| `unsupported_status` | 400 | false | A status value is not in the allowed set. Check the relevant `*_STATUSES` constant for valid values. |
| `unsupported_format` | 400 | false | The requested export format is not supported. Use one of: `docx`, `json`, `zip`. |
| `missing_selector` | 400 | false | No selector provided and `apply_to_all` is false. Set at least one selector (`finding_ids`, `section`, `severity`) or set `apply_to_all=true`. |
| `missing_preset_key` | 400 | false | `preset_key` is required. Provide a non-empty key from `GET /generate/presets/legacy` or `GET /generate/presets/rbm`. |
| `invalid_parameter` | 400 | false | A parameter value is out of the allowed range or format. Read `message` and `next_action` for the specific constraint. |
| `missing_required_field` | 400 | false | A required field was omitted or empty. Read `next_action` for which field to add. |
| `version_not_found` | 400 | false | The requested `version_id` does not exist for this section. Check available versions from `GET /status/{job_id}/versions`. |
| `linked_finding_not_found` | 400 | false | The `linked_finding_id` does not exist for this job. Check `GET /status/{job_id}/critic/findings`. |
| `section_mismatch` | 400 | false | `linked_finding_id` belongs to a different section than the comment. Use a finding whose section matches the comment section. |

### HITL and job state

| Code | HTTP | Retryable | Recovery |
|---|---|---|---|
| `invalid_job_state` | 409 | false | Job is not in the expected state for this operation. Check `GET /status/{job_id}` for the current state. |
| `checkpoint_missing` | 409 | false | No checkpoint found for the pending HITL job. Check `GET /hitl/pending`. |
| `checkpoint_pending` | 409 | false | Checkpoint has not been approved or rejected yet. Approve or reject via `POST /hitl/approve` before retrying. |
| `checkpoint_not_decided` | 409 | false | Checkpoint must be approved or rejected before the job can resume. |
| `checkpoint_stage_mismatch` | 409 | false | Checkpoint stage does not match the job's pending stage. Use the `checkpoint_id` from `GET /status/{job_id}`. |
| `checkpoint_conflict` | 409 | true | Concurrent update conflict on the checkpoint. Retry after fetching current state. |
| `finding_status_conflict` | 409 | true | Finding status changed since last read. Refresh the finding and retry with the current status. Extra fields: `finding_id` (single), `if_match_status`, `current_status`; bulk variant adds `conflict_count`, `conflicts`. |

### Service availability

| Code | HTTP | Retryable | Recovery |
|---|---|---|---|
| `job_queue_full` | 503 | true | Job queue is at capacity. Wait a few seconds and retry. |

### Idempotency

| Code | HTTP | Retryable | Recovery |
|---|---|---|---|
| `request_id_reused_with_different_payload` | 409 | false | A different payload was already submitted under this `request_id`. Reuse the original payload or generate a new `request_id`. Extra fields: `request_id`, `scope`. |

### MCP / tool layer

| Code | HTTP | Retryable | Recovery |
|---|---|---|---|
| `unknown_tool` | — | false | Call `tools/list` to get the current tool list. |
| `missing_required_argument` | — | false | Extra field: `required` (list of missing argument names). |
| `grantflow_http_error` | — | status ≥ 500 | Extra fields: `status_code`, `response`. |
| `job_poll_timeout` | — | true | Job did not reach a terminal status within `timeout_seconds`. Poll again or increase timeout. Extra field: `last_status`. |
| `unknown_method` | — | false | MCP server received an unsupported method. Extra field: `available_methods`. |

---

## Idempotency rules

- Write endpoints that accept `request_id` are idempotent: resubmitting the same `request_id` with the same payload returns the original response with `idempotent_replay: true`.
- Resubmitting the same `request_id` with a **different** payload returns `409 / request_id_reused_with_different_payload`.
- `request_id` can be sent as a body field or as the `X-Request-ID` header. The MCP tool client propagates it as both.
- Idempotency records are held in-process and expire on restart. Do not rely on them across deployments.

---

## Retry guidance

| `retryable` | Recommended action |
|---|---|
| `true` | Back off and retry the identical request. Exponential backoff with jitter is recommended. |
| `false` | Do not retry without changing the request. Read `next_action` for the required fix. |

For `grantflow_http_error` with `status_code >= 500`, `retryable` is `true`.
For `job_poll_timeout`, retry by polling again or by calling `grantflow_run_sandbox_happy_path` with a longer `timeout_seconds`.

---

## Conformance verification

Run the built-in conformance suite against a live server to verify the contract:

```bash
python -m grantflow.agents.conformance --base-url http://127.0.0.1:8000
```

The suite checks:

| Check | What it verifies |
|---|---|
| `invalid_donor` | `POST /generate/preflight` with empty `donor_id` → `400 / missing_donor_id` |
| `missing_job` | `GET /status/{job_id}` with nonexistent `job_id` → `404 / job_not_found` |
| `idempotency_replay` | Second `POST /generate` with same `request_id` + same payload → `idempotent_replay: true` |
| `idempotency_conflict` | Same `request_id`, different `donor_id` → `409 / request_id_reused_with_different_payload` |

Exit code `0` = all checks passed. Exit code `1` = one or more checks failed.

---

## Revocation limitations

Credential revocation in GrantFlow uses an **in-process JTI denylist**.

| Property | Value |
|---|---|
| `revocation_scope` | `"in_process"` |
| `restart_safe` | `false` |
| Mechanism | In-memory set of revoked JTI identifiers |
| Survives process restart | No |
| Survives horizontal scale-out | No — each process has its own denylist |

**What this means for agents:**
- After calling `POST /agents/credentials/revoke`, the credential is invalidated in the current server process only.
- If the server restarts or multiple replicas are running, the revoked credential may be accepted by a different process.
- Enforce revocation at the **gateway layer** for production multi-replica deployments.
- After revocation, always request a new credential — do not assume the old one is safe to retry.

The `revoke` response includes `revocation_scope` and `restart_safe` fields so agents can read this limitation without consulting docs.

---

## What this contract does not cover

- Production OAuth token issuance (owned by the gateway layer).
- HITL checkpoint approval (requires explicit human authorization).
- Export artifact access policies (operator-configured).
- Webhook signature verification (see `/.well-known/agent-policy.json`).
- Cross-process or cross-replica credential revocation (enforce at gateway layer).
