# Decision oracle API reference

> Every export, config field, binding, var, HTTP route and CLI command of @ixo/decision-oracle.

Every export of the package, what it does, what it defaults to, and what it guarantees. If you are
starting from zero, read the [guide](/build-an-oracle/decision-oracle) first — it is the narrative; this is the lookup.

The package is four import paths and one CLI. The root is the **worker**: it pulls the Hono app,
the Workflow classes and the chain SDK into your bundle, so it is imported once, from
`src/index.ts`. `./facts` and `./testing` are **node-safe** — no `cloudflare:*`, no wasm, no D1, no
KV in their import graph — which is why a handler test is plain vitest with no Workers pool.
`./runtime` is the other worker-only path: what a provider's own route, cron or Workflow calls when
it needs the kit to do something the lanes do not do by themselves (below).

---

## 1. Import map

### `@ixo/decision-oracle` — worker only

```ts
import { defineOracle, NETWORKS, networkDefaults } from "@ixo/decision-oracle";
import type {
  ActContext,
  ActDecision,
  ActFacts,
  ActPolicy,
  Config,
  IxoNetwork,
  OpenhandsAsk,
  OpenhandsOptions,
  OpenhandsQuota,
  OracleBindings,
  OracleConfig,
  OracleEnv,
  OracleVars,
  OracleWorker,
} from "@ixo/decision-oracle";
```

| Export | Kind | What it is | Use it when |
| --- | --- | --- | --- |
| `defineOracle` | function | The whole runtime surface: validates + registers your config, returns everything the Workers runtime needs | Once, in `src/index.ts` |
| `OracleConfig` | type | What you pass to `defineOracle` — name, listing, price, policies, `facts`, `archive` | Typing a config you build elsewhere |
| `OracleWorker` | type | What `defineOracle` returns: `fetch`, `scheduled`, `ClaimIngest`, `ReviewComplete`, `OpenhandsHire`, `SequenceManagerDO` | Rarely — the three re-exports are structural |
| `OracleEnv` | type | `OracleBindings & OracleVars` — the runtime contract your `wrangler.jsonc` must satisfy | Typing your own helpers that take `env` |
| `OracleBindings` | type | Every binding by name (`DB`, `CACHE_KV`, …) | Extending it with your own bindings: `OracleEnv & { MY_KV: KVNamespace }` |
| `OracleVars` | type | Every var and secret, as wrangler hands them over (strings, `undefined` when unset) | Same |
| `Config` | type | The **validated** config — the output of the Zod schema, so the eight per-network URLs are `string`, never `string \| undefined` | Typing a function that takes `tools.config` |
| `NETWORKS` | const | `Record<IxoNetwork, NetworkDefaults>` — the one per-network map | Reading a network's engine/blocksync/portal host without hardcoding it |
| `networkDefaults` | function | `(network: IxoNetwork) => NetworkDefaults` | Same, by name |
| `IxoNetwork` | type | `"devnet" \| "testnet" \| "mainnet"` | Typing a network switch |
| `ActPolicy` | type | `(ctx: ActContext) => Promise<ActDecision> \| ActDecision` — the `policies.act` hook's own signature (`src/act.ts`) | Typing a `policies.act` function you write |
| `ActContext` | type | Everything the act lane knows about the claim it is about to sign for — every field is documented where `policies.act` is (below) | Destructuring inside your `act` hook |
| `ActDecision` | type | `{ mode: "sign", amount?, reason? } \| { mode: "hold", reason? } \| { mode: "skip", reason? }` — what the hook tells the lane to do next | Returning from your `act` hook |
| `ActFacts` | type | `{ ext, ai }` — the frozen facts by name, as sent to the engine, never answer values | Typing `ActContext["facts"]` in a helper |
| `ArchiveHooks` | type | `{ describe?, answer? }` — the `archive` option's own shape (§2) | Typing the hooks you write |
| `ArchiveAsk` / `ArchiveDoc` | type | What `archive.describe` is handed: the ask's collection and documents | Reading `#done`, `#pro` inside `describe` |
| `ArchiveAbout` / `ArchiveKind` | type | `about.json` — the view `describe` returns part of | Returning from `describe` |
| `ArchiveAnswerView` | type | `{ text?, place? }` — what `archive.answer` returns | Returning from `answer` |
| `OpenhandsOptions` | type | `{ admit?, maxCustomAmount?, onQuota? }` — the `openhands` option's own shape (§2, "The OpenHands add-on") | Typing the hooks you write |
| `OpenhandsAsk` / `OpenhandsQuota` | type | What `admit` and `onQuota` are handed | Same |

### `@ixo/decision-oracle/facts` — node-safe, what a handler imports

```ts
import {
  beforeAfter,
  defineFacts,
  fact,
  group,
  haversineMeters,
  located,
  photoFactsFromJpeg,
  linkedDocUrl,
  readAnswer,
  readLinkedDoc,
  RetryLater,
  rootQuestionName,
  withinRadius,
} from "@ixo/decision-oracle/facts";
import type {
  AiHandler,
  AiInput,
  AiResult,
  AiSendEntry,
  AiTools,
  ChainTools,
  ClaimIntent,
  ExtHandler,
  ExtInput,
  ExtResult,
  ExtTools,
  FactErrorDraft,
  FactGroup,
  FactInputBase,
  FactsConfig,
  FactsDeclaration,
  FactTools,
  FrequencyHandler,
  FrequencyInput,
  GroupHandler,
  GroupInput,
  GroupResult,
  HttpJsonInit,
  HttpJsonResult,
  LinkedDocRead,
  LinkedDocRef,
  LinkedResource,
  PhotoFacts,
  ReadLinkedDocOptions,
  UniqueHandler,
  UniqueInput,
} from "@ixo/decision-oracle/facts";
```

| Export | Kind | What it is | Use it when |
| --- | --- | --- | --- |
| `defineFacts` | function | Normalizes your handler table into a `FactsConfig` | Once, in `src/facts/index.ts` |
| `group` | function | `group(["a", "b"], handler)` — ONE handler answering several declared sources from one call | Four criteria judged by one model call |
| `fact` | object | `fact.ok`, `fact.ai.ok`, `fact.error`, `fact.retryLater` — what a handler returns when a bare `true` isn't enough | In every handler that says *why* |
| `RetryLater` | class | The tagged error `fact.retryLater()` builds | `err instanceof RetryLater` in your own wrapper |
| `readAnswer` | function | `(answers, "$q.row[*].cell") => unknown` — the `$path` reader the package itself uses, the engine binder's grammar | Reading an answer the rubric's `send` map did not name |
| `rootQuestionName` | function | `("$deliveryPhoto.0") => "deliveryPhoto"` — the top-level question of a `$ref` | Matching a `$ref` against `attachment.question` |
| `ExtHandler` / `AiHandler` / `UniqueHandler` / `FrequencyHandler` / `GroupHandler` | types | The five handler signatures | Typing every handler file |
| `ExtInput` / `AiInput` / `UniqueInput` / `FrequencyInput` / `GroupInput` / `FactInputBase` | types | What each handler is handed | Destructuring in a helper |
| `ExtResult` / `AiResult` / `GroupResult` / `FactErrorDraft` | types | What each may hand back | A helper that builds a return value |
| `FactGroup` | type | What `group(...)` builds — an entry in `defineFacts({ groups })` | A helper that assembles groups |
| `FactTools` / `ExtTools` / `AiTools` | types | The injected machinery; the lane-typed aliases fix what `stock()` returns | A helper that takes `tools` |
| `FactsConfig` / `FactsDeclaration` | types | `defineFacts`'s output / input | Building a table programmatically |
| `HttpJsonInit` / `HttpJsonResult` | types | `tools.http.json`'s options and result | Wrapping `http.json` |
| `AiSendEntry` | type | One model input: `{ kind: "text", key, value }` or `{ kind: "attachment", key, attachment }` | Re-slicing `inputs` before `ai.judge` |
| `ChainTools` / `LinkedResource` | types | `tools.chain` and the shape its blocksync read answers with | A helper that takes a chain read's result |
| `readLinkedDoc` | function | `(resource, opts?) => Promise<LinkedDocRead>` — a chain-anchored document by its `LinkedResource` (`{ serviceEndpoint, proof? }`). Reads the IXO Vault (`https://<network.>vfs.ixo.earth/public/cid/<cid>`), the IXO IPFS gateway (`https://ipfs.gateway.ixo.world/ipfs/<cid>`, or a bare `ipfs:<cid>` / `ipfs://<cid>` resolved through `opts.ipfsGateway`, default IXO's) and Matrix media download URLs. Hashes EXACTLY the bytes served against `proof`: a raw-sha256 CIDv1 (`bafkrei…`) or 64 hex (sha256); no proof ⇒ the CID the URL names. A mismatch is `{ ok: false, reason: "mismatch", retryable: false }` — fail closed, never try another store. A proof of another shape (a dag-pb `bafybei…`) cannot be recomputed: the read is `ok` with `verified: false`. Failures: `unsupported` (`matrix:<cid>` room state, `mxc://`), `unreachable` / `status` (`retryable` on a network error, 408, 429 or 5xx), `too-large` (`opts.maxBytes`, default 2 MiB), `not-json`. `opts.fetch` swaps the transport (default global `fetch`, `opts.timeoutMs` 10 s) | An ask's `#done` / `#payoutTerms`, a `#rub` for its labels, any entity doc an adapter reads — `if (!read.ok) throw read.retryable ? fact.retryLater(read.message) : …` |
| `linkedDocUrl` | function | `(serviceEndpoint, ipfsGateway?) => string \| null` — the URL `readLinkedDoc` would fetch, or `null` for an address that is no document | Linking to a document without reading it |
| `LinkedDocRef` / `LinkedDocRead` / `ReadLinkedDocOptions` | types | `readLinkedDoc`'s input, result (`{ ok: true, url, bytes, json, verified }` or `{ ok: false, url, reason, retryable, status?, message }`) and options | Wrapping it |
| `ClaimIntent` | type | What `tools.chain.claimIntent(...)` answers — the agent's filed commitment, `{ time, transactionHash }`, or `null` when it filed none | A helper that measures delivery from the intent, or a test that builds one |
| `withinRadius` | handler | Ready-made `ExtHandler` — is the claim within `meters` of a centre? `send: { lat?, lon?, photo?, centerLat, centerLon, meters }`. The point comes from the answer's own `lat`/`lon`, else the `photo` attachment's EXIF GPS, else `capturedAt.photo` / `capturedAt.after` ([geo evidence](#geo-evidence-grades-and-capturedat)). No point from any source is an **error** (the rubric's `onError`), never `false` | Registering a "delivered nearby" source: `ext: { deliveredNearby: withinRadius }` |
| `located` | handler | Ready-made `ExtHandler` — does the claim carry coordinates at all, from any of the same sources? `send: { lat?, lon?, photo? }`. The one geo check where "none" is `false`: presence is its question | A rubric that only needs "has a location" |
| `beforeAfter` | handler | Ready-made `ExtHandler` — do two photo attachments show progress, in order and (optionally) nearby? `send: { before, after, meters?, lat?, lon? }`. `after` must be taken at or after `before` (both photos' EXIF, else both `capturedAt` readings); with `meters`, each photo's place (EXIF GPS, else its `capturedAt`, else the answer's `lat`/`lon`) must be within it. `meters` absent needs no GPS at all. A missing photo, time or place is an **error**, never `false` | A before/after delivery-photo gate |
| `haversineMeters` | function | `(a, b) => metres` — great-circle distance between two `{ lat, lon }` points | Building your own distance check on top of `photoFactsFromJpeg` |
| `photoFactsFromJpeg` | function | `(bytes) => PhotoFacts` — a JPEG's EXIF GPS point and shutter time (`DateTimeOriginal`, falling back to `DateTime`), or nulls for anything that isn't a JPEG with EXIF | Reading a photo's own coordinates/timestamp outside the three handlers above |
| `PhotoFacts` | type | `{ point: { lat, lon } \| null; takenAt: string \| null; software: string \| null }` — what `photoFactsFromJpeg` returns (`software` is IFD0 `Software`, verbatim) | Typing a helper that wraps it |

#### Geo evidence: grades and `capturedAt`

The geo handlers grade every time and place by who reported it, and write the grade into the
fact's `reason` (so it reaches the trace and the receipt), e.g. `412 m from centre, limit 500 m;
evidence: camera (photo EXIF)` or `after is 600 s after before, 3 m apart; evidence: time
browser-reported (capturedAt), place browser-reported (capturedAt.before) / camera (photo EXIF)`:

| Grade | Source |
| --- | --- |
| `camera` | The photo's own EXIF (GPS, `DateTimeOriginal`) |
| `browser-reported` | EXIF the capturing app wrote from the browser's reading into a photo that had none (IFD0 `Software` = `openhands-app`), or the claim's per-photo `capturedAt` |
| `claim` | The answer's own `send.lat` / `send.lon` |

`capturedAt` is the supported form field for per-photo capture metadata: an object keyed by the
photo's role — `before` and `after` for `beforeAfter`, `photo` (else `after`) for `withinRadius` /
`located` — each `{ at: <ISO instant>, lat?, lon?, accuracy? }`. A stamp with no zone is read as
UTC; `accuracy` is carried, not used.

```json
{ "capturedAt": {
    "before": { "at": "2026-09-20T09:00:00Z", "lat": 46.05, "lon": 14.50, "accuracy": 12 },
    "after":  { "at": "2026-09-20T09:40:00Z", "lat": 46.05, "lon": 14.50 } } }
```

Missing evidence is an **error**, never `false`: a phone with location off, a photo forwarded
through a messenger (EXIF stripped), a screenshot or a canvas resize answer nothing either way, so
the rubric's `onError` decides. `false` means only "the evidence says no": too far, or out of order.
EXIF and `capturedAt` are both client-supplied; a grade says where a value came from, never that it
is true.

### `@ixo/decision-oracle/testing` — node-safe, what a test imports

```ts
import {
  fakeTools,
  httpResponse,
  runFacts,
  sampleClaim,
  sampleCtx,
  sampleManifest,
} from "@ixo/decision-oracle/testing";
import type {
  ChainCall,
  ClaimIntentQuery,
  FactIssue,
  FakeChain,
  FakeHttpRoute,
  FakeTools,
  FakeToolsOverrides,
  HttpResponseEnvelope,
  LogRecord,
  ProducedFacts,
  RunFactsInput,
  SampleClaimOptions,
  SampleManifestDecl,
} from "@ixo/decision-oracle/testing";
```

| Export | Kind | What it is | Use it when |
| --- | --- | --- | --- |
| `runFacts` | function | The **same runner** production uses (minus the fact cache and D1), then the **same** `validateFacts` | Every handler test |
| `fakeTools` | function | `FactTools` with every lane answered from a literal, plus a `logs` array | Every handler test |
| `httpResponse` | function | `httpResponse(503, body)` — a canned answer whose **status** is the point | Testing a failure path |
| `sampleClaim` | function | A `PulledClaim` whose body is your answers | Every handler test |
| `sampleManifest` | function | The engine's work order for exactly the names you list | Every handler test |
| `sampleCtx` | function | The frozen clock read + collection window | Pinning a date rule |
| `ProducedFacts` | type | `{ ext, ai, unique?, frequency? }` — what `runFacts` returns, plus `issues` | Naming the result |
| `FactIssue` | type | `{ code, path, message }` — one coverage failure | Asserting on `issues` |
| `FakeTools` / `FakeToolsOverrides` / `FakeHttpRoute` / `HttpResponseEnvelope` / `LogRecord` | types | `fakeTools`'s shape and options | A shared test helper |
| `ChainCall` / `ClaimIntentQuery` / `FakeChain` | types | What `chainCalls` records, the query a `chain.claimIntent` function is handed (`{ agentAddress, collectionId, before, after? }`), and the `chain` override's own shape | Asserting on the chain reads a handler made |
| `RunFactsInput` / `SampleClaimOptions` / `SampleManifestDecl` | types | The three builders' options | A shared test helper |

### `@ixo/decision-oracle/runtime` — worker only, what a route/cron/Workflow calls directly

```ts
import {
  AppError,
  broadcastAsOracle,
  decisions,
  getCollection,
  getDb,
  getIxoSdk,
  getOracleSigner,
  getQueryClient,
  loadConfig,
  registerCollection,
  rehireOpenhands,
  scanCollection,
  signEvaluation,
  watchedCollections,
} from "@ixo/decision-oracle/runtime";
import type {
  BlocksyncDeps,
  ChainWriteOutcome,
  CollectionNode,
  Config,
  Database,
  DecisionRow,
  OracleSigner,
  RegisterInput,
  WatchedCollection,
} from "@ixo/decision-oracle/runtime";
```

Separate from the root because the root is what your entry module imports and `/facts`/`/testing`
are what your node test suite imports; this subpath pulls in D1, the signer and the
sequence-manager DO, so it can only ever be loaded inside a Worker. Every function takes `env`
rather than the kit's internal deps — it assembles the config, the logger, the metrics, the trace
and the database handle from it exactly as the cron does (`cron/scan.ts`'s `cronContext`).

| Export | Kind | What it is | Use it when |
| --- | --- | --- | --- |
| `signEvaluation` | function | `(env, claimId, amount?) => Promise<ChainWriteOutcome>` — signs a decision the act policy `held` back (the release pass, `lib/ixo/chain-write.ts`). `amount`, when given, is the payout to put on `MsgEvaluateClaim` — the whole point of holding (a payout pooled across several claims) — under the same owner opt-in as the hook's (`operatorAmounts`; off ⇒ the lane's default, logged). Every guard of the lane still applies, so it is safe to call twice: an already-`submitted` row answers `"skipped"`, never a second signature | Your own release trigger for a pooled payout — a batch job, an operator action, a cron of your own |
| `scanCollection` | function | `(env, collectionId) => Promise<number>` — runs the cron's per-collection discovery pass, right now, for ONE collection. Builds its context with the SAME `cronContext` helper the tick itself uses, so a webhook and the cron racing on one claim start it exactly once. Throws `notFound` if the collection isn't registered, `conflict` if it is paused | Reacting to your own webhook the instant a claim is submitted, instead of waiting for the collection's next turn in the tick's rotation |
| `registerCollection` | function | `(env, input: RegisterInput) => Promise<WatchedCollection>` — the SAME four gates (WHO/HIRED/PROTOCOL/PAID), delegation resolution, upsert and paused→active notice `POST /v1/collections/register` runs (`lib/register.ts`), as a function. `input.cursor` is the one thing the HTTP body schema never exposes: applied on FIRST registration only — a re-register can never rewind it (it is not in the upsert's conflict `set`). `null` scans the collection from claim zero (the existing NULL-cursor path, blocksync `CLAIMS_FROM_START`) for a collection whose claims may predate this oracle's registration; absent seeds the cursor to now, the HTTP route's own behaviour, unchanged | Your own Workflow registering a collection AS the oracle's own principal, after minting its own evaluate grant — there is no inbound request to authenticate or a body to validate |
| `broadcastAsOracle` | function | `(env, msgs, memo?) => Promise<{ code, transactionHash, rawLog? }>` — signs and broadcasts arbitrary messages as THIS oracle's account, the same sequence-managed signer the act lane uses (`getOracleSigner`, `lib/ixo/chain.ts`). Memo defaults to your oracle's own `name`. Does **not** inspect `res.code` — unlike `signEvaluation` this is a raw broadcast primitive, not a decision lane, so a non-zero code is yours to interpret. Throws `AppError.unavailable` when `ORACLE_SECP_MNEMONIC` is unset, before the signer is ever built | Composing your own `MsgExec` around kit-issued messages |
| `rehireOpenhands` | function | `(env, collectionId) => Promise<"started" \| "existing" \| "restarted" \| "throttled" \| "failed">` — starts the OpenHands hire for a collection, or reruns its ended instance (spending the collection's backstop restart budget: three a day). A rerun mints only when the chain says the grant is stale or below 50 verdicts | `openhands` on, and your own `act` policy counts verdicts and sees an ask running low before the backstop's next visit |
| `AppError` | class | The kit's typed error — `.notFound()`, `.forbidden()`, `.conflict()`, `.unavailable()`, `.internal()`, … (§13) | Catching/rethrowing what the four functions above throw, in your own error handling |
| `ChainWriteOutcome` | type | `"submitted" \| "skipped" \| "blocked" \| "held" \| "owner_submitted"` — what `signEvaluation` resolves to, and (alongside `"pending"`, the row's initial state before any write is attempted) the full `decisions.chainStatus` enum. A decision the act lane can never sign (an engine `unusable`, a `decided` review verdict) starts `"skipped"` instead; a `pending_review` starts `"pending"` and its fold acts | Branching on the result, or reading `chainStatus` off `GET /v1/claims/:claimId` |
| `RegisterInput` | type | `{ collectionId, principal: { did, address }, contactEmail?, delegationCar?, cursor? }`. No `contactEmail` sent and none stored ⇒ the row takes the `contactEmail` of the oldest-activated `active` Decision API source whose `companyEntityDid` is the collection's COMPANY as the engine derives it (`GET /v1/collections/:id/company`, UCAN `collection/company-read`: the collection's entity, or the org a deed is linked to both ways); a `disputed` company, a collection the engine does not know, or none such source ⇒ stays empty. Its `503` or any other failure never fails the register (logged `register.company-unavailable`, the row stays empty for a later register to fill). An explicit or stored contact is never overwritten. The SAME answer (one engine read per register) also links the collection to every `active` source of that company (`api_source_collections`, `linkedBy` = the company's entity DID, audit `source.collection_linked` by `system:company-link`; idempotent — an existing link is left alone); only the engine's `ok` links. The engine is asked only when no contact is sent or stored, or an `active` source is not yet linked to the collection. | Building the input to `registerCollection` |
| `WatchedCollection` | type | The `watched_collections` row `registerCollection` and `scanCollection` read and write | Typing a helper that takes one |

**Plumbing, verbatim.** The chain/db pieces the four functions above are themselves built from,
re-exported here with no wrapper and no behaviour change — because the package's `exports` block
only opens the root, `/facts`, `/testing` and this subpath, a deep import is not an option:

| Export | From | What it's for |
| --- | --- | --- |
| `getIxoSdk` / `getQueryClient` / `getOracleSigner` / `OracleSigner` | `lib/ixo/chain.ts` | The lazily-loaded chain SDK, the memoized RPC query client, and the oracle account signer `broadcastAsOracle` itself calls |
| `loadConfig` / `Config` | `config.ts` | The same validated config every lane reads |
| `getDb` / `Database` | `db/client.ts` | The Drizzle handle over the `DB` binding |
| `watchedCollections` / `decisions` / `DecisionRow` | `db/schema.ts` | The two tables (`WatchedCollection` is above), for a query of your own alongside these functions |
| `getCollection` / `BlocksyncDeps` / `CollectionNode` | `lib/ixo/blocksync` | The on-chain collection read `registerCollection` itself calls |

**`policies.act`, in full.** `ActPolicy` (exported from the package **root**, §1 above — it's a
type-only seam with nothing to validate at registration, so it doesn't live under `/runtime`):

```ts
type ActPolicy = (ctx: ActContext) => Promise<ActDecision> | ActDecision;
```

`ActContext` — everything the act lane knows about the claim it is about to sign for, handed once
every one of the lane's own guards has passed and immediately before it reads the authz grant:

| Field | Type | What it is |
| --- | --- | --- |
| `claimId` | `string` | |
| `collectionId` | `string` | |
| `evaluationStatus` | `1 \| 2` | The chain's `EvaluationStatus` the lane is about to sign — 1 approve, 2 reject |
| `verdict` | `string \| null` | The engine's verdict string |
| `decision` | `Decision` | The engine's decision object verbatim (`decisions.decision`) |
| `receiptCid` | `string` | The engine's signed receipt CID |
| `facts` | `ActFacts` | The facts the decision was made on, frozen on the decision row (`decisions.facts`) — `ext.<name>` / `ai.<name>` only, never answer values |
| `payment` | `ClaimPayment \| undefined` | The claim's payment plane (`lib/ixo/payment.ts`): its own coins, and whether an intent governs |
| `defaultAmount` | `{ denom, amount } \| undefined` | What the lane would put on `MsgEvaluateClaim` without the policy — `undefined` ⇒ the chain's own default |
| `operatorAmounts` | `"off" \| "allowed"` | Whether a `sign` `amount` will stand: `"allowed"` only while the collection's **current** chain owner opted in (`PATCH /v1/collections/:id/settings`; a yes set by a previous owner reads `"off"`). On `"off"` the lane pays `defaultAmount` whatever you return — `hold` or `skip` if that is not the payout you mean |
| `claim` | `ClaimNode` | The on-chain claim as Blocksync returns it (`lib/ixo/blocksync` `getClaimById`) |
| `env` | `OracleEnv` | |
| `logger` | `Logger` | |

`ActDecision` is what the hook returns: `{ mode: "sign", amount?, reason? }` proceeds (`amount`,
when given, **replaces** what the lane would have sent — denom included — but only on a collection
whose owner set `operatorAmounts: "allowed"` (`PATCH /v1/collections/:id/settings`) and still
owns it — a transfer turns the yes off until the new owner sets it again; while it is
`"off"`, the default, the lane pays its own default and logs `act.amount-ignored-not-opted-in`,
and on an intent claim, where the chain overwrites it anyway, it logs `act.amount-ignored-intent`); `{ mode: "hold", reason? }` parks the row
`chainStatus: "held"` for a later `signEvaluation`; `{ mode: "skip", reason? }` leaves the row
untouched, the same clean no-op as `AUTO_SIGN` being off. The hook runs inside the act step's own
retry, so a throw is a retry — never a silent signature: the lane records the error on the row and
rethrows.

**Writing one that cannot be gamed by a bad rubric.** The lane guarantees only its own half: nothing
but an explicit `sign` reaches the grant read and the broadcast, and an answer it cannot read — a
typo, an old shape, `undefined` — parks the row `blocked` rather than signing it. The other half is
yours, and it turns on two questions.

*Is the fact really there?* `ctx.facts` is keyed by the **rubric's** names, so a name the rubric never
declared is simply absent, and a handler that failed leaves `status: "error"` with no `value` at all.
Written as `facts.ext.siteWork?.value === true ? hold : sign`, absent, errored and honestly-false all
collapse into one branch. Read presence and status **before** value, and give the unknown case a
branch of its own:

```ts
const site = ctx.facts.ext.siteWork;
if (!site || site.status !== "ok") { /* the unknown branch — decide it deliberately, below */ }
```

*Which of your modes is the expensive one?* There is no universally safe default, because `hold`
means whatever your release pass makes it mean. Where a held row waits for a person to look at it,
holding the unknown is the cautious answer. Where a held row is entered into a **pooled payout** and
settles for a share of the whole pot when the group closes, holding is the most expensive thing you
can do with the claim you understand least — and if the marker fact is declared on only some asks,
"absent" is the *normal* state of every other ask, so holding it strands ordinary work behind your
stale timer. Work out which mode costs more in **your** design, make the unknown take the cheaper
one, and make it loud: log the fact's `detail`, or sign with a `reason` naming the value you did not
expect. A rubric that has stopped matching its claims shows up first as an unknown you swallowed.

### Types you were not given an export for

The published types reference internal shapes (`PulledClaim`, `Manifest`, `Attachment`, …) that
have no name on the public surface — deliberately: they belong to the package's internals and are
reachable from the handler types by indexed access, which can never drift from them.

```ts
import type { AiInput, ExtInput, ExtTools, FactTools, UniqueHandler } from "@ixo/decision-oracle/facts";

type PulledClaim = ExtInput["claim"];
type Manifest = ExtInput["manifest"];
type ManifestExt = ExtInput["expected"];
type ManifestAi = AiInput["check"];
type CtxFacts = ExtInput["ctx"];
type FactSettings = ExtInput["settings"];
type Attachment = ReturnType<FactTools["attachments"]["on"]>[number];
type AttachmentBody = Awaited<ReturnType<FactTools["attachments"]["bytes"]>>;
type Agreement = NonNullable<Awaited<ReturnType<FactTools["agreement"]>>>;
type Provenance = NonNullable<Awaited<ReturnType<FactTools["c2pa"]["screen"]>>>;
type QueryClient = Awaited<ReturnType<FactTools["chain"]["query"]>>;
type ExtFact = Awaited<ReturnType<ExtTools["stock"]>>;
type UniqueFact = Awaited<ReturnType<UniqueHandler>>;
```

---

## 2. `defineOracle(config)`

```ts
// src/index.ts — the whole file
import { defineOracle } from "@ixo/decision-oracle";
import { facts } from "./facts";

const oracle = defineOracle({
  name: "my-oracle",
  description: "Evaluates delivery claims against the collection's rubric.",
  supportedProtocols: [],
  pricing: { evaluation: { denom: "uixo", minAmount: "1000000" } },
  policies: { autoSign: true, allowAiChecks: true, allowImageChecks: true, allowStandingApprovals: false },
  facts,
});

export default oracle;
export const { ClaimIngest, ReviewComplete, SequenceManagerDO } = oracle;
```

Those **three re-exports are mandatory**: the Workers runtime instantiates Workflow and Durable
Object classes *by export name*, and the names here must match `class_name` in your
`wrangler.jsonc` (`workflows[].class_name` → `ClaimIngest` / `ReviewComplete`;
`durable_objects.bindings[].class_name` → `SequenceManagerDO`). An OpenHands checker (`openhands`
set, below) re-exports a fourth, `OpenhandsHire`, bound as `HIRE_WORKFLOW`.

### `OracleConfig`

| Field | Type | Validation | Meaning / default |
| --- | --- | --- | --- |
| `name` | `string` | non-empty | The marketplace label on `GET /v1/info`, and the memo on every `MsgEvaluateClaim` this oracle signs. Also the default `EMAIL_FROM_NAME` |
| `description` | `string` | any string | The listing blurb |
| `supportedProtocols` | `string[]` | every entry must start with `did:ixo:entity:` | Register gate 3: a collection whose protocol is not listed is refused `409`. `[]` ⇒ **no restriction**. Convention: exactly one entry — one oracle, one protocol |
| `pricing.evaluation.denom` | `string` | non-empty | The denom a collection's on-chain `Payments.evaluation` must be in |
| `pricing.evaluation.minAmount` | `string` | `/^\d+$/` — an **integer base-unit string** | The minimum that payment must carry, compared with `BigInt`. Below it, register is refused `402`: the chain's split of that coin (80% to the oracle, 10% to the network, 10% to the relayer node — the x/claims `network_fee_percentage` / `node_fee_percentage` chain params, governance-set, 10/10 on mainnet today; rounding remainder to the oracle) is how the oracle earns. Both fields are replaced, per deployment, by the `EVALUATION_DENOM` / `EVALUATION_MIN_AMOUNT` vars when those are set (required on mainnet) — what register charges and `/v1/info` serves is the result (`effectivePricing`) |
| `pricing.byProtocol` | `Record<protocolDid, { denom, minAmount }>` | optional; each entry the same shape/validation as `pricing.evaluation` | Per-protocol override, keyed by `did:ixo:entity:…`. Register's gate 4 resolves the collection's protocol against this map first, falling back to `pricing.evaluation` when unlisted. `minAmount: "0"` lets that protocol's collections register with an EMPTY `Payments.evaluation` leg — for an oracle whose income for it isn't the evaluation split. `GET /v1/info` advertises this map alongside `pricing.evaluation` |
| `pricing.api.amountMicroUsd` | `number` | optional (`pricing.api` whole is optional); an integer ≥ 10 000 and a multiple of 10 000 (whole cents ≥ 1¢), micro-USD (1 upay = 1 µUSD; $1.20 = `1_200_000`) | The Decision API lane's price per decision — a separate thing from the chain fees above. Published at `GET /v1/api/protocols[/:did]` as `price` (public, `cache-control: public, max-age=300`), quoted in every 402, and checked by the facilitator against every offer (`price_mismatch` otherwise). A function of the protocol alone — there are no per-source prices; a partner who should not pay gets billing mode `none`. Every price is a whole number of cents, at least 1¢ (a multiple of 10 000 µUSD — anything else fails at startup), so `price.amountCents` and `price.amount` (upay) are both exact |
| `payouts` | `Record<protocolDid, { amount: string; currency: string \| { path: string } \| { asset: string; decimals: number } }>` | optional; `amount` / `currency.path` are a path like `$.invoice.total` or `$.lines[0].amount` (`$` then `.key` / `[n]` only); `currency` an ISO 4217 code (`EUR`), or `{ asset: "eip155:<chain>/erc20:0x…", decimals }`; nothing else (a `payee` or `readers` key is refused); with a non-empty `supportedProtocols`, every key must be in it (refused at module evaluation) | Decision Payouts: where a protocol's claims carry the amount to pay. The oracle sends it to the engine as the `payout` block (outside the facts) on the decide call and the fold of EVERY claim of the protocol; the engine reads the amount from the claim's answers and, when the claim is approved, binds the payment into the decision's one credential: `ClaimRecord.decision.receipt` is then also typed `DecisionToPayCredential` and carries the payment binding, public at the engine's `/v1/receipts/:cid` (contracts 1.13.0 removed the separate `decisionToPay`). The engine derives the payee — a chain claim's on-chain agent; an API claim's `submitter`, else the answer the source's company link names (`payeeField`) — and answers `decisionToPayRefused: "NO_PAYEE"` (a receipt with no binding, logged `payout.refused`) when there is none. Submitter ids must be **opaque** (never a name, email or phone), at most 256 characters: the public receipt names the payee by that id. Absent ⇒ no payout block, nothing changes |
| `pricing.api.byProtocol` | `Record<protocolDid, number>` | optional; each value micro-USD held to the same rule as `amountMicroUsd` — an integer ≥ 10 000 and a multiple of 10 000 (whole cents ≥ 1¢); with a non-empty `supportedProtocols`, every key must be in it (refused at module evaluation) | Per-protocol override of `pricing.api.amountMicroUsd`. Resolution: `byProtocol[did]` → `amountMicroUsd` → the `API_PRICE_USD_CENTS` var × 10 000 → none: the protocol answers `price: null`, and a submit under it is `503 SOURCE_PAUSED { payments-disabled }` (nothing is offered at a price the facilitator cannot check) |

Card is offered for a protocol only when its price is **≥ 50¢ (500 000 µUSD) per decision**. That is Stripe's minimum capture, and the facilitator checks every claim separately, so a batch doesn't lift it. Below that only `ixo-pay` is offered. Changing the price is a redeploy (the code value, or `API_PRICE_USD_CENTS` when `pricing.api` is absent). The facilitator re-reads prices within ~5 min; an offer refused `price_mismatch` in that window is retried.

| `policies.autoSign` | `boolean` | required | Sign `MsgEvaluateClaim` on a terminal verdict unattended. The `AUTO_SIGN` var must be on **too** — both, or no chain write |
| `policies.allowAiChecks` | `boolean` | required | `false` ⇒ every AI fact freezes as `error / ai-checks-disabled` with **no model call**, and the rubric's own `onError` carries. Also disables the duplicate lane's same-event judge (`aiOutcome: "skipped"`) |
| `policies.allowImageChecks` | `boolean` | required | Screen an AI check's image inputs for C2PA Content Credentials before the model call. Needs the `C2PA_IMAGE_CHECK` var on too. Fail-safe: it can only ever **add** a rejection |
| `policies.actSummary` | `string` | optional, 1–500 chars | How your `act` policy picks an amount, in your own plain words — published on `GET /v1/info` as `payouts.operatorAmount.summary` (only while `act` is set) so a company sees how it is paid before it opts in to `operatorAmounts`. The oracle never writes one for you |
| `policies.allowStandingApprovals` | `boolean` | required | Let an AI app's **standing** approval (the user's own auto-approve limits on the hosted MCP server) pay for a submit without a per-claim approval. Off ⇒ AuthHub cannot set one (an approval answer with `standing`, and `PUT /v1/oauth/standing/:grantId`, are `403 FORBIDDEN`, `details.reason: standing_approvals_off`), a standing already stored never pays, is never read back (`GET /v1/oauth/standing/:grantId` is `404`) and is deleted by the hourly housekeeping sweep, a card payment one parked for 3-D Secure is not resumed (`FORBIDDEN`, `standing_approvals_off`; the retry asks for the claim's own approval), and every OAuth submit needs the user's approval of that claim. `GET /v1/info` shows it under `policies` |
| `policies.reviewerNote` | `boolean` | optional | Make a **reviewer note** for every claim held for a person (`pending_review`): one AI call, on the same provider as the AI checks, that says in plain words what the answer claims and what no check could verify — `{ says, checkThese[], model, createdAt }`. Outside the decision: never sent to the engine, never in the receipt, never moves a verdict, no recommendation. Served beside `checks` on `GET /v1/reviews/:claimId` and `GET /v1/claims/:claimId` (never the submitter's view), erased with the claim body. Absent ⇒ **on for an OpenHands checker** (`openhands` set), off otherwise. Its spend is an `ai_calls` row (`fact: "review.note"`). A failed note is just absent (`review.note.failed` log) |
| `policies.act` | `ActPolicy` | optional; typed, not validated by Zod (`src/act.ts`) | Your say over what the act lane does with a decided verdict, once every other guard has passed and just before it reads the authz grant: `sign` (optionally overriding the amount, where the collection's owner opted in — `operatorAmounts`), `hold` (parks the row `chainStatus: "held"` for a later `signEvaluation`), or `skip` (a clean no-op, same as `AUTO_SIGN` off). Absent ⇒ the lane signs on its own, as today. Full `ActContext` field list in the `@ixo/decision-oracle/runtime` section (§1) |
| `facts` | `FactsConfig \| FactsProducer` | carried through untouched | Absent ⇒ the stock lanes for everything |
| `archive` | `ArchiveHooks` | optional; typed, not validated by Zod | How the answer archive (`ANSWERS_ARCHIVE`) reads your asks and answers. `describe(ask: ArchiveAsk) => Partial<ArchiveAbout>` (may be async): `ask` is `{ askId, entity, alsoKnownAs, collection, docs, docsOf(did) }` — the chain collection as Blocksync returns it, the entity's LinkedResources and settings as `{ id, type, json, error? }` (up to 12, 256 KB each, proof-checked by `readLinkedDoc`), and the same read for another entity; return any of `title`, `question`, `kind` (`photo` · `observation` · `url-check` · `other`), `place {name?, lat?, lon?, radiusM?}`, `reward {amount, denom}`, `postedBy {name?}`, `opensAt`, `closesAt` — `askId` and `proof` are always the chain's. `answer({ body }) => ArchiveAnswerView`: `{ text?, place?: {lat, lon, accuracyM?, at?} }`, exact. Absent ⇒ the `#pro` name (else `alsoKnownAs`), the collection's window and approval pay, nothing else ([guide §12](/build-an-oracle/decision-oracle#12-operating-it)) |
| `openhands` | `OpenhandsOptions` | optional; typed, not validated by Zod | Present ⇒ this oracle is an **OpenHands checker**: it answers the OpenHands app's hire and scan webhooks, mints its own evaluate grant under each poster's meta-grant, and keeps every hired ask judging (see "The OpenHands add-on" below). `openhands: {}` is the default; absent ⇒ none of it runs and `/v1/info` has no `openhands` |

Validation runs at **module evaluation**, not per request: a bad `minAmount` or an empty `name`
fails the isolate loudly. `facts` rides through Zod untouched — the TypeScript type is its contract.

`defineOracle` also registers the config in a one-per-isolate registry. Calling it twice with two
*different* config objects throws `defineOracle() was already called — one oracle per worker`;
re-evaluating the same module (bundlers, test pools) is harmless, because the check is on the
identity of the object you passed. Reading the registry before any `defineOracle` call throws
`defineOracle() was never called — export it from your worker entry`.

### The OpenHands add-on

An OpenHands ask is one deed entity plus one claim collection, posted by the OpenHands app for a
person or an agent. When the poster picks your oracle, their posting transaction grants your
`ORACLE_ADDRESS` a **meta-grant** (`CreateClaimAuthorizationAuthorization`, `EVALUATE`) — the right
to hand itself the collection's `EvaluateClaimAuthorization` once the collection exists — and the
app calls `POST https://<your host>/openhands/hire/<collectionId>`. With `openhands` set, the
oracle then runs the **hire** (`OpenhandsHire`, one durable instance per collection, the instance
id = the collection id):

1. **verify** — the collection on chain, your `admit` hook, and a live meta-grant **the collection's
   admin signed** naming itself (a grant anyone else signed is never minted under). No grant ⇒ the
   run ends; nothing is signed.
2. **mint** — `MsgExec(MsgCreateClaimAuthorization)` as this oracle: the poster's quota (an
   uncapped grant mints 10 000 verdicts at a time), the poster's payout ceiling verbatim (none ⇒
   your `maxCustomAmount`, else none, so the collection's own approval payment pays), lasting until
   the ask's end + 90 days (never past the meta-grant). Skipped when the chain already holds a
   matching grant with ≥ 50 verdicts left.
3. **register** — the same four gates as `POST /v1/collections/register` (protocol, fee and all),
   as the oracle's own principal, scanning from claim zero. A collection already watched is never
   re-registered (an operator's pause stands).
4. **scan** — the claims filed before the hire, started now.

A **backstop** runs every five minutes so a lost webhook never strands an ask: it finds meta-grants
whose collections are not watched yet and hires them, re-mints a watched collection whose poster
raised the ceiling or whose quota fell below 50, and lifts an `engine-no-grant` pause once the
chain shows quota again (at least 5 min after the last read — the engine's grant cache). Its
window reads only the watched collections that can need it — never read, hired or claimed in the
last day, quota below a top-up, or not read for a week — so a quiet ask costs one read a week (a
raised ceiling on one arrives by the hire webhook, or within that week). Each
collection gets at most three hire reruns a day from the backstop and three from the webhook, on
separate budgets. `POST /openhands/scan/<collectionId>` scans a watched collection at once (at
most once a minute; extra calls are scanned on the next minute tick). A scan that starts nothing —
usually because Blocksync has not indexed the claim yet — is looked at again on each of the next
three minute ticks, and no webhook scan moves the collection's place in the rotation. `GET /v1/info` advertises
both paths under `openhands`, which is how the OpenHands app tells a checker from any other oracle.

`OpenhandsOptions` (all optional):

| Field | Type | Meaning / default |
| --- | --- | --- |
| `admit` | `(ask: OpenhandsAsk, env) => string \| null \| Promise<…>` | Refuse an ask before anything is minted, registered or spent: return a reason (the hire ends with it, final) or `null` to serve it. `OpenhandsAsk` is `{ collectionId, admin, entity }`. **Every engine decision is billed to your oracle's owner**, so an oracle that charges no evaluation fee (`pricing.evaluation.minAmount: "0"`) should bound whose asks it judges here — a per-owner allow-list, a daily budget. A throw retries the step. Absent ⇒ every ask that granted you is served; the register gates still apply |
| `maxCustomAmount` | `(approvalDenom: string \| null) => { denom, amount }[]` | The payout ceiling written on the minted grant when the poster's meta-grant names none. Absent ⇒ none — the collection's approval payment pays every approved claim, which is what a priced ask wants. Only an oracle whose `policies.act` **names** amounts needs one |
| `onQuota` | `(quota: OpenhandsQuota, env) => void \| Promise<void>` | Told every quota read (the hire's mint step, the backstop): `{ collectionId, remaining, mintQuota, endsAt? }`. For an oracle that counts verdicts itself and tops up early with `rehireOpenhands` (`/runtime`). Fail-soft |

Bindings, with `openhands` on: the `HIRE_WORKFLOW` workflow (class `OpenhandsHire`) and, outside
`ENVIRONMENT=development`, the `OPENHANDS_RATE_LIMITER` ratelimit — unbound there, both webhooks
answer `503` (fail closed). `decision-oracle init --openhands` writes both. State lives in two
tables of your `DB` (`openhands_state`, `openhands_hires`, shipped migration `0025_openhands`).

### The whole-producer escape hatch

`facts` also accepts a single function, for the one case a per-name table cannot express — you
assemble the whole answer yourself:

```ts
facts: async (claim, expected, deps) => ({ ext: {…}, ai: {…} }),
```

It is structural (the type has no exported name): `expected` is the manifest, `deps` carries
`config`, `logger`, `ctx`, `settings`, `engine`, `db`, `env`, and the return type is
`ProducedFacts` (nameable from `./testing`). A whole-producer replaces the runner's wiring, so none
of §7's guarantees apply to it — prefer `defineFacts`.

---

## 3. `defineFacts({ ext, ai, groups, unique, frequency })`

```ts
// src/facts/index.ts
import { defineFacts, group } from "@ixo/decision-oracle/facts";
import { deliveryConfirmed } from "./deliveryConfirmed";
import { workScores } from "./workScores";

export const facts = defineFacts({
  ext: { deliveryConfirmed },          // unlisted ⇒ the STOCK ext lane
  ai: { "*": (_input, tools) => tools.stock() },  // "*" ⇒ every check you did not name
  groups: [group(["quality", "price"], workScores)],  // ONE call answers both names
});
```

Every field is optional; `defineFacts` fills the gaps (`ext: {}`, `ai: {}`, `groups: []`) so the
runner never branches on `undefined`.

| Key | Type | Unlisted / absent means |
| --- | --- | --- |
| `ext` | `Record<string, ExtHandler>` | The stock external-source lane: a self-signed UCAN POST to the declared `endpoint` with the resolved `send` map plus `claimBody`, read against the strict `{ valid }` / `{ score }` contract (§ "How an external source is called") |
| `ai` | `Record<string, AiHandler>` | The stock judge: the configured AI provider (`AI_PROVIDER`) with the rubric's own `instructions` over the declared inputs |
| `ai["*"]` | `AiHandler` | The one wildcard — it catches every check the table does not name. A named handler still wins |
| `groups` | `FactGroup[]` | Nothing grouped: every source is answered on its own. Each `group(names, handler)` takes over those `ext` names and answers them from ONE call (§4) |
| `unique` | `UniqueHandler` | The stock duplicate lane: this oracle's D1 registries, a keyed body digest, CID + perceptual-hash attachment lanes, a JS trigram near-text lane and the gray-zone same-event judge |
| `frequency` | `FrequencyHandler` | The stock submission count: one Blocksync read over the claim's own calendar window |

**A table is a DIFF against stock, never a replacement of it.** A handler for a name the manifest
does not declare produces nothing and logs `facts.handler-unused` once — a renamed rubric entry
shows up as that warning, not as a broken build.

**One name, one handler.** A name listed in `ext` *and* claimed by a group — or claimed by two
groups — throws at module evaluation (`defineFacts: 'quality' is answered twice …`), because the
alternative is a fact whose value depends on which lane wrote last.

---

## 4. Handlers

```ts
type ExtHandler       = (input: ExtInput,       tools: ExtTools)  => Promise<ExtResult>       | ExtResult;
type AiHandler        = (input: AiInput,        tools: AiTools)   => Promise<AiResult>        | AiResult;
type GroupHandler     = (input: GroupInput,     tools: FactTools) => Promise<GroupResult>     | GroupResult;
type UniqueHandler    = (input: UniqueInput,    tools: FactTools) => Promise<UniqueFact>      | UniqueFact;
type FrequencyHandler = (input: FrequencyInput, tools: FactTools) => Promise<FrequencyFact>   | FrequencyFact;
```

Synchronous handlers are fine — every signature accepts a value or a promise.

### What every handler is handed (`FactInputBase`)

| Field | Type | What it is |
| --- | --- | --- |
| `claim` | `PulledClaim` | `claimId`, `collectionId`, `network`, `lane` (the store the body came from: `"vfs"` \| `"vfs-public"` \| `"matrix"` \| `"ipfs"` \| `"cellnode"`), `metadata` (`agentDid`, `agentAddress`, `schemaType`, `submissionDate`, `amount`, `payment`, `evaluation`), `body`, `attachments` (CID/URL **descriptors**, never bytes), `media` (that store: `{ origin, cidUrl(cid), auth?() }` — holds its auth, never log it; `null` on the cellnode lane) |
| `answers` | `Record<string, unknown>` | `claim.body` — the credentialSubject, verbatim |
| `ctx` | `CtxFacts` | The build's **one** clock read and the collection window: `{ now, collection: { startDate, endDate }, claim: { submitter, submitterAddress, submissionDate, useIntent? } }`. All dates are `Z`-suffixed ISO or `null`. **Never read the clock in a handler** — `ctx.now` is what the engine's date rules compare against, and a second read makes a fact that cannot be replayed |
| `settings` | `FactSettings` | `{ allowAiChecks, allowImageChecks }` — this build's policy, from `defineOracle` |
| `manifest` | `Manifest` | The whole work order (§8): `fields`, `derived`, `ctx`, `usedBy`, every `ext`/`ai` declaration, `unique`/`frequency`, `sizeProbes` |

### `ExtHandler` — one external source

`ExtInput` adds:

| Field | Type | What it is |
| --- | --- | --- |
| `name` | `string` | The `<name>` in `ext.<name>` — the work-order key this handler answers |
| `expected` | `ManifestExt` | The declaration for this name: `expect` (`"boolean"` \| `"score"`), `onError`, `endpoint`, `audience`, `capability?`, `send`, `timeoutMs?`, `usedBy` |
| `send` | `Record<string, unknown>` | The declared `send` map **already resolved** against the answers and `ctx`: a `$path` ref becomes the answer (`null` when it walks off the data), a `ctx.*` token the frozen value, a literal itself, and a `~derived` slot `null` |
| `unresolved` | `string[]` | The `~derived` refs that `send` names — reported, never guessed. Their expressions are on `manifest.derived` |

Return: `boolean` · an **integer** `number` (basis points 0–10000, for `expect: "score"`) ·
`fact.ok(...)` · `fact.error(...)`.

```ts
import { type ExtHandler, fact } from "@ixo/decision-oracle/facts";

export const deliveryConfirmed: ExtHandler = async ({ send, expected }, tools) => {
  const res = await tools.http.json<{ status?: string }>(expected.endpoint, {
    method: "POST",
    body: { reference: send.reference },
    timeoutMs: expected.timeoutMs ?? 5000,
  });
  if (res.status === null || res.status >= 500) {
    throw fact.retryLater(`courier unavailable (${res.status ?? "no response"})`);
  }
  if (!res.ok) return fact.error(`courier answered ${res.status}`, { transcript: res.transcript });
  return res.body?.status === "delivered"; // a plain boolean ⇒ an `ok` fact
};
```

A `score` source returns integer basis points, never a float and never a percentage:

```ts
export const onTimeRate: ExtHandler = async ({ expected }, tools) => {
  const res = await tools.http.json<{ on_time_pct?: unknown }>(expected.endpoint);
  const pct = res.body?.on_time_pct;
  if (!res.ok || typeof pct !== "number") return fact.error(`courier answered ${res.status}`);
  return fact.ok(Math.round(pct * 100), { reason: `${pct}% on time` });
};
```

### `AiHandler` — one AI check

`AiInput` adds `name`, `check: ManifestAi` (`expect: "boolean"`, `onError`, `instructions`, `send`,
`usedBy`), `send`, `unresolved`, and:

| Field | Type | What it is |
| --- | --- | --- |
| `inputs` | `AiSendEntry[]` | The `send` map as the judge's typed, **declared-order** inputs: a scalar becomes `{ kind: "text", key, value }`; a `$ref` whose root question carries files becomes one `{ kind: "attachment", key, attachment }` per file (all of them, not just the first) |

Return: `boolean` · `fact.ai.ok(...)` · `fact.error(...)`.

The common shape is a guard in front of the stock judge — `tools.stock()` is typed as this lane's
own fact, so it needs no cast:

```ts
import { type AiHandler, fact } from "@ixo/decision-oracle/facts";

export const handoverPhotoLooksReal: AiHandler = async (_input, tools) => {
  if (tools.attachments.on("handoverPhoto").length === 0) {
    // Never `false`: that would claim a model looked at a photo and found it fake.
    return fact.error("the claim carries no handoverPhoto attachment");
  }
  return tools.stock();
};
```

Your own judge, your own rules, your own model:

```ts
export const photoShowsHandover: AiHandler = async ({ name, check }, tools) => {
  const images = tools.ai.inputsFor(check).filter((entry) => entry.kind === "attachment");
  if (images.length === 0) return fact.error(`${name}: the claim carries no image to judge`);
  const { JUDGE_MODEL } = tools.env as typeof tools.env & { JUDGE_MODEL?: string };
  return tools.ai.judge(
    [...check.instructions, "The parcel must be in the recipient's hands, not on a doorstep."],
    images,
    JUDGE_MODEL ? { model: JUDGE_MODEL } : {},
  );
};
```

### `GroupHandler` — several sources, one call

`group(["quality", "price"], handler)` in `defineFacts({ groups: [...] })` takes those `ext` names
over and answers them **from one call**. The case it exists for: four criteria judged by a single
structured model call. As four single handlers that is four completions for one judgment; as a group
it is one — and it is the sanctioned way to share a call between facts (a private cache inside a
handler still is not; the runner's own fact cache is the only cache).

AI checks cannot currently be grouped; `group()` answers `ext` sources only.

`GroupInput` is `FactInputBase` plus:

| Field | Type | What it is |
| --- | --- | --- |
| `names` | `string[]` | The names THIS call must answer: the group's names, minus any the manifest does not declare and any a previous attempt already paid for (a cache hit is kept, and the call it would have taken is what the cache saves) |
| `sources` | `Record<string, ExtInput>` | Per name, the same input a single `ExtHandler` gets — its own `expected` declaration and its own resolved `send` |

Return: `Record<name, ExtResult>` — one fact per name, each of them anything a single ext handler
may return (`boolean` · integer basis points · `fact.ok(...)` · `fact.error(...)`).

```ts
import { type GroupHandler, fact } from "@ixo/decision-oracle/facts";

export const workScores: GroupHandler = async ({ names, answers }, tools) => {
  const res = await tools.http.json<{ quality?: unknown; price?: unknown }>(
    "https://judge.example/score",
    { method: "POST", body: { summary: answers.workSummary }, timeoutMs: 45_000, attempts: 2 },
  );
  // One call, one failure: every name it owed becomes an error carrying the rubric's own `onError`.
  // Never a neutral score for a judgment that was never made.
  if (!res.ok) {
    const failed = `judge answered ${res.status ?? "nothing"}`;
    return Object.fromEntries(names.map((name) => [name, fact.error(failed)]));
  }
  const bps = (v: unknown, what: string) =>
    typeof v === "number" ? fact.ok(Math.round(v * 100)) : fact.error(`judge scored no ${what}`);
  return { quality: bps(res.body?.quality, "quality"), price: bps(res.body?.price, "price") };
};
```

The runner holds a group to exactly the rules a single handler meets, **per name**: each fact
carries its own declaration's `onError`, each is cached on its own, and a throw freezes every name
the call owed (only `fact.retryLater()` still gets out). On top of that:

- **A name you leave out is that name's own error** — `handler returned no fact for <name>` with the
  rubric's `onError`. Silence is not an answer and never reads as one.
- **A name nobody asked for is dropped** with one `facts.group-extra-fact` warning: a name the
  rubric does not declare, or one already answered from the cache. Nothing is overwritten.
- **The group is one slot in the fan-out cap**, not one per name — the cap counts the call, which is
  what costs money.
- **`tools.stock()` throws inside a group.** It could only mean "the stock lane for which of these
  names", so a grouped name's lane is the group; leave a name out of the group to keep its stock
  lane.

### `UniqueHandler` — the duplicate fact

`UniqueInput` adds `rule: UniqueRule`, the manifest's declaration **with the engine's own defaults
already applied**:

| Field | Type | Default when the manifest omits it |
| --- | --- | --- |
| `id` | `string` | — (always the manifest's `ruleId`) |
| `by` | `(string \| { ref: string; granularity?: "day" \| "month" \| "year" })[]` | absent ⇒ the whole canonical body is the identity |
| `countPrior` | `("approved" \| "pending_review" \| "queued")[]` | `["approved", "pending_review", "queued"]` |
| `attachments` | `boolean` | `true` |
| `ai` | `{ enabled: boolean; maxPairs: number }` | `{ enabled: true, maxPairs: 3 }` |
| `thresholds` | `{ rejectAtBps: number; reviewAtBps: number }` | `{ rejectAtBps: 9000, reviewAtBps: 5000 }` |

What the **stock** lane counts as a prior claim: every claim this oracle registered for the
collection **before** this one (ingest order, so a later copy never flags the original when the
original's evaluate step retries), whose verdict `countPrior` lists, or that is still waiting for one.
A claim that never reached a decision (a facts-lane outcome such as `invalid_facts`, a content
refusal, an `unusable` rubric, an ingest that failed or ran out of retries) is marked `void` and
never counts, so an honest resubmission of the same evidence is not its duplicate. The same filter
applies to every lane: exact digest, attachment CID, perceptual hash and near text.

Who submitted it decides the exact-digest score: the SAME submitter repeating a claim scores
10000 (`exact_body`, the reject band), another submitter's copy 8800 (`exact_body_cross_agent`,
review: a front-run copy is held for a person, never auto-rejected). The submitter is the chain
claim's agent address, or the API claim's `submitter` (keyed as its HMAC digest, the same key the
frequency count uses, so it survives erasure). Unknown on either side ⇒ different submitters.

Return the **wire shape** (§9): `ruleId`, `digest`, `scoreBps`, `band` are what the engine reads;
anything else you add rides loose into its trace. `tools.stock()` **throws** here by design — a
custom `unique` handler replaces its lane wholesale.

```ts
import type { UniqueHandler } from "@ixo/decision-oracle/facts";

export const unique: UniqueHandler = async ({ rule, claim }, tools) => {
  const res = await tools.http.json<{ digest?: unknown; scoreBps?: unknown }>(
    "https://registry.example/duplicates",
    { method: "POST", body: { claimId: claim.claimId, collectionId: claim.collectionId } },
  );
  const digest = res.body?.digest;
  if (!res.ok || typeof digest !== "string") {
    // A registry we could not reach is never a pass.
    return { ruleId: rule.id, digest: "registry-unavailable", scoreBps: 0, band: "review" };
  }
  const scoreBps = typeof res.body?.scoreBps === "number" ? res.body.scoreBps : 0;
  return {
    ruleId: rule.id,
    digest,
    scoreBps,
    band:
      scoreBps >= rule.thresholds.rejectAtBps
        ? "reject"
        : scoreBps >= rule.thresholds.reviewAtBps
          ? "review"
          : "pass",
    vendor: "registry.example", // extra fields ride into the engine's trace
  };
};
```

`band` is the branch the engine takes: `pass` is silent, `review` downgrades the decision to a human
review, `reject` fails the claim. It is a closed enum — a typo fails validation here rather than
misrouting there.

### `FrequencyHandler` — the submission-rate fact

`FrequencyInput` adds `rule: FrequencyRule | null`:

| Field | Type | Default |
| --- | --- | --- |
| `id` | `string` | the manifest's `ruleId` |
| `max` | `number` | **none** — a declaration without it cannot run |
| `per` | `"day" \| "week" \| "month" \| "year"` | **none** — same |
| `onBreach` | `"review" \| "reject"` | `"reject"` (the engine's default; informational: the engine rejects every breach regardless) |

`rule` is `null` when the manifest declared the rule without `max`/`per`. The **stock** lane is
never asked in that case (the build is refused as `FACT_MISSING "manifest lacks max/per"`); a
**custom** handler still is, because it may know the limit another way.

```ts
import { fact, type FrequencyHandler } from "@ixo/decision-oracle/facts";

export const submissionRate: FrequencyHandler = async ({ rule, manifest, claim }, tools) => {
  const ruleId = rule?.id ?? manifest.frequency?.ruleId ?? "frequency";
  const max = rule?.max ?? 0;
  const per = rule?.per ?? "day";
  const res = await tools.http.json<{ count?: unknown }>(
    `https://ledger.example/claims?agent=${claim.metadata.agentAddress ?? ""}&per=${per}`,
  );
  // An outage is not an answer: `unresolved` is a final reject on the engine, so retry instead.
  if (!res.ok) throw fact.retryLater(`ledger answered ${res.status}`);
  const count = res.body?.count;
  if (typeof count !== "number") {
    // `unresolved` is its own state — the engine fails it closed, never reads it as headroom.
    return {
      ruleId, count: 0, max, per,
      windowStartISO: null, windowEndISO: null,
      breach: false, unresolved: true,
    };
  }
  return { ruleId, count, max, per, breach: count > max };
};
```

`count` **includes** the claim under evaluation (it is on chain by the time it is judged), so
`breach` is `count > max`. Windows are **UTC calendar** periods; weeks are ISO weeks (Monday 00:00
UTC), derived from the claim's own on-chain `submissionDate` — never from evaluation time. The
rubric's `Frequency` declaration has no time-zone field, so there is no local day: "30 a day" means
30 per UTC day by chain submission time (a Sydney courier's working day spans two UTC days).

The stock lane freezes `unresolved` only when the window cannot be counted at all — the claim has
no submitter, or no usable on-chain `submissionDate`. A count that cannot be **read** (Blocksync
down, a 5xx, a timeout, an error payload) throws `unavailable` and the workflow step retries: the
engine rejects an `unresolved` fact for good, and an outage is not evidence. A custom handler owes
the same: `throw fact.retryLater(...)` on trouble, `unresolved` only for a deterministic miss.

### What a return becomes

| Return | Frozen fact |
| --- | --- |
| `true` / `false` (ext) | `{ status: "ok", value, reason: null }` |
| `8200` (ext, `expect: "score"`) | `{ status: "ok", value: 8200, reason: null }` — an integer 0–10000, never clamped |
| `true` / `false` (ai) | `{ status: "ok", value, reason: null, failedRules: [], score: 100 }` |
| `fact.ok(...)` / `fact.ai.ok(...)` | passed through verbatim |
| `fact.error(detail)` | `{ status: "error", onError: <the manifest's>, detail }` |
| anything thrown | the same error fact, logged `facts.handler-threw` |
| `throw fact.retryLater(detail)` | **not** a fact — the workflow step retries; facts already answered are kept in the fact cache. On the step's last attempt: the error fact `still unavailable on the last attempt: <detail>` |
| anything else (a string, `undefined`, `null`, a bare object) | an error fact naming the problem: `handler returned <kind> — expected boolean/number/a fact` |

---

## 5. `fact` helpers

```ts
fact.ok(
  value: boolean | number,
  extra?: { reason?: string | null; confidence?: number; transcript?: Transcript },
): ExtFact

fact.ai.ok(
  value: boolean,
  extra?: {
    reason?: string | null;
    failedRules?: string[];
    score?: number;
    confidence?: number;
    transcript?: Transcript;
  },
): AiFact

fact.error(detail: string, extra?: { transcript?: Transcript }): FactErrorDraft

fact.retryLater(detail: string): RetryLater   // `throw` it
```

| Helper | Produces | Defaults |
| --- | --- | --- |
| `fact.ok` | `{ status: "ok", value, reason }` (+ `confidence`, `transcript` when given) | `reason: null`; `confidence` omitted unless passed (integer basis points 0–10000) |
| `fact.ai.ok` | `{ status: "ok", value, reason, failedRules, score }` (+ `confidence`, `transcript`) | `reason: null`, `failedRules: []`, `score: 100`. `score` is confidence in the **verdict** (0–100), not input quality |
| `fact.error` | `{ status: "error", detail }` — a **draft** | The runner stamps the `onError` |
| `fact.retryLater` | A `RetryLater` error. Throwing it surfaces as `AppError.unavailable`, so the workflow step retries | — |

**`fact.error` takes no `onError` and there is no way to pass one.** The runner stamps the
manifest's own value over whatever a handler produced, in both directions. That is the invariant the
whole design rests on: a handler can never turn its own failure into an approval.

---

## 6. `FactTools`

Built **per fact** and injected, so `stock()` means "the stock lane for *this* fact". The type
parameter is which fact that is: `ExtTools = FactTools<ExtFact>` in an ext handler, `AiTools =
FactTools<AiFact>` in an ai one — which is why `return tools.stock()` type-checks with no cast.
`unique`/`frequency` handlers see the bare union, and there `stock()` throws.

### `tools.stock(): Promise<ExtFact | AiFact>`

Runs this fact's stock lane with **exactly** the input the runner would have handed it, and
normalizes the answer the same way. Wrap it, fall back to it, or ignore it — but never call it
*and* the declared endpoint yourself: that is two calls, and two charges, for one fact.

In a `unique`/`frequency` handler it throws `AppError.internal` explaining that such a handler
replaces the lane wholesale.

```ts
const base = await tools.stock();
if (base.status === "error") return fact.error(`stock lane: ${base.detail}`);
return base;
```

### `tools.http.json<T>(url, init?): Promise<HttpJsonResult<T>>`

```ts
interface HttpJsonInit {
  method?: "GET" | "POST";       // default "GET"
  headers?: Record<string, string>;
  body?: unknown;                // POST only — sent as JSON with a content-type
  timeoutMs?: number;            // default 15_000, per attempt
  attempts?: number;             // default 3
  // Sign the call as this oracle, for THE HOST OF `url` (audience did:web:<host>, never named by
  // you): `true` ⇒ { can: "*", with: "ixo:external-source" }; `{ capability }` ⇒ yours
  ucan?: true | { capability: { can: string; with: string } };
}
interface HttpJsonResult<T> {
  ok: boolean;                   // a 2xx on the last attempt
  status: number | null;         // null = NO HTTP response at all on any attempt
  body: T | null;                // the parsed JSON, null when absent or not JSON
  transcript: Transcript;        // hand it back on the fact
}
```

- **Never throws for an HTTP outcome.** A 404 and a 500 come back as results, because only the
  handler knows whether a 404 means "no such batch, so false" or "unanswerable".
- **Retry budget:** up to `attempts`, exponential backoff (1 s, 2 s, …), a ~30 s whole-budget cap.
  A 5xx, a `408` or a `429` burns an attempt and retries; any other 4xx ends the loop on the first
  answer (the same request gets the same answer, so `ok: false`, that `status`, `attempts: 1`).
  Pass `attempts: 1` for one shot even on a 5xx.
- **SSRF guard:** the URL is held to `assertPublicHttpUrl` (http/https only; `localhost`, private
  IPv4/IPv6 ranges and internal suffixes refused) because a handler's URL often comes from a claim
  answer. A refusal is an **outcome** — `{ ok: false, status: null, attempts: 0 }` — not a throw.
  Redirects are followed by hand, at most 3, each hop held to the same guard; a hop to a private
  host, an https → http downgrade or a malformed `Location` ends the call as `status: null`, never
  retried. A hop to **another origin** carries none of your headers but `accept` and
  `content-type` — no `authorization`, no vendor key like `x-api-key`, no idempotency key — so a
  UCAN minted for one host, or a secret meant for one API, never reaches another. The whole call,
  redirects included, stays inside the ~30 s budget.
- **`ucan`:** signs the call as this oracle (below, "How an external source is called") for the
  URL's own host. This is the way to call a source that authenticates you: the audience cannot
  drift from where the token goes.
- **Transcript:** the last attempt's request and response, the attempt count and elapsed ms.
  Credentials never reach it. A header is dropped when its name ends in `key` (`x-api-key`,
  `apikey`, `X-RapidAPI-Key`, `Ocp-Apim-Subscription-Key`, `api_key` …) or contains
  `authorization`, `token`, `secret`, `password`, `credential` or `signature`
  (`proxy-authorization`, `cf-aig-authorization`, `x-auth-token` …), and `cookie` / `set-cookie`
  always go; `idempotency-key` is kept. In the recorded URL, the value of a query parameter whose
  name contains `key`, `token`, `secret`, `password`, `signature`, `sig`, `auth` or `credential`
  becomes `[redacted]` (`?api_key=[redacted]`); the names and the rest of the URL are kept. A
  response body over 64 KiB is recorded as `{ truncated: true, bytes, sha256 }` — the result's own
  `body` is still whole. Name the response type or `body` is `unknown`.

### Signing a call as this oracle: `http.json(url, { ucan })`

A self-signed UCAN invocation from this oracle's `did:web:<ORACLE_HOSTNAME>` to the did:web of the
host `url` names, minted per `http.json` call (300 s TTL, a nonce, never cached — the same token
rides that call's retries and same-origin redirect hops) and sent as
`authorization: Bearer <token>` + `x-auth-type: ucan`. `ucan: true` signs
`{ can: "*", with: "ixo:external-source" }` — exactly what the stock connector presents, so a
handler calling a source by hand looks identical to it. Pass your own capability when your API
wants one; it is **yours**, set in code — never `expected.capability` (the rubric's, which the
oracle ignores; see below).

```ts
const res = await tools.http.json(expected.endpoint, {
  method: "POST",
  ucan: true,
  body: { ...send, claimBody: answers },
});

// Your API scopes its callers? Name the capability it checks — in YOUR code:
await tools.http.json("https://api.example/shipments", {
  ucan: { capability: { can: "shipments/read", with: "did:web:api.example" } },
});
```

An IXO service capability — any `ixo:` resource other than exactly `ixo:external-source`, or any
resource with whitespace or a control character in it — is refused with a plain `Error`. The runner
freezes that as an `error` fact carrying the rubric's `onError` (`facts.handler-threw`): a
deterministic negative, recorded, never a retry.

### `tools.http.ucanHeaders(audience, capability?): Promise<Record<string, string>>`

The same token as a header pair, for a call you make yourself — kept for compatibility. **The
audience must be the did:web of the host you send it to**; nothing checks that for you, so prefer
`http.json(url, { ucan })`, which binds it. The same capability default and the same refusal
apply.

### How an external source is called

What the stock `ext` lane (and a handler calling `http.json(endpoint, { method: "POST", ucan: true,
… })`) sends, so the API behind a source knows what to expect:

- **Request:** `POST <endpoint>` with a JSON body — the rubric's `send` map resolved against the
  claim (`$path` answers, `ctx.*` values, literals) plus the whole credentialSubject under the
  reserved `claimBody` key (a `send` entry of that name loses).
- **Headers:** `Authorization: Bearer <UCAN>`, `X-Auth-Type: ucan`,
  `Idempotency-Key: <claimId>:<name>` — a retry of the same fact carries the same key.
- **The UCAN:** issued by this oracle's `did:web:<ORACLE_HOSTNAME>`, audience the **endpoint's own
  did:web** (`did:web:<endpoint host>`, a `:port` percent-encoded as `%3A`), capability
  `{ can: "*", with: "ixo:external-source" }` by default or the one your handler passes, a 300 s
  expiry and a nonce (every token is unique).
- **Redirects:** followed at most 3 times, each hop re-checked against the public-host guard (no
  https → http downgrade); a hop to another origin carries only `accept` / `content-type` — never
  the `Authorization` or `Idempotency-Key`. The token is one per fact's call: its retries and
  same-origin hops reuse it.
- **What the API may verify:** the issuer's key from `https://<ORACLE_HOSTNAME>/.well-known/did.json`,
  that the audience is itself, and the expiry — and, if it keeps a list, which oracles it answers.
  It may equally ignore the token and answer anyone; the capability is a label the oracle's
  developer chose, not a grant anyone else issued.
- **Response:** `200` with `{ "valid": boolean }` (`expect: "boolean"`) or
  `{ "score": 0..10000 }` (an integer, `expect: "score"`), optional `reason`. A 5xx, `408`, `429`,
  network error or contract miss is retried in the call (3 attempts, ~30 s, a `Retry-After` honoured
  when the budget allows). **Down is not "no":** if the last attempt was still a 5xx, `408`, `429`
  or no answer at all, the lane throws `unavailable` and the workflow step retries (about 4 hours
  of backoff), so an outage never becomes a decision. Only a definitive answer becomes an `error`
  fact carrying the rubric's `onError`: any other 4xx (one call), or a malformed or off-contract
  `2xx`. On the step's **last** attempt an outage that is still there freezes as that `error` fact
  too, so it is bounded.

**The rubric's `capability` is ignored, and the audience is bound to the endpoint.** A rubric
belongs to the protocol — written by its owner and shared by every oracle serving it — so it may say
*where* to call and *what* to send, never what this oracle's key grants. The key's grants are its
holder's: the stock lane always signs `ixo:external-source`, logs `ext.source.capability-ignored`
(warn) when the manifest declares one, and signs only when `audience` is exactly the endpoint's own
did:web. A mismatch signs and sends nothing: the fact is `error` with the rubric's `onError` and the
detail `the source's audience … must be the endpoint's own did:web …` (transcript `attempts: 0`).
`decision-oracle protocol review` and `rubric validate` print both findings per source, and exit `1` on
a refused source; `facts scaffold` writes no stub for one — the rubric is what has to change.

**No IXO service capability ever leaves for an external call.** The engine and the payment
facilitator accept a token self-signed by this oracle's did:web *as this oracle*, so a capability on
`ixo:eval-engine`, `ixo:ixo-pay-facilitator`, `ixo:eval-oracle`, `ixo:oracle/…` or any other `ixo:`
resource except `ixo:external-source` is refused by the stock lane, `http.json({ ucan })` and
`ucanHeaders` alike — as an `error` fact with the rubric's `onError`, never a crash. The check
becomes redundant once those services accept oracle authority only from the oracle itself; it
stays until then.

### `tools.ai.judge(instructions, inputs, opts?): Promise<AiFact>`

The stock judge with **your** rules: the configured AI provider, a static compliance-evaluator prompt with your
numbered ruleset appended, the typed inputs last, strict structured output.

- **Policy gate:** `settings.allowAiChecks === false` ⇒ returns `error / ai-checks-disabled` with
  **no model call**. Never approve on a check that did not run.
- **Strictness is your rules':** the prompt judges each rule exactly as its text states it and adds
  no rule of its own. A rule that says to be lenient (a dim photo is still evidence, let doubt lower
  the score rather than fail) is judged leniently; a rule that says to fail on doubt fails on doubt;
  a rule silent on it fails when the inputs do not show what it requires. A check for screens,
  prints or generated images runs only where one of your rules asks for a real photo.
- **Model:** `opts.model` overrides the pinned `AI_CHECK_MODEL`.
- **Images:** sent at the pinned `AI_IMAGE_DETAIL` (default `high`), and the transcript records it.
- **Provider:** `AI_PROVIDER` — the Cloudflare AI Gateway (the `AI` binding + `AI_GATEWAY_ID`) by
  default, OpenRouter with `OPENROUTER_API_KEY`. Either way the call carries
  `reasoning.effort = AI_REASONING_EFFORT` (default `low`). A missing piece throws `AppError.internal` — loud,
  so the step retries rather than freezing a verdict on a config fault.
- **Audio/video:** sent only with `AI_AUDIO_VIDEO=true` (OpenRouter only); otherwise each such
  attachment reaches the model as a "content not sent" descriptor.
- **Idempotency:** each call is keyed `${claimId}:${factName}#${n}`, where `n` counts calls within
  one fact's tools. The counter restarts with the tools, so a step retry replays the same keys, and
  two calls under one name can never collide on the provider's cache.
- **Three outcomes per rule:** the strict schema is `{ failed_rules, undeterminable_rules, reason,
  valid, score }`. A rule the readable evidence refutes **fails**; a rule the model cannot judge
  either way because an input is too blurred, dark, cropped, truncated or not sent — even read the
  way your rules say to read it — is **undeterminable**. Any undeterminable rule makes the fact an `error` (the rubric's `onError`,
  detail `undeterminable: R1 (<rule text>) — <model reason>`), never `valid: false`, even when
  another rule failed: an unreadable photo is not evidence that the work was not done.
- **Comparison guidance:** when two or more images reach the model (a before/after pair), the
  prompt adds a COMPARISON section: changes the work or the moment cause (dirt or growth removed, a
  colour revealed by cleaning, repairs, light, colour cast, angle, crop) are expected and never by
  themselves mean a different subject; sameness is judged from what the work cannot change (shape,
  structure, fixed markings, fittings, surroundings). Rubric authors no longer need to write that
  into each rule.
- **Verdict:** `valid` is derived **in code** from `failed_rules` (the model's own `valid` is
  ignored); `score` is clamped 0–100; a score below `AI_CHECK_MIN_CONFIDENCE` (default 60) is the
  unreachable path (an `error` fact), never a trusted verdict. So is any non-2xx, unparseable body,
  schema miss or empty completion. A 5xx, `408`, `429`, network error, malformed answer or a score
  below the floor is retried (each attempt a fresh sample, and a fresh paid call); any other 4xx
  ends the loop after that one call. A 5xx, `408`, `429`, network
  error or AI Gateway credits exhausted that is still there after the in-call attempts THROWS
  `unavailable` (the workflow step retries; the fact cache keeps every answer already paid for), and
  freezes as the `error` fact only on the step's last attempt.
- **`onError`:** inherited from the AI check being answered; `"review"` anywhere else.
- **Timeout:** 60 s per attempt for image and text inputs, 3 minutes when a PDF, video or audio
  input rides along (a PDF page is 10–30× an image's tokens), under the same 3-attempt budget. On the gateway an attempt past its deadline is aborted, and the gateway gets its own
  deadline 2 s sooner (`cf-aig-request-timeout`), so the provider call ends too.
- The transcript freezes the model id, the recorded request (attachments as CID + digest, never
  base64), the raw response, the provider's call id as `genId` (OpenRouter's generation id, or the
  gateway log id) and `usage`. `usage` (tokens + USD) is set only when the provider reports cost at
  call time (OpenRouter); on the gateway it is `null` and the USD of every billed attempt (a retry
  included) lands in the `ai_calls` table within ~5 minutes (cron `ai-cost-backfill`, from the
  gateway's log). A log not found for an hour, or with no price, is closed `unavailable`; the log
  API failing pauses the job without closing anything, unless it has refused a row for a day. `unique.aiCostUsd` is
  omitted when no judged pair has a known cost — never a fake 0.

```ts
return tools.ai.judge(["The photo shows a sealed parcel"], input.inputs);
```

### `tools.ai.inputsFor(check): AiSendEntry[]`

The declared `send` map as the judge's typed, ordered entries — what `AiInput.inputs` already is,
exposed so you can re-slice it (drop the text, keep the images, reorder, add your own).

```ts
const images = tools.ai.inputsFor(input.check).filter((e) => e.kind === "attachment");
```

### `tools.attachments`

```ts
on(question: string): Attachment[];
bytes(att: Attachment, opts?: { maxBytes?: number; timeoutMs?: number }): Promise<AttachmentBody>;
size(att: Attachment): Promise<number | null>;
```

- `on` filters `claim.attachments` by the **root question name** — `"deliveryPhoto"`, not
  `"$deliveryPhoto"`. Use `rootQuestionName("$deliveryPhoto.0")` to get one from a `$ref`.
- `bytes` defaults `maxBytes` to **10 MiB** — the same cap the AI lane uses, deliberately, so a
  handler reading a file itself never quietly gets a different limit. `content-length` is checked
  *before* the body is read, and the cap holds *while* it is read: a body streamed without a length
  (the VFS) stops at the cap, and its `size` is then `maxBytes + 1` — a lower bound, never counted
  further. Returns
  `{ ok: true, bytes }` · `{ ok: false, reason: "too-large", size }` · `{ ok: false, reason: "unfetchable" }`.
  "Too large" and "could not be read" are different answers: the first is a known file you can still
  describe, the second taught you nothing.
- `size` reads `content-length` without consuming the body. A store that answers 200 with no
  `content-length` (the VFS streams its files, so the header never arrives) has its body counted
  instead, up to 25 MiB. `null` on any failure, an empty body, or past that cap — and `null` is what
  the engine's null rule then decides on.
- The store's auth (the bot UCAN, or a fresh single-use VFS token per request) is sent **only** to
  that store's host; a `data:` URL carries its own bytes. `bytes` and `size` follow at most 3
  redirects, each hop held to the public-host guard (no https → http downgrade), and a hop to
  another origin carries none of the store's auth; a refused hop is `unfetchable` / `null`.

```ts
const [photo] = tools.attachments.on("handoverPhoto");
if (!photo) return fact.ok(false, { reason: "no handover photo on the claim" });
const body = await tools.attachments.bytes(photo, { maxBytes: 5_000_000 });
if (!body.ok) return fact.error(`photo could not be read: ${body.reason}`);
```

### `tools.c2pa.screen(bytes, mediaType?): Promise<Provenance | null>`

Reads C2PA "Content Credentials" off image bytes and verifies the certificate chain against the
official trust list. Returns `null` on **anything inconclusive** — no manifest (the common case: a
screenshot strips it), unreadable bytes, any internal error. Fail-safe by construction: it can only
ever add a rejection.

`Provenance` is `{ vendor, issuer?, model?, modelVersion?, aiGenerated, watermarked, signedAt?,
trusted, failures[] }`. **`trusted` is the only thing that makes the rest mean anything** — anyone
can self-sign a manifest naming any issuer.

Note the asymmetry: `policies.allowImageChecks` gates the **stock** AI lane's provenance
short-circuit, not this call. A handler that asks for the read gets it.

```ts
const provenance = await tools.c2pa.screen(body.bytes, photo.mediaType);
if (!provenance) return fact.ok(true, { reason: "no Content Credentials on the photo" });
return fact.ok(!(provenance.trusted && provenance.aiGenerated));
```

### `tools.chain` — the chain, read-only

Deep imports are blocked by the package `exports`, so these reads ARE a handler's access to
Blocksync and the chain. Generic on purpose: nothing here knows what an agent card is. The act lane
(signing) stays the package's, never a handler's.

```ts
interface ChainTools {
  linkedResources(entityDid: string): Promise<LinkedResource[]>;
  claimIntent(params: {
    agentAddress: string;
    collectionId: string;
    before: string;
    after?: string;
  }): Promise<{ time: string; transactionHash: string } | null>;
  query(): Promise<QueryClient>;
}
```

- **`linkedResources(entityDid)`** — what an entity anchors on chain (`#vct`, `#rub`, a card…),
  each with its `proof` content hash. The same read the package's own rubric lane uses. To read
  one's bytes, hand it to [`readLinkedDoc`](#ixodecision-oraclefacts--node-safe-what-a-handler-imports)
  (Vault, IPFS or Matrix, checked against that `proof`).
- **`claimIntent({ agentAddress, collectionId, before, after? })`** — the newest `MsgClaimIntent`
  this agent filed on this collection before `before` (normally the claim's submission date),
  **bare or authz-wrapped**: a delivery clock and the tx hash to cite for it, or `null` when it
  filed none. Pass the agreement's consent time as `after` so an earlier engagement's intent can
  never become the clock.
- **`query()`** — the `@ixo/impactxclient-sdk` query client for this oracle's network (balances,
  grants, module state). The SDK is heavy, so it loads on **first call** and the client is memoised
  per isolate; a handler that never asks never pays for it.

**There is no GENERAL message-history read, and a handler must not build one.** `claimIntent` is the
one lookup an oracle gets about what preceded a claim, and it is here so it stays ONE server-side
filtered query: Blocksync leaves `messages.from` NULL on an intent, so the indexer matches the agent
and collection inside the message's jsonb `value` (Postgres `@>`), bounds the time on the
transaction, and returns at most one row. An intent filed through authz — what the portal and the
boilerplate chain client both do — is nested inside a `/cosmos.authz.v1beta1.MsgExec` with an ARRAY
`value`, and `@>` is type-exact inside the json (the indexer's spelling of `collectionId` is its own
choice too), so each `or` arm carries its own `typeUrl`: two shapes × two id spellings, four arms,
still one round trip. Paging the chain for anything is a bug, not a fact.

**An empty answer is a fact; an unreachable indexer is not.** `[]` means "this entity anchors
nothing" and `null` means "this agent filed no intent" — answer on either. Blocksync being down
throws `AppError.unavailable`, which the runner lets
through as the workflow step's retry signal, so a chain outage retries instead of freezing a verdict.
Catch it only when "could not read the chain" is genuinely the fact you mean, and then say so with
`fact.error`.

```ts
// "Is the offer still the version we agreed to?" — an ext fact answered from the chain, not an
// endpoint. An entity that anchors nothing answers `[]`; a Blocksync outage throws and the step
// retries, so neither can read as a pass.
const cardUnchanged: ExtHandler = async ({ send }, tools) => {
  const { oracleEntityDid, cardProof } = send;
  if (typeof oracleEntityDid !== "string" || typeof cardProof !== "string") {
    return fact.error("the rubric's send map names no oracle entity / card proof to compare");
  }
  const card = (await tools.chain.linkedResources(oracleEntityDid)).find((r) => r.id === "#acard");
  if (!card?.proof) return fact.error(`${oracleEntityDid} anchors no readable #acard`);
  return card.proof === cardProof;
};
```

### `tools.agreement(counterparty?): Promise<Agreement | null>`

The engine's buyer statement for this collection: `{ id, digest, counterparty, doc }`, where `doc`
is never interpreted by the package beyond its `kind`.

- **Omit `counterparty`** for the claim's own agent (`claim.metadata.agentAddress`) — the usual
  question.
- **Pass an explicit `null`** for the collection's own terms, the statement that names no
  counterparty. The two are different questions and do not collapse.
- Returns `null` (and warns `facts.agreement-no-engine` once per fact) when the build carries no
  engine client — tests and CLI dry runs.

**Read it LAST**, right before returning the fact it feeds: the engine records the snapshot current
at decision time on its receipt, so reading it last is what makes the receipt name the version your
facts actually used.

**The agreement is read once per claim and shared.** The kit memoises the read per build, keyed by
the counterparty asked for, so five handlers calling `tools.agreement()` cost ONE engine round trip
and — the part that matters — all judge against the same snapshot. That is the kit's cache, not a
licence for a handler-private one: the runner's fact cache is still the only place an *answer* is
kept.

### `tools.logger` / `tools.config` / `tools.env`

- `logger` — structured (`debug`/`info`/`warn`/`error`, `child()`), landing in the oracle's Workers
  Logs stream. Never log answer values or `claim.media`.
- `config` — the validated `Config`: `IXO_NETWORK`, the eight resolved per-network URLs, hostnames,
  model and policy values. Secrets are on it too; do not log them.
- `env` — the step's worker env, so a handler can reach **its own** binding or secret. It is a
  getter that **throws** `AppError.internal("tools.env: this build carries no worker env")` in tests
  and CLI dry runs, where there is none — loud beats reading a binding off `undefined` three lines
  later.

```ts
const { COURIER_API_KEY } = tools.env as typeof tools.env & { COURIER_API_KEY?: string };
if (!COURIER_API_KEY) return fact.error("COURIER_API_KEY is not configured on this oracle");
```

---

## 7. What the runner guarantees

One code path runs every handler, stock or yours, so these hold identically for both.

1. **Normalization.** A plain `true` / `8200` becomes an `ok` fact; a `fact.error` draft becomes a
   full error fact; a full fact passes through verbatim; anything else becomes an error fact that
   *names* the bad return.
2. **Fail closed.** An error fact carries the **manifest's** `onError`, stamped here, overriding
   whatever the handler put there. A throw is the same thing.
3. **One retry signal.** Only `fact.retryLater()` — and the package's own `AppError`s, thrown by the
   stock lanes for a signing/config fault or an outage (the ext and AI lanes' 5xx / 429 / timeout /
   AI credits exhausted) — get out, as `unavailable`, so the workflow step retries instead of
   freezing a verdict. Every other throw is an error fact, logged `facts.handler-threw`. Bounded: on
   the evaluate step's last attempt a `retryLater` or an `unavailable` freezes as the rubric's
   `onError` (`still unavailable on the last attempt: …`, logged `facts.retries-exhausted`), so an
   outage that outlives every retry still ends as a decision, never as a dead ingest.
4. **The fact cache.** Every resolved `ext`/`ai` fact is written to this oracle's D1 as it lands and
   read back *once*, before any handler runs, on a later attempt of the same claim under the same
   rubric: a hit is returned verbatim — transcript, error status and all — and **the handler never
   runs**. So a crashed step never re-buys a paid AI or vendor call. A re-anchored `#rub` is a new
   work order: its facts run again, even the same-named ones. `unique`/`frequency` never cache:
   registry state and calendar windows must be fresh per attempt. Both sides are fail-soft — a cache
   error means "no cache", never a failed fact.
5. **The fan-out cap.** At most **4** ext+ai handlers in flight (`MAX_CONCURRENT_CONNECTORS`); the
   isolate allows 6 outbound connections and a 30-check rubric must degrade to "slower", not to a
   socket storm. `unique`/`frequency` run beside the pool, one read each. **Do not add your own
   concurrency.**
6. **Unused names.** A handler for a name the manifest does not declare is skipped with one
   `facts.handler-unused { kind, name }` warning — including a `unique`/`frequency` handler written
   for a rubric that declares no such rule.
7. **`ai["*"]`.** The wildcard catches every check not named in the table; a named handler wins.
8. **Groups.** A `group([...], handler)` runs **once** per claim and takes **one** slot in the cap;
   its grouped names never also run the single/stock lane. Every guarantee above then applies per
   NAME — own `onError`, own cache entry, a crash frozen on each — plus: a name the handler omitted
   is that name's `handler returned no fact for <name>` error, and a name nobody asked for is
   dropped with a `facts.group-extra-fact` warning. Two handlers for one name is a **boot** error.
9. **Derived refs.** The stock lanes refuse a `send` map naming a `~derived` ref (an `error` fact
   with the rubric's `onError`, logged `stock.derived-unsupported`). A **custom** handler is not
   short-circuited: it gets the refs in `unresolved` and the expressions on `manifest.derived`.
10. **Rule-less frequency.** A `frequency` declaration without `max`/`per` is warned
    (`stock.frequency-unrunnable`) and omitted from the FactSet — unless you wrote a custom handler,
    which is asked with `rule: null`.
11. **Coverage.** `validateFacts` (§9) runs over the whole answer before anything is frozen.

And around the runner, in the build: `ctx` is resolved **before** any handler runs (one clock read,
one Blocksync read), and after the FactSet is frozen there is no network, no clock and no AI
anywhere downstream. That boundary is the replay guarantee a review fold depends on.

---

## 8. The work order — `Manifest`

What the engine's binder resolved the protocol's rubric to want, read from
`GET /v1/rubrics/manifest?protocol=<did>` (public, KV-cached for 5 minutes). **This oracle never
parses a rubric** — the manifest is the only work order, and `bunx decision-oracle protocol review <did>`
prints it in plain words.

| Field | Type | What it is |
| --- | --- | --- |
| `rubricId` | `string` | The verified rubric id — sha256 of its anchored bytes, canonical lowercase |
| `formProof` | `{ pinned, live: string \| null, drifted: boolean }` | The rubric's pinned `#vct` proof vs the live claim form. `drifted` is the engine's call; `protocol review` prints a warning for it |
| `ext` | `Record<string, ManifestExt>` | Every declared external source |
| `ai` | `Record<string, ManifestAi>` | Every declared AI check |
| `hasAiChecks` | `boolean` | Shorthand for "this rubric spends on a model" |
| `ctx` | `string[]` | The `ctx.*` tokens the rules name explicitly |
| `fields` | `{ path: string; kind: string }[]` | Claim-form catalog paths the rules read, with the live form's kinds |
| `derived` | `{ name: string; expression: string }[]` | `~name` values the **engine** computes — with the source expression, so a custom handler can reproduce one |
| `review` | `{ tasks: string[]; taskDetails?: { id, prompt, answer }[] }` (optional) | The human-review tasks a `pending_review` decision can name. `answer` is `{ type: "boolean" \| "enum" \| "number" \| "text" }` (enum carries `options`). `taskDetails` also lists the engine's synthetic tasks after the rubric's: `unique:<ruleId>` (a possible duplicate) and `checks:held` (held only by a failed check), answered `"approve"` or `"reject"` — never a boolean |
| `unique` | `ManifestUnique \| null` (optional) | `ruleId` plus the full post-default rule: `by?`, `countPrior?`, `attachments?`, `ai?`, `thresholds?` |
| `frequency` | `ManifestFrequency \| null` (optional) | `ruleId`, `max?`, `per?`, `onBreach?` |
| `sizeProbes` | `{ path: string; maxSizeMB: number }[]` (optional) | The **bare root question names** a `maxSizeMB` rule reads, with the minimum limit declared. The only attachments the package probes a byte size for |

`ManifestExt`: `expect` (`"boolean"` ⇒ the fact's `value` is a boolean · `"score"` ⇒ an integer
0–10000), `onError` (`"review"` \| `"reject"`, always present), `endpoint`, `audience` (the source's
`did:web` — it must be the endpoint's own, or the stock lane does not call it), `capability?` (the
rubric's; **ignored** — the oracle's developer owns the capability), `send`, `timeoutMs?`, `usedBy`
(`"gate:<code>"` / `"criterion:<code>"` — every rule whose bound deps read this fact).

`ManifestAi`: `expect: "boolean"` (v1 checks are yes/no only), `onError`, `instructions` (the
plain-language rules — **all** must hold for a `true`), `send`, `usedBy`.

Every object is parsed **loosely**: the engine may add fields without a wire bump, so an unknown key
is tolerated, never fatal.

**The unusable answer.** A rubric the engine cannot resolve or bind comes back as
`{ status: "unusable", issues: [{ code, message, path }] }` — a `200`, not an HTTP error. That is
recorded as the claim's outcome with **no handler run and no decide call**, and the collection is
paused `rubric-unusable`. There is nothing to build against until the rubric or its `#vct`/`#rub`
anchoring is fixed.

---

## 9. Wire facts, and what `validateFacts` refuses

These are the shapes that ride to the engine inside a `FactSet v1`. The FactSet itself is **strict**
(the engine 400s on an unknown key) and is assembled by the package around your answer: `version`,
`frozenAt` (identical to `ctx.now`), `answers`, `attachments`, `ctx`, `ext`, `ai`, `unique?`,
`frequency?`, `settings`.

| Shape | Fields | Notes |
| --- | --- | --- |
| `ExtFact` (ok) | `status: "ok"`, `value: boolean \| number`, `reason: string \| null`, `confidence?`, `transcript?` | Strict. `confidence` is integer basis points 0–10000 |
| `ExtFact` (error) | `status: "error"`, `onError: "review" \| "reject"`, `detail: string`, `transcript?` | Strict |
| `AiFact` (ok) | `status: "ok"`, `value: boolean`, `reason: string \| null`, `failedRules: string[]`, `score: 0..100`, `confidence?`, `transcript?` | Strict. `value` is derived in code, never the model's own |
| `AiFact` (error) | same as `ExtFact` (error) | Strict |
| `UniqueFact` | `ruleId`, `digest`, `scoreBps: 0..10000`, `band: "pass" \| "review" \| "reject"` | **Loose** — everything else you freeze (winner, candidates, `aiOutcome`, `aiPairs`, …) rides into the engine's trace |
| `FrequencyFact` | `ruleId`, `count: int`, `max: int`, `per?`, `windowStartISO?`, `windowEndISO?`, `breach: boolean`, `unresolved?: true` | **Loose**. `count`/`max` compose the engine's reason text; `breach`/`unresolved` are its branches |
| `AttachmentFact` | `question`, `name`, `mediaType`, `cid: string \| null`, `size: number \| null` | The package builds these; bytes never leave Matrix |
| `Transcript` | `request { url, body, headers }`, `response { status, body } \| null`, `attempts: int`, `elapsedMs: int`, `latencyMs?`, `genId?`, `usage?`, `provenance?` | Optional on the wire; the stock lanes always produce one. `headers` never carries a credential header and `url` has credential query values masked (`http.json` above); a `response.body` over 64 KiB is `{ truncated: true, bytes, sha256 }`; `response: null` means no HTTP response at all |

### Coverage: `FactIssue` codes

`validateFacts` mirrors the engine's own fact-coverage check and runs **before** anything is frozen
or sent, so a producer bug is loud here instead of a billed `unusable` there. `runFacts` returns the
same issues.

| Code | Raised when | Path |
| --- | --- | --- |
| `FACT_MISSING` | A declared `ext`/`ai` name was not produced; a declared `unique`/`frequency` rule produced no fact. For a `frequency` declaration the engine sent without `max`/`per`, the message is `manifest lacks max/per` | `/facts/ext/<name>` · `/facts/ai/<name>` · `/facts/unique` · `/facts/frequency` |
| `FACT_EXTRA` | A name the rubric never declared is present (the engine would ignore it, but no rule can ever read it — so here it is a bug); a `unique`/`frequency` fact with no declared rule | same |
| `FACT_KIND` | `expect: "boolean"` but the value is not a JS boolean; `expect: "score"` but the value is not an **integer 0–10000** (never clamped); a `unique`/`frequency` fact naming a different `ruleId` than the manifest | same |
| `FACT_ON_ERROR` | An error fact carries an `onError` other than the rubric's (only reachable from a whole-producer — the runner stamps it) | same |
| `FACT_SHAPE` | The fact fails its wire schema; the `ext`/`ai` lane is not an object | `/facts/ext` · `/facts/ai` · per-name paths |

A failed validation is recorded as `invalid_facts` for the claim and fails the ingest loudly. It is
never sent.

---

## 10. Testing — `@ixo/decision-oracle/testing`

`runFacts` is the **same runner** production uses, minus the fact cache and D1, followed by the
**same** `validateFacts`. `issues: []` means the engine would accept this FactSet.

### `runFacts(facts, input)`

```ts
interface RunFactsInput {
  claim: PulledClaim;      // sampleClaim(...)
  manifest: Manifest;      // sampleManifest(...)
  tools?: FactTools;       // default: fakeTools()
  ctx?: CtxFacts;          // default: sampleCtx()
  settings?: FactSettings; // default: { allowAiChecks: true, allowImageChecks: true }
  /** Per-lane replacements for what an unlisted name — and `tools.stock()` — runs instead. */
  stock?: Partial<{
    ext: ExtHandler;
    ai: AiHandler;
    unique: UniqueHandler;
    frequency: FrequencyHandler;
  }>;
}
// → Promise<ProducedFacts & { issues: FactIssue[] }>
```

Both policy lanes are open by default: a handler under test should run, not be gated by a policy the
test never mentioned. Pass `settings` to exercise the gated path.

The default `stock` lanes: `ext` and `ai` both call `tools.stock()` (so `fakeTools({ stock })` is
what answers); `unique` **throws** `no stock unique lane in tests — pass stock.unique or a
facts.unique handler`; `frequency` returns the fail-closed `unresolved` fact.

`runFacts` also binds `tools.attachments.on` and `tools.ai.inputsFor` to the claim you passed (they
are pure functions of it, and `fakeTools` has no claim). Only the `fakeTools` stubs are replaced —
an implementation you wrote yourself survives.

A `groups` entry runs here exactly as it does in production: declare every grouped name on the
`sampleManifest`, assert each one's fact, and test the half that matters — the failed call, where
one `fact.error` has to come back for *every* name the group owed.

### `fakeTools(overrides?)`

| Override | Type | Default |
| --- | --- | --- |
| `http` | `Record<string, FakeHttpRoute>` | `{}`. A **matched** URL answers its value as the body under a `200`; an `httpResponse(status, body)` envelope sets the status; a **function** of `init` returns either. An **unmatched** URL is a network failure: `{ ok: false, status: null, body: null }` with `attempts: 0` — exactly what the real lane produces for a URL it refused to call |
| `stock` | `ExtFact \| AiFact \| (() => …)` | `{ status: "error", onError: "review", detail: "stock lane is not available in tests" }`. There is no external source and no model behind a test; the runner re-stamps the manifest's `onError` over it, exactly as in production |
| `ai.judge` | `(instructions, inputs, opts?) => AiFact` | `fact.ai.ok(true)` |
| `attachments` | `Record<string, Uint8Array>` | `{}`, keyed by the attachment's **url**. `bytes` → `{ ok: false, reason: "unfetchable" }` when absent; `size` → the byte length or `null` |
| `c2pa` | `Provenance \| null` | `null` |
| `agreement` | `Agreement \| null` | `null` |
| `chain` | `{ linkedResources?: Record<string, LinkedResource[]>; claimIntent?: ClaimIntent \| null \| ((q: ClaimIntentQuery) => ClaimIntent \| null); query?: QueryClient }` | An entity DID the map does not name anchors `[]` (what the real read answers); `claimIntent` is `null` — an agent that filed nothing — unless you supply one, and a **function** is handed the `ClaimIntentQuery` so a test can assert on what was asked (or throw, to stand in for a blocksync outage, which a handler must let through rather than read as "no intent"); `query()` **throws** "not available in tests" unless you supply a client |
| `env` | `Record<string, unknown>` | `{}` — reaches the handler as `tools.env` |

Plus, always: `logs: LogRecord[]` collecting everything the handler logged (`{ level, msg, data? }`,
in order); `chainCalls: ChainCall[]` collecting every `tools.chain` call in order
(`{ call: "linkedResources", entityDid }` / `{ call: "claimIntent", params }` / `{ call: "query" }`),
so a test can assert on the entity its handler read and on the intent query it asked
(`ClaimIntentQuery` — `{ agentAddress, collectionId, before, after? }`, exported from
`@ixo/decision-oracle/testing`); a real `config` from the same Zod schema the Worker validates at boot (so
`tools.config.IXO_NETWORK` is `"devnet"`, not a stub with holes); and `http.json(url, { ucan })` /
`http.ucanHeaders` producing a readable fake token (`Bearer fake-ucan-for-<audience>[-<can>-<with>]`,
the audience `did:web:<url host>` for `ucan`) so a test can assert on the audience and capability
its handler addressed — an IXO service capability throws there exactly as in production.

`httpResponse(status, body)` is a branded envelope — it exists precisely so that
`{ body: "hello", sender: "bob" }` reads as a *body*, not a status/body pair.

### `sampleClaim(answers, opts?)`

| Option | Default |
| --- | --- |
| `claimId` | `"claim1"` |
| `collectionId` | `"1"` |
| `agentAddress` | `null` (the dup/rate lanes key on it) |
| `attachments` | `[]` — each entry is `{ question, name, mediaType, url?, cid? }`; `url` defaults to `""`, and `url` is what `fakeTools({ attachments })` is keyed by, so give each one its own as soon as more than one needs bytes |

`network` is `"devnet"`, `lane` is `"cellnode"` and `media` is `null` (the no-store posture: nothing
to fetch bytes from in a test), and the claim is an ordinary unpaid, non-escrow one.

### `sampleManifest(decl?)`

Only `expect` (a source) and `instructions` (a check) must be given; everything else is the call
detail the *stock* lane would need, which a handler under test usually ignores.

| Key | Default filled in |
| --- | --- |
| `ext[name]` | `onError: "review"`, `endpoint: "https://source.example/<name>"`, `audience: "did:web:source.example"`, `send: {}`, `usedBy: []` |
| `ai[name]` | `expect: "boolean"`, `onError: "review"`, `send: {}`, `usedBy: []` |
| `unique` / `frequency` | key present **only** when declared — the runner produces such a fact iff it is there |
| `fields` / `derived` / `ctx` | `[]` |

`rubricId` is 64 zeros, `formProof` is undrifted, `hasAiChecks` is derived from the `ai` keys.

### `sampleCtx(over?)`

`now: "2026-01-01T00:00:00.000Z"`, an empty collection window and an empty claim plane — one fixed
instant, so digests repeat. Override any field.

### Three worked tests

**An ext handler over a canned route.** Cover both halves — the answer, and the failure it will
actually meet:

```ts
import { fakeTools, httpResponse, runFacts, sampleClaim, sampleManifest } from "@ixo/decision-oracle/testing";
import { facts } from "../src/facts";

const manifest = sampleManifest({
  ext: {
    deliveryConfirmed: {
      expect: "boolean",
      onError: "review",
      endpoint: "https://courier.example/deliveries",
      send: { reference: "$reference" },
    },
  },
});

it("answers ok when the courier says delivered", async () => {
  const out = await runFacts(facts, {
    claim: sampleClaim({ reference: "ABC" }),
    manifest,
    tools: fakeTools({
      env: { COURIER_API_KEY: "test-key" },
      http: { "https://courier.example/deliveries": { status: "delivered" } },
    }),
  });
  expect(out.issues).toEqual([]);
  expect(out.ext.deliveryConfirmed).toMatchObject({ status: "ok", value: true });
});

it("answers error — never false — when the courier sends a body it cannot read", async () => {
  const out = await runFacts(facts, {
    claim: sampleClaim({ reference: "ABC" }),
    manifest,
    tools: fakeTools({
      env: { COURIER_API_KEY: "test-key" },
      http: { "https://courier.example/deliveries": httpResponse(422, { detail: "bad ref" }) },
    }),
  });
  expect(out.ext.deliveryConfirmed).toMatchObject({ status: "error", onError: "review" });
});
```

**An ai handler that forwards to stock.** Can the stock lane, so the forward proves something —
and test the guard with a stock lane that *would* have said yes:

```ts
const manifest = sampleManifest({
  ai: {
    handoverPhotoLooksReal: {
      instructions: ["The photo shows an original handover, not a screenshot"],
      onError: "review",
    },
  },
});

it("hands a claim that carries a photo to the stock lane", async () => {
  const out = await runFacts(facts, {
    claim: sampleClaim({ reference: "ABC" }, {
      attachments: [{
        question: "handoverPhoto",
        name: "handover.jpg",
        mediaType: "image/jpeg",
        url: "https://media.example/handover.jpg",
      }],
    }),
    manifest,
    tools: fakeTools({ stock: fact.ai.ok(true, { reason: "reads as original", score: 90 }) }),
  });
  expect(out.issues).toEqual([]);
  expect(out.ai.handoverPhotoLooksReal).toMatchObject({ status: "ok", value: true, score: 90 });
});

it("guards the missing photo before the judge is ever reached", async () => {
  const out = await runFacts(facts, {
    claim: sampleClaim({ reference: "ABC" }),
    manifest,
    tools: fakeTools({ stock: fact.ai.ok(true) }),
  });
  expect(out.ai.handoverPhotoLooksReal).toEqual({
    status: "error",
    onError: "review",
    detail: "the claim carries no handoverPhoto attachment",
  });
});
```

**A custom unique lane.** Declaring the rule is what makes the runner ask for the fact:

```ts
const manifest = sampleManifest({ unique: { ruleId: "no-double-claims" } });

it("bands a vendor score against the rule's own thresholds", async () => {
  const out = await runFacts(facts, {
    claim: sampleClaim({ reference: "ABC" }),
    manifest,
    tools: fakeTools({
      http: { "https://registry.example/duplicates": { digest: "d1", scoreBps: 6000 } },
    }),
  });
  expect(out.issues).toEqual([]);
  // Defaults from the engine's own schema: reject ≥ 9000, review ≥ 5000.
  expect(out.unique).toMatchObject({ ruleId: "no-double-claims", band: "review" });
});
```

---

## 11. Configuration

### Bindings (`OracleBindings`) — names are the contract, never rename one

| Binding | Type | What it backs |
| --- | --- | --- |
| `DB` | `D1Database` | Every ledger: watched collections, claim ingests, decisions, reviews, the duplicate registries, the fact cache, Matrix delivery markers. Migrations **ship with the package** — point `migrations_dir` at `node_modules/@ixo/decision-oracle/migrations` and never run drizzle-kit |
| `CACHE_KV` | `KVNamespace` | Caches only: UCAN principals, peer `did:web` documents, the engine-registered marker, the rubric manifest (5 min), chain grants, claim-bot tokens, the C2PA trust list. Nothing here is a source of truth |
| `API_RATE_LIMITER` | `RateLimit` | The coarse per-IP guard on the whole API. `limit`/`period` live in `wrangler.jsonc`, not in code |
| `EMAIL` | `SendEmail` (optional) | Cloudflare Email Sending for pause/resume/review notices, the API lane's member and review emails, and the `balance.low` email. Absent ⇒ the email lane no-ops |
| `API_KEY_RATE_LIMITER` | `RateLimit` (optional) | The Decision API lane's per-key / per-source limit — its own namespace, read only with `API_LANE` on |
| `API_UPLOADS` | `R2Bucket` (optional) | The Decision API lane's uploads. Absent ⇒ `PUT /v1/api/uploads` answers 501 `UPLOADS_DISABLED`; URL attachments still work |
| `ANSWERS_ARCHIVE` | `R2Bucket` (optional) | The answer archive ([guide §12](/build-an-oracle/decision-oracle#12-operating-it), "The answer archive"): every evaluated ask and answer under `<askId>-<slug>/` — `about.json`, then per claim `answer.json`, `claim.json` (the body verbatim) and the attachment bytes, each with custom metadata; shaped by `OracleConfig.archive`. Bound ⇒ on; absent ⇒ nothing is archived. Fail-soft: an R2 failure never fails an evaluation. Holds exact locations and photos — keep the bucket private |
| `METRICS` | `AnalyticsEngineDataset` (optional) | Workers Analytics Engine: request, upstream, cron and cache metrics you can chart as p50/p95/p99 and rates ([guide §12](/build-an-oracle/decision-oracle#12-operating-it)). Absent ⇒ every metric is a `msg = "metric"` log line only |
| `CLAIM_WORKFLOW` | `Workflow` | `ClaimIngest` — one instance per claim |
| `REVIEW_WORKFLOW` | `Workflow` | `ReviewComplete` — the review fold |
| `HIRE_WORKFLOW` | `Workflow` (optional) | `OpenhandsHire` — one instance per hired OpenHands collection. Required with `openhands` on |
| `OPENHANDS_RATE_LIMITER` | `RateLimit` (optional) | The OpenHands webhooks' limit, spent per caller address (an IPv6 caller by its /64) and per collection. Required with `openhands` on outside `ENVIRONMENT=development` (unbound ⇒ `503`) |
| `SEQUENCE_MANAGER` | `DurableObjectNamespace` | The SDK's `SequenceManagerDO`: one instance per signer, allocating account sequence numbers so concurrent chain writes never race |

Your own bindings ride alongside — the type is open (`OracleEnv & YourEnv`); only the names above
are reserved.

### Vars and secrets (`OracleVars`)

Non-secret vars live in `wrangler.jsonc` `vars`, **redeclared per named environment** (named envs
inherit no bindings or vars). Secrets live in `.dev.vars` locally and `wrangler secret put <NAME>`
once deployed. Every value is validated by Zod at isolate start, so a bad env fails loudly and early.

| Var | Where | Default | Meaning |
| --- | --- | --- | --- |
| `ENVIRONMENT` | vars | `development` | `development` \| `staging` \| `production`. `development` is the only one where an external source may be plain `http` |
| `LOG_LEVEL` | vars | `info` | `debug` \| `info` \| `warn` \| `error` |
| `LOG_PRETTY` | `.dev.vars` only | `false` | Colorized local logs. Deployed envs must emit JSON — that is what Workers Logs ingests |
| `SERVICE_NAME` / `SERVICE_VERSION` | vars | `eval-oracle` / `0.0.0` | Log and `/health` identity |
| `CORS_ORIGINS` | vars | `*` | Comma-separated origins, or `*` |
| `CRON_COLLECTIONS_PER_TICK` | vars | `25` | Watched collections visited per cron tick. Only DUE rows rotate (`nextScanAt`): each visit parks a collection by what Blocksync says — closed, or ended more than a day ago, for a week; never claimed for a day; otherwise an hour (a full page stays due) — and a webhook scan or a claims-feed hit makes it due again. A fleet larger than this rotates in across ticks, least-recently-scanned first |
| `IXO_NETWORK` | vars | `devnet` | `devnet` \| `testnet` \| `mainnet` — supplies eight URLs below |
| `EVALUATION_DENOM` / `EVALUATION_MIN_AMOUNT` | vars | unset — **required when `IXO_NETWORK` is `mainnet`** | The evaluation fee for this network: never `upay` on an oracle IXO does not run (register refuses it `409 pay_requires_ixo_managed`; mainnet third parties charge USDC.inj `ibc/77C12480BD8C29BF0ACEC2143736C15BAF8CD232CA27BAE09336677B828955E6` or `uixo`). the denom a collection's `Payments.evaluation` must be in (matched exactly) and its minimum, an integer base-unit string. Set both or neither (one alone fails at isolate start). When set they replace `pricing.evaluation` at register and on `/v1/info`; a `pricing.byProtocol` entry still wins for its protocol. No default in code: on mainnet an unset pair fails config validation at isolate start, so a stand-in coin written in `defineOracle` can never be charged there. The template sets `uixo` / `1000000` on devnet and testnet and Noble USDC (`ibc/6BBE9BD4246F8E04948D5A4EEE7164B2630263B9EBB5E7DC5F0A46C62A2FF97B`, 6 decimals) / `1000000` on production |
| `BLOCKSYNC_GRAPHQL_URL` | vars | per network | Override only for a self-hosted indexer |
| `CHAIN_RPC_URL` | vars | per network | The SDK query client and the chain write |
| `MATRIX_HOMESERVER_URL` | vars | per network | Fallback home server when an entity declares none |
| `UCAN_STORE_URL` | vars | per network | Where an owner→oracle delegation is fetched from |
| `VFS_URL` | vars | per network | The IXO Virtual Filesystem — the first claim-body stores: the collection's claims lane (read under your Evaluate grant), then public copies |
| `IPFS_GATEWAY_URL` | vars | per network | The IPFS gateway a claim body is read from when neither the VFS nor Matrix has it |
| `PORTAL_BASE_URL` | vars | per network | Deep links in notices. No trailing slash |
| `EVAL_ENGINE_HOSTNAME` | vars | per network | The engine you ask for decisions. A host you **call** — its `did:web` is the invocation audience. Set it for a local engine or a tunnel |
| `CLAIM_BOT_URL_OVERRIDE` | `.dev.vars` | derived | Local harnesses only — the bot URL is normally derived as `https://claim.bot.<homeserver domain>` |
| `IXO_CLIENT_SEQUENCE_MIN_DELAY_MS` | vars | `1000` | Read by the sequence keeper off `env`: the minimum gap between two sequence allocations |
| `ORACLE_HOSTNAME` | vars | **required** | *Your* canonical `did:web` host. Every inbound invocation's audience is pinned to this, never the request `Host`; it is also the issuer of every invocation you mint. It must equal the Custom Domain in `routes` for that env, or, with no `routes`, the env's `<worker>.<account>.workers.dev` host |
| `ORACLE_DID` | vars | **required** | `did:ixo:<address>` — the runtime chain identity |
| `ORACLE_ADDRESS` | vars | **required** | Must be the `ORACLE_SECP_MNEMONIC` wallet's address — asserted at first use, fails loud |
| `ORACLE_MATRIX_USER_ID` | vars | **required** | The MXID derived from `ORACLE_DID` (cross-checked, never re-derived) |
| `ORACLE_ENTITY_DID` | vars | unset | The on-chain oracle **entity** (`did:ixo:entity:…`, from `oracle init`) — a different DID from `ORACLE_DID`. **Required for a deployed oracle**: unset while `ORACLE_HOSTNAME` is not local (`localhost`, `127.0.0.1`, `*.localhost`), readiness fails its `identity` check (`/health/ready` 503) — the worker still boots, so `oracle init` can read it. The portal resolves your API from its `#eval` service, so the Matrix review card names it. Unset (or `""`) ⇒ falls back to `ORACLE_DID`: the card is still delivered but can only be answered from the portal's claim page |
| `AI_CHECK_MODEL` | vars | `openai/gpt-6-luna` | The pinned model for AI checks (any provider — the gateway's `compat` route takes the same id) |
| `AI_PROVIDER` | vars | `cloudflare` | `cloudflare`: every AI call goes through the Cloudflare AI Gateway via the `AI` binding (`"ai": { "binding": "AI" }`), paid from Unified Billing credits or the gateway's stored keys. `openrouter`: the OpenRouter API with `OPENROUTER_API_KEY` |
| `AI_REASONING_EFFORT` | vars | `low` | `low` / `medium` / `high` — the judge's reasoning effort on every AI call, either provider. Higher thinks more: slower answers and more output tokens, so measure latency and cost before raising it |
| `AI_GATEWAY_ID` | vars | unset | The gateway the `cloudflare` provider routes through. Unset (or no `AI` binding) ⇒ an AI fact throws `AppError.internal` at use |
| `AI_AUDIO_VIDEO` | vars | `false` | Send audio/video attachments to the model. **OpenRouter only**: on with any other provider, config refuses to load. Off ⇒ each reaches the model as a "content not sent" descriptor (a change from earlier versions, where OpenRouter always got them) |
| `AI_CHECK_MIN_CONFIDENCE` | vars | `60` | A verdict scoring below this is the unreachable path (an `error` fact), not a trusted answer |
| `AI_IMAGE_DETAIL` | vars | `high` | `low` / `high` / `auto` — the `image_url.detail` every image rides to the model at (an AI check's photos, the same-event judge's pair evidence). `high` because a photographed screen (moiré, a pixel grid, a cursor) or a generated image (garbled text) is only visible at full resolution. **Cost:** `high` spends more image tokens per call than `low`'s flat handful — set `low` only when no check turns on fine detail |
| `C2PA_IMAGE_CHECK` | vars | `false` | The provenance gate in the stock AI lane. Needs `policies.allowImageChecks` on too |
| `AUTO_SIGN` | vars | `true` | Sign `MsgEvaluateClaim` unattended. Needs `policies.autoSign` on too |
| `API_LANE` | vars | `false` | Mount the Decision API router (`/v1/api`). Off ⇒ the lane is dormant and the chain lane runs exactly as before |
| `API_PAYMENTS` | vars | `false` | The x402 gate. Off ⇒ every new submit answers `503 SOURCE_PAUSED { reason: payments-disabled }` — nothing is accepted unpaid |
| `API_PRICE_USD_CENTS` | vars | unset | The fallback price per decision in whole USD cents, for every protocol `pricing.api` does not price (the config wins). Unset with no `pricing.api` ⇒ `price: null` and the gate refuses loudly at use |
| `X402_FACILITATOR_URL` | vars | unset | The x402 facilitator (verify + settle). No trailing slash. **Required** when `API_PAYMENTS` is on — config refuses to load without it |
| `X402_NETWORK` | vars | unset | The chain every 402 names, CAIP-2: `cosmos:<chain-id>` (`cosmos:devnet-1`, `cosmos:pandora-8`, `cosmos:ixo-5`) |
| `EARNINGS_ADDRESS` | vars | unset | Where your earnings for decisions land in PAY and USDC on IXO: your own `ixo1…` account, sent as each offer's `payTo` (the facilitator checks it, never picks one). Checked at start, checksum included. Needed only when IXO's plan for the oracle takes PAY (the engine's `GET /v1/oracles/me` `accepts` holds `upay`; KV-cached 5 min): unset then ⇒ new x402 submits answer `503 SOURCE_PAUSED { reason: payments-disabled }`. A plan without PAY (card and/or USDC on Base) sends no ixo-pay terms and never reads it; a plan the engine cannot tell is the same 503 |
| `EARNINGS_BASE_WALLET` | vars | unset | Your Base wallet (`0x…`) for USDC on Base. Unset ⇒ that way to pay is not offered |
| `BILLING_ENGINE_URL` | vars | unset | Charge lookups and the spend figures on `/v1/api/me`. No trailing slash |
| `CONSOLE_URL` | vars | unset | The Decision Console — every error's `docsUrl`, the 402's `fundingUrl`, and every email link (the `balance.low` email's is `<CONSOLE_URL>/billing/credits`). Unset ⇒ the API lane's emails are logged skips. No trailing slash |
| `AUTH_HUB_URL` | vars | per `IXO_NETWORK` (`https://dev.auth.ixo.earth`, `https://test.auth.ixo.earth`, `https://auth.ixo.earth`) | IXO AuthHub, the OAuth server of `/v1/mcp`: the protected-resource metadata's `authorization_servers`, the exact `iss` of an access token (its JWKS at `<url>/.well-known/jwks.json`), the approve link's host, and the one `did:web` (`did:web:<its host>`) the approvals back-channel accepts. Set it only for a local AuthHub. No trailing slash |
| `ORACLE_ADMIN_DIDS` | vars | unset | Comma-separated `did:ixo` admins of this oracle — the oracle's operator's own people — allowed on `GET /v1/admin/status` and `/v1/api/admin/*`. Unset ⇒ nobody |
| `STATUS_READER_DIDS` | vars | unset | Comma-separated DIDs of any method (`did:web:…`, `did:ixo:…`, each checked as `did:<method>:<id>`) allowed to read `GET /v1/admin/status` and nothing else — for a monitoring service, e.g. your decision engine's status board, identified by its DID. Kept apart from `ORACLE_ADMIN_DIDS` so a monitor is never made an operator. Unset ⇒ nobody |
| `SERVICE_ED25519_MNEMONIC` | secret | — | The `did:web` key: `sha256(mnemonic)` → Ed25519 seed. Signs every UCAN invocation you mint and backs `/.well-known/did.json` |
| `ORACLE_SECP_MNEMONIC` | secret | — | The chain account key (secp256k1) that signs `MsgEvaluateClaim`. **Unset ⇒ the chain-write lane logs one line and skips** — the decision itself is unaffected |
| `OWNER_DELEGATION_CAR` | secret | — | The oracle owner's UCAN delegation to the engine (base64 CAR), sent once at register so the engine can find and bill their subscription. Its issuer is the oracle's owner, who may not review (`oracle-owner`). **Unset ⇒ register refuses, loudly** |
| `ORACLE_MATRIX_ACCESS_TOKEN` | secret | — | The oracle bot's Matrix token, **produced by `decision-oracle oracle init`** (its `matrix` step creates the account and writes this line into `.dev.vars`; `wrangler secret put` it for a deployed env). Unset ⇒ every notification lane logs one line and skips; no claim is ever blocked on it |
| `OPENROUTER_API_KEY` | secret | — | Only with `AI_PROVIDER=openrouter`: required when a rubric asks for an AI fact (or a `unique` rule with its judge on) |
| `UNIQUE_HMAC_KEY` | secret | — | Keyed digest for duplicate detection and for recording an API submit's `submitter`. **Required when `API_LANE` is on** (the worker refuses to start without it) and when a rubric declares `unique`. **One long-lived secret per oracle** — rotating it orphans every registered digest |
| `WEBHOOK_ENCRYPTION_KEY` | secret | — | Encrypts the Decision API lane's bearer secrets at rest (`v1:` AES-256-GCM): webhook signing secrets (every endpoint's and each source's own), every verified `PAYMENT-SIGNATURE` (on `api_charges.paymentHeader` and the KV `x402-paid:` copy — it embeds the payer's reusable payment delegation; rows written before 2026-10-14 are sealed by the hourly housekeeping job `header-seal`, and read either way meanwhile) and each source's review-billing delegation. **One per deployment, for life** — rotating it strands every stored secret. **Required when `API_LANE` is on**: config load fails without it |
| `SENTRY_DSN` | secret | — | Unset ⇒ error reporting is a no-op |
| `SENTRY_RELEASE` | stamped by your deploy | — | Never in `wrangler.jsonc` |
| `SENTRY_TRACES_SAMPLE_RATE` | vars | `0.1` | Share of requests Sentry traces, `0`–`1` |
| `SENTRY_ENVIRONMENT` | vars | `ENVIRONMENT` | The Sentry environment tag — set it to tell apart two deploys that share an `ENVIRONMENT` (`testnet`, `mainnet`) |
| `OPS_ALERT_WEBHOOK_URL` | secret | — | A Slack-compatible incoming webhook for ops alerts — one message per condition per 30 minutes ([guide §12](/build-an-oracle/decision-oracle#12-operating-it)). Unset ⇒ alerts reach Sentry only |
| `EMAIL_FROM_ADDRESS` | secret | — | Unset ⇒ the whole email lane no-ops (the same "unset ⇒ off" contract) |
| `EMAIL_FROM_NAME` | secret | the oracle's `name` | Display name on outbound notices |

After any binding or var change: `bun run cf-typegen`.

### The identities behind those vars

Four identities, three of which have on-chain or homeserver state that the vars alone do not create.
`decision-oracle oracle init` is what creates that state, once per network — thirteen idempotent steps
(`fund`, `iid`, `key`, `matrix`, `entity`, `orz`, `profile`, `domain`, `acard`, `linked`, `service`,
`backlink`, `index`), a dry run unless `--yes`, `--only <step>` for one. Only `iid`, `key` and
`matrix` need the oracle's own mnemonics; the rest run on the AuthHub session and the public vars. **A full on-chain identity
is mandatory**: the entity, its `#pro` profile, its `#dmn` domain card and its `#acard` Agent Card
(validated against the engine's Agent Card schema). `--logo <url>` is optional and recommended —
without it the portal and the console show a placeholder. `--openhands` adds a `check-openhands`
service to the Agent Card (the OpenHands app's checker marker), priced in PAY from `/v1/info`'s
`pricing.api` for `--protocol`, else `--price-pay <n>` (1 PAY = 1 USD).

**Where the documents live.** Every document the CLI anchors — `oracle init`'s `#orz`, `#pro`,
`#dmn`, `#acard`, `publish-protocol`'s `#vct` / `#rub` / `#pro` (`--name`), `org create`'s `#pro` — is stored on the
IXO Vault (`POST <vfs>/api/fs/files?public=true` under a one-write `fs/write` UCAN from the AuthHub
session's Ed25519 key, the entity owner) and anchored by its public, unauthenticated
`<vfs>/public/cid/<cid>` URL. A Vault refusal prints one `⚠` line and the document goes to the IXO
IPFS gateway instead (`https://ipfs.gateway.ixo.world/ipfs/<cid>`); nothing is uploaded to Matrix
media any more. The proof rules are unchanged: sha256 hex for `#vct` / `#rub` / `#orz`, the
`bafkrei…` CID for `#pro` / `#dmn` / `#acard`. An `oracle init` re-run moves an `#orz`, `#pro`,
`#dmn` or `#acard` still on Matrix media onto the Vault (same content, delete + add in one tx);
readers keep reading Matrix media (`readLinkedDoc`).

| Identity | What exists, and where | Who creates it | Why it matters |
| --- | --- | --- | --- |
| `did:web:<ORACLE_HOSTNAME>` | `/.well-known/did.json`, served by this worker from the `SERVICE_ED25519_MNEMONIC` key | you, by deploying to that host | the issuer of every invocation this oracle mints, and the audience every inbound one is pinned to |
| `did:ixo:<ORACLE_ADDRESS>` — its **IID document** and the **Ed25519 verification method** carrying the `SERVICE_ED25519_MNEMONIC` public key (`authentication` + `assertionMethod`) | on chain | `oracle init`, steps `iid` and `key`, signed by the oracle's **own** wallet (only its key may write its own document) | the claim bot, the UCAN Store and the VFS claims lane resolve this DID and check the invocation's signature against that key. **Without the document, or without the key on it, all three answer 401** and no claim body can be pulled from Matrix or the VFS claims lane. The wallet also needs a balance (step `fund`) to sign `MsgEvaluateClaim` |
| `ORACLE_ENTITY_DID` | the oracle **entity** on chain, with `#eval`, `#orz`, `#pro`, `#dmn` (domain card), `#acard` (Agent Card) and the protocol edges | `oracle init`, steps `entity` onwards, signed by the AuthHub session that owns it. Set (per network: `env.<network>.vars`, else top-level; `oracle init` writes it into `.dev.vars` the moment it creates the entity), it pins the entity for every later run — refused unless the entity is bound to this oracle; unset, `oracle init` finds THIS oracle's among the session's oracle entities by `#orz` granting `ORACLE_ADDRESS`, else `#eval` = `--kit-url` — never another oracle's | how the portal finds this oracle from a protocol, what the domain indexer / portal / console list it by, and what the Matrix review card resolves an API from |
| `ORACLE_MATRIX_USER_ID` | the oracle bot's Matrix account on the network's homeserver | `oracle init`, step `matrix` — it also writes `ORACLE_MATRIX_ACCESS_TOKEN` into `.dev.vars` | the account the review-card lane posts as. No account, no token, and that lane is a logged skip |

### Per-network defaults

`IXO_NETWORK` supplies all of these; **any var set explicitly wins**. A wrong network still fails
loud at the first `did.json` fetch.

| | devnet | testnet | mainnet |
| --- | --- | --- | --- |
| engine host | `decision-engine.devnet.ixo.earth` | `decision-engine.testnet.ixo.earth` | `decision-engine.ixo.earth` |
| blocksync | `devnet-blocksync-graphql.ixo.earth/graphql` | `testnet-blocksync-graphql.ixo.earth/graphql` | `blocksync-graphql.ixo.earth/graphql` |
| rpc | `devnet.ixo.earth/rpc/` | `testnet.ixo.earth/rpc/` | `impacthub.ixo.world/rpc/` |
| rest | `devnet.ixo.earth/rest` | `testnet.ixo.earth/rest` | `impacthub.ixo.world/rest` |
| matrix | `devmx.ixo.earth` | `testmx.ixo.earth` | `mx.ixo.earth` |
| ucan store | `devnet.store.ucan.ixo.earth` | `testnet.store.ucan.ixo.earth` | `store.ucan.ixo.earth` |
| vfs | `devnet.vfs.ixo.earth` | `testnet.vfs.ixo.earth` | `vfs.ixo.earth` |
| ipfs gateway | `ipfs.gateway.ixo.world` | `ipfs.gateway.ixo.world` | `ipfs.gateway.ixo.world` |
| portal | `dev.portal.qi.space` | `test.portal.qi.space` | `portal.qi.space` |
| authHub | `dev.auth.ixo.earth` | `test.auth.ixo.earth` | `auth.ixo.earth` |
| facilitator | `facilitator.devnet.ixo.earth` | `facilitator.testnet.ixo.earth` | `facilitator.ixo.earth` |
| relayer node | the network's relayer entity DID (used by the publishing commands only) | — | — |

`facilitator` is the CLI's only (the audience `api review-billing enable` signs to, as the Decision
Console's preset does); the worker's facilitator is `X402_FACILITATOR_URL`, never this row.

`NETWORKS[network]` and `networkDefaults(network)` expose the same row to your code and to the CLI's
`--network` flag.

### The policy triangle

Three switches decide what actually runs. Each has a config half (`defineOracle`) and, for two of
them, an env half — **both** must be on.

| Behaviour | Config | Env | Off means |
| --- | --- | --- | --- |
| Sign `MsgEvaluateClaim` unattended | `policies.autoSign` | `AUTO_SIGN` | The decision is still recorded and receipted; submitting it is a human's job |
| Run AI checks at all | `policies.allowAiChecks` | — | Every AI fact freezes `error / ai-checks-disabled` with no model call, and the rubric's `onError` carries. The duplicate lane's judge is skipped too |
| Screen images for Content Credentials | `policies.allowImageChecks` | `C2PA_IMAGE_CHECK` | The stock AI lane makes no provenance read. `tools.c2pa.screen()` still works — a handler asking for the read gets it |

---

## 12. What the package does at runtime

Everything in this section already exists. Do not rebuild any of it in a handler.

### HTTP routes

Middleware order is fixed: `observability → database → secureHeaders → cors → rateLimit → bodyLimit
→ prettyJSON → routes → onError/notFound`. The body cap is 1 MiB. Every `/v1/*` route requires a
**UCAN invocation** addressed to `did:web:<ORACLE_HOSTNAME>` (never the request `Host`) unless it is
on the explicit public allowlist. It is signed by the caller's own key — a delegated chain (root ≠
invoker) is `401` — and carries exactly ONE capability, `{ with: "ixo:eval-oracle", can: <action> }`,
where the action is the route's own, named in the Auth column below (`src/middleware/capability.ts`
`ROUTE_ACTIONS`; `HEAD` takes its `GET`'s). A different action is `403`, and so is any action on a
route with no entry; a `can` that is none of the table's actions is `401` before it is validated. The
legacy `can: "*"` is still accepted on every route while its callers switch, and each use is logged
at info as `ucan.wildcard` (`route`, `invoker`) and counted as the `ucan.wildcard` metric (`route`
only) — a strict release will refuse it. The package's own CLI (`register`, `scan`, `relink`, `erase`) signs each route's action; send the
route's action from your own clients too.
An invocation is **short-lived**: it must carry an `exp` at most 600 s ahead, and when it states an
`nbf`, `exp − nbf` may not exceed 600 s either (an invocation has no issued-at, so a far-future `exp`
would otherwise pass in its last 10 minutes) — each is `401` ("valid for too long — at most 600 s",
"it has no expiry"). The CLI signs 120 s. Replay protection is off: a token may be reused within its
window, and the verified token is cached (in-isolate and KV) no longer than that window.
That proves key possession, audience and the action — **authorization is each route's own identity
check.**

| Route | Auth | Gate |
| --- | --- | --- |
| `GET /health` | public, rate-limit exempt | — |
| `GET /health/ready` | public, **rate-limited** (per IP) | `{ ok }` only — `200` when every critical check passes, `503` otherwise. Checks `d1`, `kv`, `engine` (its `/.well-known/did.json`) and, with `API_LANE` and `API_PAYMENTS` on, `facilitator` (`X402_FACILITATOR_URL/supported`), in parallel, 2 s each, plus `identity` (a config read: fails when `ORACLE_ENTITY_DID` is unset on a non-local `ORACLE_HOSTNAME`) and `owner-delegation` (fails when `OWNER_DELEGATION_CAR` is set but is not a UCAN delegation; unset passes); the settled answer is cached per isolate for 15 s. A failure before the route runs (a config that does not load) is also `503 { ok: false }`. Names no check — the detail is `/v1/admin/status`'s |
| `GET /.well-known/did.json` | public, rate-limit exempt | Needs `SERVICE_ED25519_MNEMONIC` |
| `GET /.well-known/oauth-protected-resource/v1/mcp` | public, rate-limit exempt, `Access-Control-Allow-Origin: *`, `cache-control: public, max-age=300` | RFC 9728 metadata for the MCP server: `{ resource: "https://<ORACLE_HOSTNAME>/v1/mcp", authorization_servers: [AUTH_HUB_URL], scopes_supported: ["claims:write", "claims:read"], bearer_methods_supported: ["header"] }`. `404` unless `API_LANE` is on. Outside the `/v1/api` contract |
| `GET /v1/info` | **public** (the only public `/v1` path) | —. The marketplace listing: `name`, `description`, `did`, `address`, `entityDid` (the on-chain oracle entity from `ORACLE_ENTITY_DID`, `null` when unset), `serviceDid`, `supportedProtocols`, `protocols` (the same list readably: `[{ did, name, fee }]` — `name` the protocol's `#pro` profile name, else its claim form's title, cached 1 h, `null` when neither reads; `fee` the `{ denom, minAmount }` register charges for it), `pricing`, `policies`, `earnings: { address, baseWallet }` (the operator's `EARNINGS_ADDRESS` / `EARNINGS_BASE_WALLET`, `null` when unset — read-only, set in the oracle's config), `payouts: { decisionToPay, operatorAmount }` — how a payout amount is chosen, for a portal to show before a company opts in: `decisionToPay` is the config's `payouts` map verbatim (per protocol DID, `{ amount: <answer path>, currency: <ISO 4217 code> \| { path } \| { asset, decimals } }`; `{}` when none, and a protocol absent from it gets no decision to pay), `operatorAmount` is `{ summary }` from `policies.actSummary` when an `act` policy is configured, else `null` (`actSummary` is served only here, never under `policies`), and `reviewBilling: { mode, pricePerReview }` — how a collection pays for its reviews: `mode: "x402"` (per-review billing ACTIVE: IXO's plan for the oracle allows it — the engine's `GET /v1/oracles/me` `reviewBilling`, KV-cached 5 min — on an oracle running `API_LANE`: per review from the company's credits, an empty evaluation leg) with `pricePerReview: { denom: "upay", amount }` the published per-review price (the one the supported protocols share, else `pricing.api.amountMicroUsd` / `API_PRICE_USD_CENTS`; `null` when none is published), or `mode: "escrow"` (the collection's on-chain evaluation payment, `pricing`) with `pricePerReview: null`. `cache-control: public, max-age=300`. With `openhands` set, also `openhands: { hire: "/openhands/hire/:collectionId", scan: "/openhands/scan/:collectionId" }` — absent on any other oracle. A portal is built against these exact field names |
| `POST /openhands/hire/:collectionId` | public, **`OPENHANDS_RATE_LIMITER`** (per caller and per collection; unbound outside development ⇒ `503`), `openhands` on only | Starts the collection's hire (`202 { started: true }`) after reading that it exists and its admin granted this oracle a live meta-grant: `404` no such collection (not cached), `409` no meta-grant (cached 10 min). A hire in flight, or an ended one the chain says is healthy, is `200 { existing: true }` (the healthy answer cached 1 min); an ended one the chain says is stale or low is rerun from the webhook's own budget (`200 { restarted: true, reason }`, three a day, then `{ throttled: true }`). `400` a malformed id, `405` not POST, `503` when the chain or Blocksync cannot answer |
| `POST /openhands/scan/:collectionId` | public, **`OPENHANDS_RATE_LIMITER`**, `openhands` on only | Scans one watched collection now: `200 { started }`, `202 { coalesced: true }` within a minute of the last (scanned on the next minute tick), `404` not watched, `409` paused. A `200 { started: 0 }` (or a failed scan) is retried on the next three minute ticks |
| `POST /v1/collections/register` | UCAN `collections/register` | The four gates, in order: **who** (the collection entity's chain owner, or a holder of an active on-chain `EvaluateClaimAuthorization` — read uncached) · **hired** (this oracle's `ORACLE_ADDRESS` holds that grant too, else `409`) · **protocol** (in `supportedProtocols`, else `409` — checked before payment) · **paid** (`Payments.evaluation` matches `pricing`, else `402`; while per-review billing is active (IXO's plan), the collection's company — ONE engine read, `GET /v1/collections/:id/company`, its blip or a `disputed` company `503 company_unavailable` — holding a live review-billing delegation on its Decision API source, still signed by the organisation's current chain owner, with a published price and cap left, registers with an EMPTY evaluation leg (`billingSourceId` set, never billed twice) once the payer's credits cover one review: `GET /v1/balances` with the stored delegation in `X-Payment-Delegation`, both `available` and `delegationRemaining` ≥ the price, else `402 insufficient_credits` (`details.price`, `details.fundingUrl`), an unreadable answer `503 credits_unavailable`; no usable delegation ⇒ the escrow gate as before, its refusal `402 no_review_billing` naming both ways in). Body: `{ collectionId, delegationCar?, contactEmail? }`. No `contactEmail` sent and none stored ⇒ the row takes the `contactEmail` of the oldest-activated `active` Decision API source whose `companyEntityDid` is the collection's COMPANY as the engine derives it (`GET /v1/collections/:id/company`, UCAN `collection/company-read`: the collection's entity, or the org a deed is linked to both ways); a `disputed` company, a collection the engine does not know, or none such source ⇒ stays empty. Its `503` or any other failure never fails the register (logged `register.company-unavailable`, the row stays empty for a later register to fill). An explicit or stored contact is never overwritten. The SAME answer (one engine read per register) links the collection to every `active` Decision API source of that company, so its chain claims read with the source's (contracts 1.21.0: `LinkedCollection.linkedBy` = the company's entity DID, audit `source.collection_linked` by `system:company-link`, re-checked hourly against the engine and dropped `not_company` when it names another company or `disputed`; only such a positive finding hides a link — an unreachable engine keeps it in the reads, contracts 1.23.0); idempotent, and only the engine's `ok` links. The engine is asked only when no contact is sent or stored, or an `active` source is not yet linked to the collection. Every refusal carries a stable machine code in `details.reason` beside the human message: `404 collection_not_found`, `403 owner_unresolvable` · `not_owner_or_grantee` · `contact_change_forbidden` · `resume_forbidden`, `409 not_hired` · `unsupported_protocol`, `402 evaluation_underpaid` · `no_review_billing` · `insufficient_credits`, `503 company_unavailable` · `credits_unavailable`. |
| `GET /v1/collections` | UCAN `collections/read` | The rows the caller owns or enrolled |
| `POST /v1/collections/:id/pause` | UCAN `collections/pause` + read gate + owner, enroller (while it holds a live grant) or `ORACLE_ADMIN_DIDS` (a plain grant holder is `403`) | Sets `status: paused`, clears the reason — the cron enqueues nothing |
| `POST /v1/collections/:id/resume` | UCAN `collections/resume` + read gate + owner, enroller (while it holds a live grant) or `ORACLE_ADMIN_DIDS` (a plain grant holder is `403`) | Clears whichever reason the engine set; sends the paired "resumed" notice on a real flip |
| `POST /v1/collections/:id/scan` | UCAN `collections/scan` + read gate | Runs the cron's per-collection pass **now**. `409` if paused. Same dedupe and cursor rules, so an eager caller can never double-start a claim |
| `PATCH /v1/collections/:id/settings` | UCAN `collections/settings` (never `*`) + the collection's **current** chain owner (the entity's owner, read live off Blocksync; no enroller, grant holder or `ORACLE_ADMIN_DIDS`) — `403` otherwise, `404` unwatched | `{ operatorAmounts: "off" \| "allowed" }` (strict). Whether the act seam's on-chain amount (`policies.act`, `signEvaluation`) may stand on this collection: `"off"`, the default, ⇒ the lane pays its default and logs `act.amount-ignored-not-opted-in`. Recorded with the owner who set it: `"allowed"` stands only while that address still owns the collection's entity, so after a transfer it reads `"off"` until the new owner sets it. Answers the collection row, as `GET /v1/collections` does |
| `GET /v1/collections/:id/settings` | UCAN `collections/settings-read` (never `*`) + the collection's **current** chain owner, as the PATCH — `403` otherwise, `404` unwatched, `503` on chain trouble | `{ operatorAmounts, operatorAmountsBy, effective }`: what the owner set, the address that set it, and whether it is in force NOW — `effective` is the act lane's own reading (`operatorAmountsEffective`, `lib/ixo/chain-write.ts`): `true` only when `operatorAmounts` is `"allowed"` and `operatorAmountsBy` is the live owner, so after a transfer the value reads `"allowed"` with `effective: false` until the new owner says yes |
| `GET /v1/claims?collectionId=&limit=&cursor=` | UCAN `claims/read` + read gate | Summary list, newest first, keyset-paginated (`limit` default 100, max 500) |
| `POST /v1/collections/:id/reviewers` | UCAN `collections/reviewers-grant` (never `*`) + a **current controller** of the collection's entity (read live off Blocksync), who must be the grant's issuer | `{ delegation }` — a base64 CAR the controller signed: a reviewer grant (below). Checked as the review gate checks it (no revocation step), then filed in `collection_reviewers`. A controller may name **themselves**, and a grant to the collection's owner is filed like any other: one person may post an ask and review it (the owner's seat in the review room only lets them see reviews; answering always takes a grant). `201` + the grant row (`memberDid`, `rootDid`, `delegationCid`, `delegation`, `expiresAt`, `revokedAt`, `createdAt`). The same CAR again answers the same row; a revoked one is `409` (sign a new one). `400 REVIEWER_GRANT_INVALID` / `403 REVIEWER_NOT_AUTHORIZED` as the gate, `404` unwatched |
| `GET /v1/collections/:id/reviewers` | UCAN `collections/reviewers-read` (never `*`) | A current controller: `{ reviewers: [...] }`, every grant on the collection; a reviewer: only their own, CAR included (the Portal sends it with an answer); anyone else `403` |
| `POST /v1/collections/:id/reviewers/:delegationCid/revoke` | UCAN `collections/reviewers-revoke` (never `*`) + a current controller | Stamps `revokedAt` (once) and kicks the reviewer from the collection's review room. Every later answer and every pending fold under that grant is refused. `404` for an unknown grant |
| `GET /v1/claims/:claimId` | UCAN `claims/read` + read gate, or a live reviewer of the claim's collection (a filed, unrevoked grant inside its window whose root is still a controller — read live, so Blocksync down is `503`), or the claim's own submitter (matched by EITHER the claim's on-chain agent DID or its agent address, invoking as themselves; one Blocksync claim read, cached a day — a claim Blocksync does not know yet is not cached — tried only after the other doors; Blocksync down is `503`) | The full record: ingest ⟕ decision ⟕ review. The submitter sees the decision and its public receipt (which names who reviewed), but never the review's answers: `review.answers` and `review.reviewerDid` come back `null`, and so do the internal failure texts `ingest.error`, `decision.chainError` and `decision.erroredFacts`. `404` when this oracle has not seen the claim. The submitter gets no other read: not the list, not `/v1/reviews`, not another claim. Plus `checks` — the breakdown: every check the decision was made on, `[{ name, status, value, reason }]` (`reason` the checker's own words, a passed AI check's included; an error's detail), never a transcript or a prompt, `null` when the claim has no decision or its row has no facts. The submitter's copy is **cleaned**: URLs, emails, DIDs, CIDs and other identifiers, exact coordinates, phone-shaped and long numbers, plate-shaped tokens and long quotes become `[hidden]`, capped at 280 characters — they read why, not another claim's ids or someone's details. Plus `reviewerNote` (`policies.reviewerNote`) for every reader but the submitter, who gets `null` |
| `DELETE /v1/claims/:claimId/body` | UCAN `claims/erase` (never `*`) + the collection's **current** chain owner (read live off Blocksync; the enroller, grant holders and `ORACLE_ADMIN_DIDS` are `403`) — `404` when this oracle has not seen the claim or it is a Decision API claim, `503` on chain trouble | `204`; `409 CONFLICT` while the claim is still `queued` or `processing` (erase it once it is decided). Erases the claim's answer copies now: strips every `transcript` from `decisions.facts` (the AI prompts and HTTP payloads; the fact values stay), nulls the duplicate registry's `normalizedText`, drops its fact-cache rows, and stamps `claim_ingests.erasedAt`. The decision, its receipt and the registry digests stay, so the receipt still verifies and duplicates are still caught. Idempotent: `204` again, the first `erasedAt` kept. Logged `claims.erased`, counted `claims.erased` (`op: chain`). The CLI's `decision-oracle erase` loops over it The reviewer note (`reviewerNote`) is erased with them |
| `GET /v1/reviews/:claimId` | UCAN `reviews/read` + read gate, or a live reviewer of the claim's collection | The pending tasks, the stored answers, why a fold failed, `erroredFacts` (below; null when none errored), and `checks` — every frozen `ext` / `ai` fact the decision was made on, `[{ name: "ai.judge", status: "ok" \| "error", value, reason }]`, `reason` the checker's own words (a passed AI check's reasoning included; an error's `detail`), never a transcript or a prompt; `null` on a row with no facts (a facts-lane outcome). What a reviewer reads before deciding (the same list `GET /v1/claims/:claimId` serves). Plus `reviewerNote` — `{ says, checkThese[], model, createdAt }` (`policies.reviewerNote`), `null` when none was made |
| `GET /v1/admin/status` | UCAN `admin/status` + `ORACLE_ADMIN_DIDS` or `STATUS_READER_DIDS` (`403` otherwise — everyone, while both are unset) | `{ readiness: { ok, checks: { <name>: { ok, ms, critical, detail? } } }, backlog: { review: { pending }, collections: { paused: { total, <reason>: n } }, sources?: { paused }, webhooks?: { due, oldestAgeS } }, engine: { billingStatus, manageSubscriptionUrl } \| null }` — `sources`/`webhooks` only with `API_LANE` on; `engine` is the engine's `GET /v1/oracles/me`, `null` when it did not answer within 2 s; `detail` is set only on a failed `identity` or `owner-delegation` check and names the command to run. Outside the `/v1/api` contract |
| `POST /v1/admin/collections/relink` | UCAN `admin/collections-relink` (strict: never `*`) + `ORACLE_ADMIN_DIDS` (`403` otherwise) | The operator's backfill of register's company links. Body `{ collectionIds?: string[] }` (1–100 ids); absent ⇒ every watched collection, lowest id first, up to 100 (`truncated: true` says more exist — name them). Per collection, the same engine read and the same link register makes (no `active` source left unlinked ⇒ `unchanged`, no engine call) → `{ items: [{ collectionId, outcome: "linked" \| "unchanged" \| "no-company" \| "disputed" \| "unavailable" \| "not-watched", linkedSources }], truncated }`. Idempotent. CLI: `decision-oracle relink [--collection <id>]…`. Outside the `/v1/api` contract |
| `POST /v1/admin/claims/:claimId/reviewer-note` | UCAN `admin/reviewer-note` (strict: never `*`) + `ORACLE_ADMIN_DIDS` (`403` otherwise) | (Re)makes the reviewer note for a claim held for a person — one decided before the oracle made notes, or one whose note failed. Re-pulls the body through the claim's own lane, spends one model call, stores the note. `200 { claimId, written: true }`, `503 { written: false }` when the model gave none, `404` no decision, `409` not `pending_review`. The note itself is read on `GET /v1/reviews/:claimId`. CLI: `decision-oracle reviewer-note <claimId>`. Outside the `/v1/api` contract |
| `POST /v1/admin/claims/:claimId/redeliver` | UCAN `admin/redeliver` (strict: never `*`) + `ORACLE_ADMIN_DIDS` (`403` otherwise) | Releases a decided approve/reject with a receipt whose delivery is `pending`/`blocked` — made for the row the evaluate step's recovery parked without facts. With `facts` null it re-pulls the body through the claim's own lane, rebuilds the facts (`buildFacts`, the collection's protocol) and stores their `ext`/`ai` on the row with `chainStatus: pending` in one statement — the act policy's inputs only, never sent to the engine, whose decision and receipt stay as recorded — then runs the idempotent `deliverChainEvaluation`. `200 { claimId, outcome, factsDigestMatches, chainStatus, chainError, txHash }` (`factsDigestMatches` null when the row had facts already, `false` when the rebuild differs from the engine's `factsDigest`, e.g. an AI fact answering differently — stored anyway); `404` no decision; `409` not a receipted approve/reject waiting for the chain, a rubric-unusable / invalid-facts rebuild (`details.reason`, `details.issues`; nothing written), or a protocol now pinned to another rubric than the decision's; transient trouble is a `503` to run again. CLI: `decision-oracle redeliver <claimId> --yes`. Outside the `/v1/api` contract |
| `GET /v1/oauth/approvals/:approvalId` | UCAN `oauth/approval/read`, invoker AuthHub's `did:web` only (`403` otherwise; `*` is never accepted here); no `X-Auth-Type` needed | What AuthHub's approve page shows: `{ did, grantId, clientName, protocol: { did, name \| null }, price: { display, maxAmountUpay }, billingMode: "x402" \| "none", status, expiresAt }` (`expiresAt` in unix seconds). `404` unless the approval exists, is `pending` and has not expired. Never the delegation. `404` without `API_LANE`. Outside the `/v1/api` contract |
| `POST /v1/oauth/approvals/:approvalId` | UCAN `oauth/approval/approve`, same invoker rule | `{ decision: "approve", did, delegation: string \| null }` or `{ decision: "decline", did }` → `{ status: "approved" \| "declined" }`. `404` unless pending and `did` is the approval's. For billing `x402` the delegation must be a payment delegation (`parsePaymentDelegation`) whose root issuer is `did`, whose `oracles` name this oracle's did:web, whose audience is this oracle's facilitator (`X402_FACILITATOR_URL`'s did.json), whose `maxAmount` is exactly `maxAmountUpay` (a total across rails: more would be standing authority), and which has not expired — else `400 VALIDATION` with `details.reason` one of `invalid_delegation`, `issuer_mismatch`, `oracle_mismatch`, `facilitator_mismatch` (also logged `oauth.approval.facilitator_mismatch` with both DIDs: AuthHub and this oracle name different facilitators), `amount_mismatch`, `expired`, and the approval stays pending. `price.display` is informational; AuthHub computes what it shows from `maxAmountUpay`. For `none` nothing is kept. Approving gives the app 15 minutes to use it. Audited as `oauth.approval_approved` / `oauth.approval_declined` (actor kind `oauth`). Outside the `/v1/api` contract |
| `GET /v1/oauth/standing/:grantId` | UCAN `oauth/standing/read`, AuthHub's `did:web` only, never `*` | A grant's auto-approve: `{ perClaimUpay, totalUpay, spentUpay, until }` (upay strings, unix seconds), never the delegation. `404` when there is none, it is revoked, `until` has passed, or `policies.allowStandingApprovals` is off |
| `PUT /v1/oauth/standing/:grantId` | UCAN `oauth/standing/set`, same | `403 FORBIDDEN` (`details.reason: standing_approvals_off`) unless `policies.allowStandingApprovals`. `{ did, delegation, perClaimUpay, totalUpay, until }` replaces it: the same delegation checks (`maxAmount` = `totalUpay`, expiration ≥ `until`); `did` must be the grant's user as this oracle has seen it (its standing, else an approval) — else `404`. `spentUpay` is never reset (the total is this auto-approve's total across every delegation): a total below it is `400 VALIDATION` (`details.reason: limits_invalid`), nothing changed |
| `DELETE /v1/oauth/standing/:grantId` | UCAN `oauth/standing/revoke`, same | `{ did }`: deletes it, delegation and all; `200` whether or not it existed; another user's `did` is `404` |
| `POST /v1/reviews/:claimId` | UCAN `reviews/submit` (never `*`) + **the reviewer gate** (below) — never the read gate: an evaluate grant alone does not answer | `{ answers: { taskId → boolean \| string }, attestation, reviewerGrant }` — the answers must cover **exactly** the pending tasks (all-or-nothing). `202` + the review row, and the `ReviewComplete` workflow spawns. Answered-once until folded: a re-POST before the fold replaces the answers and the grant and re-spawns (that is also how a `foldError` is cleared); after the fold it `409`s |

**The oracle never deletes an organisation's claim data on its own**. Nothing expires a chain claim's transcripts or registry text, nor an API claim's
answers, uploads, webhook payloads, events, charges or audit rows: the organisation erases a claim
by hand — the collection's owner with `DELETE /v1/claims/:claimId/body` (above, or
`decision-oracle erase`), an API source with `DELETE /v1/api/claims/:claimId/body`, which also deletes
the claim's uploads; an upload no claim names goes with `DELETE /v1/api/uploads/:uploadId`
(`uploads/delete`, the same `settings/manage` say; `409` for a claim's file, `204` for one already
gone). Either erase is `409 CONFLICT` while the claim is still being decided — a running evaluate
would write the transcripts back — so erase it once it is decided. The hourly
housekeeping sweep only expires what is not the organisation's data: idempotency answers (24 h),
OAuth approvals and auto-approvals past their window, and the objects an organisation's own action
marked (an export bundle after its day, a deleted source's files).

**Reviewer grants (company authority).** Only a reviewer the company named answers a chain
collection's reviews. A current controller of the collection's entity (Blocksync
`entity.controller`, `did:ixo:ixo1…` accounts only) signs them one `@ixo/ucan` delegation and files
it with `POST /v1/collections/:id/reviewers`; the reviewer sends that grant as `reviewerGrant`
with every answer, the answer stores — and the fold forwards — the bytes the controller filed (not
the reviewer's copy), and the engine checks its root again and records
it in the receipt (`review.root`, `review.memberDid`). The gate (`lib/review/reviewer-grant.ts`)
runs at the answer **and again inside the fold**, before the re-pull, so a revoke between the two
refuses the fold with no engine call:

1. **The CAR** — standard, padded base64 (decoding and re-encoding gives back the very string;
   base64url or unpadded is refused, as on every CAR this oracle accepts, the Decision Console's
   included); exactly one delegation, no proofs, no extra block; its signature verifies against
   a key the issuer's `did:ixo` publishes on chain; its capabilities are exactly
   `[{ with: "ixo:oracle/<this oracle's did:web>/collection/<collectionId>", can: "claims/review" }]`
   (no wildcard); issuer and audience are `did:ixo:ixo1…` accounts (the same one is allowed: a controller may grant themselves); `nbf` and `exp`
   are both set, `exp − nbf` ≤ 30 days (UCANs carry no issued-at), `nbf ≤ now + 60 s` (the
   engine's clock skew) and `now < exp`.
2. **The reviewer** — its audience is the invoking principal (= the attestation's `issuer`).
3. **Revocation** — its canonical CID is a `collection_reviewers` row of this collection, not revoked.
4. **No oracle** — none of `ORACLE_DID`, `did:ixo:<ORACLE_ADDRESS>`, the did:web or
   `ORACLE_ENTITY_DID` is in it (`oracle`), nor the oracle's **owner** — the issuer of
   `OWNER_DELEGATION_CAR`, read once per isolate; skipped when it is unset (`oracle-owner`). The
   engine refuses the owner's fold, so it is refused at the answer; the message names the owner's
   DID and says to answer from another account. Register an oracle with an account nobody uses to
   review its claims.
5. **Live root** — the issuer is a current controller of the collection's entity (uncached).
6. **No evaluator** — neither the issuer nor the reviewer holds an `EvaluateClaimAuthorization` on
   the collection (read uncached): that power already signs `MsgEvaluateClaim` and moves the
   collection's payments, so it never also reviews.

`POST /v1/collections/:id/reviewers` runs 1, 4, 5 and 6, and requires the caller to be the issuer.
Every refusal carries `details.reason` in the engine's vocabulary — 400 `REVIEWER_GRANT_INVALID`:
`absent`, `unparseable`, `extra-block`, `shape`, `audience`, `identity`, `capability`, `resource`,
`lifetime`, `not-yet-valid`, `expired`, `too-long`, `signature`; 403 `REVIEWER_NOT_AUTHORIZED`:
`root-not-controller`, `oracle`, `oracle-owner`, `evaluator`, and the oracle's own `revoked`. A collection with no
entity is a 503 (fail closed). A fold refused either way stores `foldError` as
`CODE (reason): message`.
The collection's review room follows the grants that stand (unrevoked, in their window, root still
a controller): the hourly pass invites them and kicks the rest — never the owner; Blocksync down
skips the pass.

`attestation` is the reviewer's own signed statement over those answers — a compact JWS
`ClaimReviewAttestation` credential, signed with the key their `did:ixo` publishes on chain, whose
`credentialSubject` binds `{ claimId, oracleDid, answeredAgainst, answers }`. **It is required on
every review**: a review answer IS a signed statement, and there is no flag and no unsigned path.
It is verified before anything is stored (`issuer` = the invoking principal first, then `kid` ∈ the
issuer's published keys, the signature, and each binding) and refused otherwise — a refused
attestation never replaces stored answers, which is what keeps a stored answer signed: replacing
one takes another signature. The bytes are kept verbatim and forwarded to the engine, which
verifies them again; the review row reports `signed: true`, and the JWS itself is never returned by
a read route. The fold's Matrix card edit then names the reviewer and the attestation's CID.

| Refusal | Status | When |
| --- | --- | --- |
| `ATTESTATION_INVALID` | 400 | Not a compact JWS / not a `ClaimReviewAttestation` / payload is not canonical JSON / the signature does not verify |
| `ATTESTATION_KEY_UNKNOWN` | 400 | The issuer publishes no such key — including a DID that resolved to no document or to no Ed25519 method. Deterministic: retrying cannot change it |
| `ATTESTATION_ISSUER_MISMATCH` | 403 | `issuer` is not the invoking principal |
| `ATTESTATION_MISMATCH` | 400 | `oracleDid` / `claimId` / `answeredAgainst` / `answers` do not bind this review |
| `ATTESTATION_UNRESOLVABLE` | 503 | The resolver could not REACH its source (Blocksync down, host unreachable). Transient — retry |
| `ATTESTATION_REQUIRED` | 400 | No `attestation` on the request — refused before any lookup or write |
| `REVIEWER_GRANT_INVALID` | 400 | No `reviewerGrant` (`details.reason: "absent"`, refused before any lookup or write), or gate checks 1–2 fail |
| `REVIEWER_NOT_AUTHORIZED` | 403 | Gate checks 3–6 fail: never filed or revoked, an oracle identity (or the oracle's owner, `oracle-owner`) in it, a root no longer a controller, an evaluator in it |
| `UNAVAILABLE` | 503 | Blocksync, the chain or a DID resolver could not be reached while checking the grant — never a pass. Retry |

**Deploy order.** The portal ships first — an older package's review body schema is not strict, so
the extra `attestation` field rides along harmlessly. The engine and this package then deploy
**together**: both require the field, and the engine's fold body schema is strict, so a package
that forwards `attestation` to an older engine turns every fold into a 400 recorded as `foldError`.
A review row written before this (no stored attestation) cannot fold at all — the workflow says so
on the row and the reviewer answers again, signed.

The **read gate** (`assertCanRead`) is the chain's answer **now**, never the row's memory: the
collection entity's current chain owner (read off Blocksync, cached 5 min), or any holder of a live
evaluate grant for it (cached 5 min) — the same permission surface as the portal's "Grant Role"
flow. The enroller passes only while it still holds a grant, and an owner who transferred the
entity reads nothing. A collection this oracle does not watch is a `404`. One claim's record also
opens to that claim's submitter (its on-chain agent DID or agent address) — that claim only,
never the collection.

There is deliberately **no claim-ingestion endpoint**: the chain is truth, so a pushed claimId adds
nothing worth trusting.

The Decision API lane's routes (`/v1/api/*`, `API_LANE` on) are specified by
`openapi/decision-api.yaml`, shipped in the package and served at `GET /v1/api/openapi.json`. A
console invocation (`X-Auth-Type: ucan`) carries ONE capability whose `can` is the operation's own
`x-ucan-action` (`API_ACTIONS` in `src/contracts/capabilities.ts`, e.g. `claims/read` for `GET
/v1/api/claims`); any other `can` — another operation's action, or the legacy `*` — is `403
FORBIDDEN`, with a message naming the action the route takes. API keys are unaffected.

**Console roles** (`ROLE_CAPABILITIES`, `src/contracts/capabilities.ts`) are the five presets a
member grant must match exactly: `owner` = `source/*` (covers every capability), `developer` =
`claims/read` · `claims/review` · `keys/manage` · `webhooks/manage` · `spend/read` · `audit/read`,
`reviewer` = `claims/read` · `claims/review`, `viewer` = `claims/read` · `spend/read`, and
`submitter` = `claims/submit` · `claims/read` (v1.9). `claims/submit` is what submitting (one claim
or a batch), `PUT /v1/api/uploads` and `POST /v1/api/uploads/links` require; `claims/read` covers a
claim's record, receipt and report and `GET /v1/api/uploads/:uploadId` — source-wide, never the
answers.

**A source's organisation.** An organisation IS its on-chain
entity, and a source is that organisation's access to this oracle — it never exists without one,
and there is no personal root. `POST /v1/api/sources` takes `{ name, contactEmail, entityDid }` and
is `403` unless the invoker is a CURRENT controller of `entityDid` (Blocksync `entity.controller`,
`did:ixo:ixo1…` accounts, read live and uncached; `503` when it cannot be read); the creator pays
(`ownerDid`) but gets no membership. The source starts **`pending-link`**: it answers its lists,
`GET /v1/api/me`, the sync, revoke/remove and delete, and refuses every other route — claims,
uploads, keys (even an existing one, on any route), invites and their acceptance, reviews,
settings, webhooks — with `409 SOURCE_PENDING_LINK`, checked where the caller is built (console,
key, OAuth). Until it is first active (`activatedAt`, set once; on the `Source` view since 1.17.0,
null for a source that never held claims), only its organisation's controllers can list it — not
the operator (`/admin/sources`) — and its id is in no log line, metric, alert or Sentry event (logs
carry `sourceIdHash`, the first 12 hex of its sha256; `/v1/api` log lines carry the route pattern,
not the path; the D1 audit row keeps the id). The organisation's link lives at the decision
engine: a controller signs a `SourceCompanyLink`, the console PUTs it there, then calls `POST /v1/api/sources/:sourceId/company/sync` (`sources/company-sync`), which
reads the engine's stored state (`company/read`) and mirrors it: a link for the source's own
entity ⇒ **`active`** (a source with a `pausedReason` stays `paused`, read in the same statement)
with `payeeField` and the link (verbatim) — a read older than the stored link is
ignored; a link for another entity, or the engine's `disputed` ⇒ **`disputed`** (`409
SOURCE_DISPUTED` on the same routes; the `api.company-link.disputed` alert); nothing stored ⇒
nothing changes (a source already linked keeps its state — an engine-side transient — with the
`api.company-link.unlinked` alert). The sync never writes `pausedReason`. Any
non-2xx from the engine (a `404` included) is `503` and changes nothing. The oracle never
verifies a link JWS. **Roots:** the entity's current controllers, wherever the API used to say
"owner" — a controller invokes any source route as root with no proof and no membership
(`X-Source-Id`, `with: ixo:oracle/<did:web>/source/<id>`, the route's action; `GET /v1/api/me`
answers `caller.kind: "controller"`), every member grant must be rooted at one, and a former
controller with no membership is a stranger (`404`) at once. `DELETE /v1/api/sources/:sourceId`
(`sources/delete`, a controller only) deletes the source and every row hanging off it — the way
out of `disputed`; its uploads and exports are made due at once, so the hourly housekeeping sweep
deletes their R2 objects, then the rows. `GET /v1/api/organisations/:entityDid/sources`
(`listOrganisationSources`, `sources/list-organisation`, 1.17.0) answers an organisation's sources
here in EVERY state — `pending-link`, `active`, `paused`, `disputed` — newest first, keyset-paged
`{ items, nextCursor }`, to a current controller of the entity only (read live; `403` otherwise,
`503` when the chain cannot be read). It is how a controller finds a source an abandoned "Add
oracle" or a CLI sign-up left `pending-link` (sync it, or delete it — nothing deletes one
automatically). `GET /v1/api/sources` (`sources/list`) is the account's memberships alone, each
source carrying its `companyEntityDid`; a stale `?entity=` on it is `400 VALIDATION`, naming the
new route.

**Review billing (per-review checker billing for chain claims, contracts 1.22.0).** Only while it
is active — IXO's plan for the oracle allows it (the engine's `GET /v1/oracles/me`
`reviewBilling`, IXO-admin only, default false, KV-cached 5 min; an oracle never turns it on
by itself, and the facilitator — the real lock — refuses chain-lane reservations otherwise) on an
oracle running `API_LANE` + `API_PAYMENTS` with a 0.7.0 facilitator — otherwise all three are
`404`. A collection registered per review while it was active, whose plan no longer allows it,
pauses `payer-delegation` rather than be decided unpaid. `PUT
/v1/api/source/review-billing` (`review-billing/set`, `keys/manage`: a member's grant or a
controller) stores the source's ONE standing payment delegation: `{ delegation }`, an x402-ixo-pay
payment delegation (base64 CAR, ≤ 16 KiB) — one `billing/pay` on `billing:decision-api` addressed to
the facilitator (`X402_FACILITATOR_URL`'s did:web), `nb: { maxAmount, denom: "upay", oracles:
[<this oracle's did:web>] }` (exactly this oracle), an `exp` at least 24 h out (any later one,
however far), an `nbf` (if any) not more than 5 min ahead, no proofs, no facts, its signature
verified against the issuer's keys on chain. Its issuer must be the organisation's CURRENT chain
owner (`getEntityOwner` on the source's `companyEntityDid`, uncached): `403 FORBIDDEN` `reason:
not_org_owner` otherwise, whoever stores it — the facilitator debits the issuer's subscription deed.
Also refused, nothing stored: `409 CONFLICT` `billing_mode_none` (the source is not billed) or
`review_billing_elsewhere` (another source of the same organisation holds a live one;
`details.sourceId` — one paying source per organisation), `503 SOURCE_PAUSED` `payments-disabled`
(`API_PAYMENTS` off or no facilitator), and `400 VALIDATION` with `reason`
`not_a_payment_delegation` · `wrong_audience` · `not_oracle_bound` · `expires_too_soon` ·
`not_yet_valid` · `cap_below_price` (`maxAmount` below the lowest published price) ·
`bad_signature`. Stored sealed in `review_billing_delegations` (one row per source, `v1:` under
`WEBHOOK_ENCRYPTION_KEY`; audited `source.review_billing_set` by CID, payer, `maxAmount`,
`expiresAt` — never the token); the same CID again keeps its count, a new one starts at 0. `GET`
(`review-billing/read`, `spend/read`) answers `ReviewBilling` — `state` (`active` · `expired` ·
`exhausted` · `payer_not_owner`), `payer`, `delegationCid`, `maxAmount`, `spent` / `remaining` (the
oracle's own count of what it reserved against `maxAmount`: the facilitator exposes none, and the
delegation names this oracle alone), `expiresAt`, `credits` (the payer's facilitator balance, null
until readable), `pricePerReview`, `pausedCollections` — or `404`. `DELETE` (`review-billing/delete`,
`keys/manage`) drops it: `200 { deleted, message }`, idempotent; the delegation itself stays valid
until its `exp` — a full stop is a revoke in the UCAN store (the Console's). A row is deleted with
its source, and 30 days past its `exp` by the housekeeping sweep.

**Who a claim is about (`submitter`).** `ctx.claim.submitter` on the API lane is
the caller's explicit `submitter`, else the answer the source's organisation link names
(`answers[payeeField]`: a string or finite integer, trimmed, 1–256 characters — the engine's own
`submitterFor` rule, `lib/api-lane/submitter.ts`), else null — never an oracle-side fill. A protocol whose work order has a frequency rule refuses a
claim with neither `400 VALIDATION` at `submitter` — at `answers.<payeeField>` when the source has
one — before any payment (MCP: `SUBMITTER_REQUIRED`, before any approval). An AI app over OAuth acts
for one person: an explicit `submitter` other than the signed-in user's DID is `400 VALIDATION` at
`submitter`. `ClaimRecord.submittedBy` is who SENT the claim, set server-side from the caller (the
OAuth user's DID; null for an API key or a console submit) — never part of the content.
`ClaimRecord.submittedVia` (v1.12.0) is WHICH AI app sent it over OAuth, `{ name, verified }`; null
otherwise. `name` is the client's registered name (the AuthHub token's `client_name`, else its
`client_id`), trimmed and capped at 100 characters — the app declares it at dynamic registration, so
it is a display hint, never an authorisation input. `verified` is the token's `verified` claim
(exactly `true`, else false): AuthHub sets it only when every redirect URI the client registered is
Claude's or ChatGPT's own callback, so a local app (Claude Code, Codex) is `false`. Stored as
`api_claims.submitted_via` + `submitted_via_verified`.

**A URL attachment's `mediaType` is detected at accept when the caller sends none** (an AI app
over MCP sends a photo as `{ question, url }`). `recordApiClaims` (`lib/api-lane/media-type.ts`)
reads it from the URL path's extension (jpg/jpeg, png, webp, gif, heic/heif, avif, pdf, mp4, mov,
webm), else from one `HEAD` through the SSRF guard (3 s, at most 6 at once across a batch,
`Content-Type` parameters stripped, a 403/405 left unknown — no `GET` fallback), and stores it on
the claim's attachment, so every evaluate, retry and review fold reads the same value. A declared
`mediaType` always wins; one it cannot tell stays absent and the facts lane's
`application/octet-stream` default applies. Detection never fails the submit, and the
`Idempotency-Key` comparison is still over the caller's own request. An upload's type is the one
sniffed at upload.

**Who answers an API claim's review (`POST /v1/api/claims/:claimId/review`, contracts 1.11.0).**
Every answer carries `reviewerGrant`, a base64 CAR (≤ 16 KiB): the engine refuses a company
decision's fold without one (every source has a company), so a missing one is `400
REVIEWER_GRANT_INVALID` (`details.reason: "absent"`) before any read or write. Every link must
state `nbf` and `exp` with `exp − nbf` ≤ 30 days, `nbf ≤ now + 60 s`, `now < exp`, carry a
`claims/review` capability on `ixo:oracle/<did:web>/source/<sourceId>`, and no wildcard in any
`can` or `with`; no identity of this oracle may appear in it — its owner (the issuer of
`OWNER_DELEGATION_CAR`) included: `403 REVIEWER_NOT_AUTHORIZED`, `details.reason: "oracle-owner"`,
before anything is stored, whether the owner answers as a controller, as a member or is the
controller who granted a member's access (contracts 1.15.0).

- A **member** signs the attestation with the `did:key` they registered on the source
  (`api_members.reviewerDid`) and must hold `claims/review` over the claim's protocol now
  (`mayReview`: the stored grant's capabilities and `nb.protocols` — the engine does not read
  `nb.protocols`, so this is where the protocol limit holds). Its grant is exactly **two links**:
  (1) its STORED, unrevoked role grant (matched by canonical CID against `api_members.delegationCid`
  — anything else is `403 REVIEWER_NOT_AUTHORIZED`, `revoked`), issued by a current controller of
  the organisation (`403`, `root-not-controller`) to the member's `did:ixo`; then (2) the member's
  `did:ixo` → that registered `did:key`, exactly `{ with: <source resource>, can: "claims/review" }`,
  with link 1 its only proof. Each signature is checked against its issuer's IID. A grant reached
  through a co-owner (three links) or an `owner`-role grant (`source/*`, a wildcard) cannot review:
  the member needs a `reviewer` or `developer` grant straight from a controller. So the first link
  can stand, a role grant carrying `claims/review` (`PUT /v1/api/members/:memberId/delegation`)
  must have `nbf` and `exp` at most 30 days apart (else `400 VALIDATION`, "reviewer grants last at
  most 30 days") — re-grant before it runs out.
- A **root** — a current controller of the source's organisation, invoking as itself — needs no
  member row: its attestation's `issuer` is its own `did:ixo`, the `kid` a `did:key` on that DID's
  document (resolved like the chain lane's), and its grant is ONE delegation from the controller
  to itself, exactly `claims/review` on the source, signed by that same `kid`. Its controller-ness
  is the console's live root check (no second chain read); the engine records it as root and
  member (`rootKind: company-controller`).

Refusals carry the engine's `details.reason` (`absent`, `unparseable`, `shape`, `extra-block`,
`signature`, `capability`, `resource`, `identity`, `lifetime`, `too-long`, `not-yet-valid`,
`expired`, `audience` → 400 `REVIEWER_GRANT_INVALID`; `revoked`, `root-not-controller`, `oracle`,
`oracle-owner` → 403 `REVIEWER_NOT_AUTHORIZED`). The review row stores the grant beside `reviewerDid`. Before it
forwards both, the fold re-checks what can change after the answer: a member's row must still
review the claim's protocol (not revoked, expired or narrowed) and still name the grant's first
link; a root must still be a controller; neither may be the oracle's owner. A miss is a `foldError` with the same code and reason
(`revoked`, `root-not-controller`), nothing is sent, and the reviewer answers again. The engine
checks the chain again, live.

**Telling the console up front (contracts 1.15.0).** The review view (`GET
/v1/api/claims/:claimId/review`) carries `canAnswer: { ok, reason? }` and `GET /v1/api/me` carries
`reviewBlocked` (`"oracle-owner" | "not-reviewer" | null`), both for the CALLER and computed only
from what is cheap and certain: `oracle-owner` when the caller's `did:ixo` is the oracle's owner,
`not-reviewer` when the caller does not sign answers — an API key (it may still relay a
member-signed answer), a member without `claims/review`, or an AI app without it or without the
token's `claims:write` scope. Neither names the owner, and an
answer runs every check whatever they said — an owner-granted member's answer is still refused at
the answer (`oracle-owner`) though its hint is `ok`.

**Why it was held (contracts 1.20.0).** When an `ext`/`ai` fact comes back `error` with the
rubric's `onError: review`, the engine's reason line is generic ("External check unreachable after
retries — held for review") — it is hashed into `resultDigest`, so it never names the check. The
engine also answers `erroredFacts: [{ name, detail }]` on the decide, its stored-decision read and the fold: one entry per
fact that errored, `name` lane-prefixed (`ext.beforeAfter`, `ai.sameBoat`), `detail` that fact's own
error text (what the handler or stock lane wrote — never an answer field), `ext` then `ai`. The
oracle stores ONLY that list on the decision row (`decisions.erroredFacts`, each detail capped at
500 chars; never logged), written with the decision, replaced by a fold that carries one, and serves
it as `ClaimRecord.decision.erroredFacts` and the review view's `erroredFacts` — both omitted when
nothing errored or the engine sent no list (an older engine) — and on the chain lane's
`GET /v1/claims/:claimId` (`decision.erroredFacts`, null for the claim's submitter) and
`GET /v1/reviews/:claimId`, null when none.

**What a held duplicate matched (contracts 1.24.0).** For each pending `unique:<ruleId>` task the
engine also answers `pendingTaskDetails["unique:<ruleId>"] = { kind: "duplicate", ruleId, score?,
priorClaimId? }` — the frozen unique fact's score in basis points and its winning earlier claim. The
oracle stores that map on the decision row (`decisions.pendingTaskDetails`, with the decision only)
and the review view turns it into `duplicates: [{ taskId, ruleId, score?, prior }]`: `prior.outcome`
is this oracle's decision on the earlier claim (`null` when it has none); `prior.claimId`,
`prior.submittedAt` and `prior.files` only when the earlier claim is the same source's, under the
same protocol. The files are the earlier claim's attachments whose CID this claim also registered in
the duplicate lane (`claim_attachment_cids`) — a near-image match shares no CID, and then they are the
earlier claim's images — each URL signed like the view's own `files`. Another organisation's claim
never shows its id or files. Omitted when no duplicate task pends. The chain lane's
`GET /v1/reviews/:claimId` does not carry it.

One of the lane's review rules is a payment rule: a paid claim decided `review` asks the
facilitator to extend its x402 reservation (up to 30 days from verify). The review's **`answerBy`** is that reservation's
expiry — the extended one, else the default 24 h from verify — less 5 minutes
(`ANSWER_BY_MARGIN_MS`: the fold and then the settle still run after the answer), on the claim
record's `review` and on the reviewer view (`GET /v1/api/claims/:claimId/review`); null for a
`none`-billed source and once the claim no longer awaits review. An answer after it is refused
`409 REVIEW_EXPIRED` (`details.answerBy`) before anything is verified or written — the claim could
no longer be paid — except the same signed answer re-sent after it first landed in time (a retried
`503`). The view keeps serving the claim. The review emails and reminders state the deadline
("Answer by 30 Oct 2026, 11:57 UTC, or this claim can't be paid.") only for a claim that has one,
and nobody is emailed or posted about a claim already past it.

**Who is emailed about a claim waiting for review.** Every accepted member whose live grant carries
`claims/review` over the claim's protocol, with an address and `notifyReviews` on, gets the
review-needed email at once — at most one per member per source per 15 minutes: claims that arrive
inside the window go out together as one email when it ends — and the 3- and 14-day reminders. The
source's `contactEmail` gets the same three emails, on its own 15-minute window (an organisation
admin needs no member row to hear of a claim), unless a member with that same address
(case-insensitive) is already sent every claim it would be. The source's Matrix review room takes
every review-capable member.

**Webhooks go to the registered origin only.** A delivery follows at most 3 redirects, each hop
https and public, and only within the endpoint's own origin, re-sent as the same signed POST; a
redirect to another origin is not followed — the delivery is `dead` at once, its `lastError` naming
the target's origin — so a source registers the final URL.

**Where a webhook goes (contracts 1.16.0).** Every active endpoint subscribed to the event type
(`events: []` = all), each signed with its own secret; and, for `claim.*`, the claim's own
`webhookUrl` or else the source's default URL (`defaultWebhookUrl`), signed with the source's
secret — whatever endpoints exist, so adding an endpoint never cuts the default receiver off. A
default (or claim) URL that is exactly a subscribed endpoint's URL is posted once, as the
endpoint's delivery. `source.*`, `payment.settled` and `balance.low` go to endpoints only.

**A redeliver re-reads its target (contracts 1.18.0).** `POST
/v1/api/webhooks/deliveries/:deliveryId/redeliver` posts the frozen envelope to where the row's
target points NOW: an endpoint's row to the endpoint's current URL (its secret); a row without an
endpoint to the claim's own `webhookUrl`, else the source's CURRENT default (the source's secret) —
never the URL frozen on the row. It is refused `409 CONFLICT` before anything is reset or posted when
the endpoint was deleted (`details.reason: "endpoint-deleted"`) or turned off (`"endpoint-disabled"`),
or a default-URL row's source has no default URL (`"no-webhook-url"`); like any answer, that 409 is
stored under its `Idempotency-Key`, so a retry after the fix sends a new key. A default-URL or
claim-URL redeliver always goes to the current target, signed with the SOURCE's secret, even when an
endpoint now has that same URL (the enqueue-time de-dupe does not apply to one explicit row);
receivers de-duplicate on `Oracle-Event-Id`. An erased row stays erased and goes `dead`.

**A low balance is told twice.** With a facilitator configured, the balance watch (every 5
minutes) reads each active source's payer balance against its `refillFloorCents`. The first read
below the floor sends one `balance.low` webhook (`{ sourceId, balanceCents, floorCents }`) and one
email to the source's `contactEmail`: the source's name, the balance and the floor as dollars, and
an "Add credits" link to `${CONSOLE_URL}/billing/credits`. The webhook goes once per crossing (a recovery
above the floor re-arms it). The email goes once per UTC day while the balance stays under the
floor (`notice_balance_low:<sourceId>:<yyyy-mm-dd>`): still low tomorrow is a new email, a balance
bobbing around its floor within a day is one, and a send that fails is retried on the next check.
No `contactEmail`, or no `EMAIL` binding, `EMAIL_FROM_ADDRESS` or `CONSOLE_URL` ⇒ the email is a
logged skip (`email.notice_balance_low.skipped`) and the webhook still goes; the
`email.notice_balance_low` metric counts `sent` / `skipped` / `failed`.

**A review that closes unanswered is announced once.** The hourly pass writes a `review_expired`
timeline event (`data.answerBy` only) and sends the `claim.review_expired` webhook with the record.
The record's status stays what the engine said (`pending_review`: nothing was decided), so
`review.answerBy` in the past together with that event means the claim is **closed unpaid** — its
reservation was released and no receipt will come.

**Operators get a card-facilitator credential.**
`GET /v1/api/admin/facilitator-delegation[?actions=][&ttl=]` (console sign-in on the account — no
`X-Source-Id`, invoker in `ORACLE_ADMIN_DIDS`; an API key is `401 UNAUTHORIZED`, any other account or a
source-scoped invocation `403 FORBIDDEN`) answers `{ delegation, issuer, audience, capabilities,
expiresAt }`: a UCAN delegation (base64 CAR, as a console role grant) from
`did:web:<ORACLE_HOSTNAME>` — signed with `SERVICE_ED25519_MNEMONIC`'s key, the one
`/.well-known/did.json` publishes — to the operator's did:ixo, whose `att` is exactly one `{ with:
"ixo:ixo-pay-facilitator", can: <action> }` per requested action, no `nb` (`capabilities` echoes
it). `actions` is a comma-separated list of `operators/onboard`, `operators/read`,
`operators/refund`, `operators/evidence` (default all four; deduplicated, listed in that order);
anything else — `*`, the retired `operators/manage`, any `payouts/*` action, an unknown name, an
empty list — is `400 VALIDATION`. `ttl` is 60–3600 s (default 3600; `400 VALIDATION` outside): the delegation
carries `nbf` = its issue time − 60 s (a UCAN is valid only once `now > nbf`, so it is backdated past
skew) and `exp` = `min(issue + ttl, nbf + 3600)` (unix seconds; `expiresAt` is that `exp`), so
`exp − nbf ≤ 3600` always — the default gives `exp` = issue + 3540. The facilitator refuses a grant
without `nbf` or living longer. Every call mints a new one (a nonce
each, `Cache-Control: no-store`) and writes an `api_audit` row `facilitator.delegation_minted` (the
audience, the actions, `expiresAt` and the delegation's `cid`, never the token); `503 UNAVAILABLE`
when the signing key is unset.

Payout access is not minted here: the payouts facilitator holds each company's payout team itself
(the controllers of its company entity, `GET /v1/payouts/info` `{ entityDid, admins, rails }`) and
accepts no oracle-minted payout grant, so the Console manages that team on the facilitator.

#### Use with Claude Code and other MCP clients

With `API_LANE` on, every oracle also hosts an MCP server at `https://<ORACLE_HOSTNAME>/v1/mcp` (MCP
over Streamable HTTP, stateless, JSON answers; `POST /v1/mcp` only — any other method or path under
it answers `405` with `Allow: POST`, after the key check below, so a request without
`Authorization` is `401` first). Nothing to install:

```bash
claude mcp add --transport http ixo https://<oracle>/v1/mcp --header "Authorization: Bearer eo_live_…"
```

The credentials are headers on the connection: `Authorization: Bearer <API key>` (required — a
request without one is `401 UNAUTHORIZED`, with `WWW-Authenticate: Bearer realm="ixo-decision-api"`),
plus `X-Payment-Delegation: <IXO_PAYMENT_DELEGATION>` for a source in billing mode `x402` (and, optionally,
`X-Payment-Prefer: card | upay`, case-insensitive — which way to pay when a 402 offers both; without
it, or with any other value, the payer's own console default, read from the facilitator with the
delegation, and PAY when that can't be read). The key is
checked at connect: an `initialize` (alone or in a batch) calls `GET /v1/api/me` with it, and an
invalid or revoked key is that same `401` there; every other request checks only that a bearer is
present, and each tool call is authorised by the route it calls. Exactly five tools, each the SDK
(`@ixo/decision-oracle-client`) calling this oracle's own route in-process with that key — so scopes,
`SCOPE`, the per-key and per-IP limits (the MCP request counts against the caller's IP, and each
tool call also counts its inner route calls — a paid submit is three: the MCP request, the `402`,
the paid resend), the daily spend cap, idempotency, the audit log and "never the body" hold exactly
as over HTTP. Only the SDK's own headers (plus the caller's IP, request id and `traceparent`) reach
the inner request — never a caller's `X-Auth-Type` / `X-Source-Id`:

| Tool | Arguments | Calls | Returns |
| --- | --- | --- | --- |
| `submit_claim` | `protocolDid?`, `answers`, `attachments?`, `submitter?`, `metadata?`, `webhookUrl?`, `idempotencyKey?`, `resumePayment?` (after a card 3-D Secure refusal: its `paymentRequired`, once the payer has confirmed at `confirmUrl`) | `POST /v1/api/claims` with `Prefer: wait=90` and an `Idempotency-Key` — the argument, else `mcp_` + hex `sha256(canonicalJson(request))`; a 402 is paid by at most one attempt per rail with `X-Payment-Delegation`, as the SDK's payer does (the card authorize goes to this oracle's `X402_FACILITATOR_URL`, never the delegation's audience; a `resumePayment` that is not this oracle's card 402 is a `VALIDATION` refusal) | the decided record, or `{ status: "pending", claimId, pollUrl }` |
| `upload_file` | `sourceId?` (OAuth only) | `POST /v1/api/uploads/links` | `{ uploadId, uploadUrl, expiresAt }` as `structuredContent`; the text is instructions instead of the JSON (the upload card, or `curl -sS -T <path> '<uploadUrl>'`, then `submit_claim` with `{ question, uploadId }`) |
| `get_decision` | `claimId` | `GET /v1/api/claims/:claimId` | the record |
| `list_protocols` | `protocolDid?` | `GET /v1/api/protocols`, or `…/:did` for one protocol's work order (answers JSON Schema, files, facts, price, example) | the list (`{ protocolDid, name, price }`: `name` is the protocol entity's `#pro` profile name — hash-checked, KV-cached 1 h — else its claim form's title, else `null`; a failed read never fails the list), or the work order |
| `verify_receipt` | `claimId`, or `jws` (+ `cid?`) | `GET /v1/api/claims/:claimId/receipt` (with `claimId` alone; given both, the `jws` is verified and must name that `claimId`), then `verifyReceipt` trusting only this oracle's engine (`did:web:<EVAL_ENGINE_HOSTNAME>`) | `{ valid: true, issuer, claimId, verdict, status, outcome, protocolDid, oracleDid, kind, evaluatedAt }` or `{ valid: false, reason, message }` |

**An attachment named by `uploadId` alone gets its `cid` filled in.** Before anything else is
asked or charged, `submit_claim` reads `GET /v1/api/uploads/:uploadId` on the source the submit
will use and sets the attachment's `cid`; a `404` (the link not used yet) is the tool's own
`UPLOAD_PENDING` refusal, with no approval filed and nothing submitted.

**The default `Idempotency-Key` is derived from the request, not random.** It hashes the request
after the `cid`s are filled, so a retry that passes the `cid` names the same claim. An MCP client often gives
up on a tool call at 60 s while `submit_claim` still holds its 90 s wait, and an agent's blind retry
under a fresh key would be a second claim — on a paid source, a second price held. So an identical
claim submitted again within 24 h (the oracle's idempotency window, scoped per source) returns the
first claim's decision and is never charged again; pass a distinct `idempotencyKey` to submit the
same claim twice on purpose. The SDK inside retries a transient failure once (`maxRetries: 1`): a
5xx after the gate reserved the price must be resent with the SAME `PAYMENT-SIGNATURE`, which only
the SDK holds — the agent's own retry would arrive unpaid and be offered again.

Results are `structuredContent` plus the same JSON as a text block. A refusal is an `isError` result
carrying `{ error: { code, message, requestId, docsUrl, fundingUrl?, details?, retryAfterSeconds?,
idempotencyKey? } }` — the route's own envelope, plus the route's `Retry-After` in whole seconds
when it set one and, on a failed `submit_claim`, the key to retry with (the same claim, never a
second charge) — never a JSON-RPC error. A failure that is none of these is `{ error: { code:
"INTERNAL", message } }`, logged on the oracle, with no stack and no input. A `submit_claim` whose claim was RECORDED but
whose answer then failed (the wait, the record read) is not an `isError` result: the ledger is read
for the claim the request's key names, and the tool answers `{ claimId, status, pollUrl,
idempotencyKey, note }` — never a refusal without the id, which would invite a resubmit. A refusal
of the tool's own, and `IDEMPOTENCY_MISMATCH`, stay refusals. No review answering over
MCP.

**Cards (MCP Apps).** The server implements the `io.modelcontextprotocol/ui` extension (stable
2026-01-26). `submit_claim` and `get_decision` carry `_meta.ui.resourceUri:
"ui://eval-oracle/decision-card"`, `verify_receipt` carries `"ui://eval-oracle/receipt"` and
`list_protocols` `"ui://eval-oracle/protocols"` and `upload_file` `"ui://eval-oracle/upload"` (each
plus the pre-GA flat `_meta["ui/resourceUri"]`), and the views are listed by `resources/list` and
served by `resources/read` as `text/html;profile=mcp-app` with `_meta.ui.csp` declaring no domain —
one self-contained page each, inline CSS and JS, nothing fetched — save the upload card, whose one
`connectDomains` entry is `https://<ORACLE_HOSTNAME>`: its "Choose file" button `PUT`s the chosen
file (≤ 25 MB, checked before sending) to `uploadUrl` and shows the upload's name, size, type and
cid, or the refusal in plain words (`403` expired, `409` already uploaded, `413` too large). A host that renders them hands the
view the tool's own `structuredContent`; the card shows the verdict, the checks (`gates`,
`criteria`, `reasons`), the price and payment status and the claim id, never the answers or
`metadata`. Its "Verify receipt" button is a `tools/call` of `verify_receipt` through the host; on
an approval ask it shows the price and an "Approve on IXO" button that asks the host to open
`approveUrl` (`ui/open-link`, AuthHub's origin only) — it never approves or pays itself. The
protocols view lists each protocol's name and price, or one work order's answer fields (label,
type, required, hint) and file questions; it submits nothing. The views are linked
unconditionally (the server is stateless, so `tools/list` never sees the client's `initialize`
capabilities); a client without the extension ignores `_meta`, and every result's text is its
`structuredContent` as JSON, as before — save the approval asks below.

**Connecting by signing in (OAuth).** A bearer that is not an `eo_` key and is shaped like a JWT is
an access token from IXO AuthHub (`AUTH_HUB_URL`), the one authorization server of every oracle. The
`401` on `/v1/mcp` carries `WWW-Authenticate: Bearer realm="ixo-decision-api",
resource_metadata="https://<ORACLE_HOSTNAME>/.well-known/oauth-protected-resource/v1/mcp"`, which is
how Claude, ChatGPT and Claude Code find AuthHub. The token is checked on every request: EdDSA only,
`typ: at+jwt`, a key from AuthHub's JWKS (cached in KV for 5 minutes; a miss or an unknown `kid` fetches it, at most every 30 s per isolate —
an unreachable AuthHub is `503`), `iss` exactly `AUTH_HUB_URL`, `aud` exactly
`https://<ORACLE_HOSTNAME>/v1/mcp` (one audience, never a list), unexpired within 60 s of skew,
issued (`iat`, required) at most 10 minutes ago, and `sub` a `did:ixo` account. A
refused token is `401` with `error="invalid_token"` added. The token is never passed on, and
`/v1/api` never accepts one (it is a `401` there like any other non-key): the tools call the routes
in-process as an `oauth` caller, marked on the request object the handler builds, never by a header.

The `oauth` caller acts on the sources the user stands on, read on every request: as ROOT (every
capability) on any `active` source whose organisation entity lists the user among its CURRENT
controllers (Blocksync, uncached, one read per organisation per request; a controller needs no
membership, as in the console; when the read fails, a source only that standing would add is left
out and logged `mcp.oauth.controllers-unavailable`), else as a live member (accepted, unrevoked,
unexpired, the stored grant's chain unrevoked and its co-owner live — the console's checks over the
stored grant). A source the user both controls and joined is one source, as root. Its rights are the role's capabilities intersected with
the token's scopes (`claims:write`, `claims:read`), and it reaches ONLY the operations the four
tools call (`submitClaim`, `getClaim`, `getClaimReceipt`, `listProtocols`, `getProtocol`,
`createUploadLink`, `getUpload`) — any other is `403 SCOPE`, whatever the role, so it never reaches
erasure, reviews, the raw upload, batches, keys,
members, webhooks, settings or spend. It is rate-limited per grant (`gid`) and per source, like a key.

- **Choosing a source.** One usable source (`claims/submit` to submit or upload, `claims/read` to
  read) is used; `submit_claim` and `upload_file` choose it the same way.
  With several, `submit_claim` takes `sourceId`, and without it answers `SOURCE_REQUIRED` with
  `sources: [{ sourceId, name }]`. With none, `NO_SOURCE` with `consoleUrl` (`CONSOLE_URL`, else
  `PORTAL_BASE_URL`). A named source that is not linked answers `SOURCE_PENDING_LINK` /
  `SOURCE_DISPUTED` before any approval. OAuth never creates an account or a source.
- **Every submit is approved.** The first `submit_claim` for a request files an `mcp_approvals` row
  (bound to the grant and to `sha256` over the protocol, source, canonical body, attachments and
  idempotency key) and answers `APPROVAL_REQUIRED` with `approveUrl`
  (`<AUTH_HUB_URL>/connect/approve?oracle=<ORACLE_HOSTNAME>&id=<id>`, the id 128 random bits),
  `price` and `expiresAt` (15 minutes). The same request answers `APPROVAL_PENDING` until the user
  answers on AuthHub. These three asks (`APPROVAL_REQUIRED`, `APPROVAL_PENDING`, `APPROVAL_STALE`)
  are NOT `isError` results — a host renders no card for an error, and the card holds the approve
  button: `structuredContent` is `{ status: "approval_required", error: { code, message,
  approveUrl, price, expiresAt } }`, and the text opens `NOT SUBMITTED. Needs the user's approval on
  IXO: <message>` before the same JSON. Every other refusal stays `isError`. Once approved, one conditional `UPDATE` (`approved` → `used`) spends the row
  before anything is paid, so two racing retries pay once; the loser answers `{ status: "pending",
  claimId, pollUrl }`. The claim is then paid in-process with the delegation the user signed. What
  becomes of the approval depends on how the payment ended. It is put back to `approved` (one
  conditional `UPDATE … WHERE status='used' AND expires_at > now`, delegation kept) ONLY when the
  payment is proven to have reserved nothing — the submit route never left `/verify` reserving and
  no card hold was sent — and the refusal is one the user can fix without re-signing:
  `INSUFFICIENT_CREDITS`, `SPEND_CAP`, a refused card (`CARD_DECLINED`, `CARD_BLOCKED`,
  `CARD_NOT_SAVED`, `CARD_NOT_OFFERED`), `NO_SUPPORTED_PAYMENT`, or the payment lane's `503`
  before `/verify` — never `CARD_REFUSED` (the SDK's catch-all: a hold may exist), never the resume
  of a confirmed 3-D Secure payment (a second challenge retires it too: the user approves again,
  and no second hold is ever placed), and never when the row can't be made live again (it lapsed, or an identical request
  filed its own approval meanwhile): those retire the delegation. Once any attempt left `/verify`
  reserving, a later refusal does not count. The resume of a confirmed card offer is exempt from
  the per-grant cap, which that offer already passed. A card that wants 3-D Secure parks it as `awaiting_3ds`, bound to
  that offer and PaymentIntent: only a `submit_claim` whose `resumePayment` names that offer may
  spend it (anything else, or `X-Payment-Prefer: upay`, is `AWAITING_CARD_CONFIRMATION`), and a
  resume carrying any other payment is `409 CONFLICT` before the route. Anything else — paid, a
  refused delegation (`INVALID_DELEGATION`, `DELEGATION_EXPIRED`, `DELEGATION_WRONG_ORACLE`), or
  ambiguous (a 5xx or a timeout once a payment was sent: `/verify` may have reserved) — retires the
  delegation, and the same request asks for a new approval. A price or billing mode that changed since is `APPROVAL_STALE`, with a
  new `approveUrl`; nothing is paid at the new price. `maxAmountUpay` is the published price in upay
  (PAY is USD-pegged, so a card-only price in cents × 10 000 is the same number); billing mode
  `none` still needs the approval, with no delegation. A repeat of a claim that already exists is
  answered like any repeat, with nothing to approve.
- **Auto-approve** , only on an oracle whose config sets
  `policies.allowStandingApprovals`: elsewhere an answer carrying `standing`
  is `403 FORBIDDEN` (`details.reason: standing_approvals_off`), nothing changed, and a standing
  already stored never pays, is not resumed from a 3-D Secure park, and is deleted by the housekeeping
  sweep — every submit asks the user. Where it is on, AuthHub may answer an approval with
  `standing: { perClaimUpay, totalUpay, until }` (upay strings, unix seconds) beside the delegation;
  that delegation must then have `maxAmount` EQUAL to `totalUpay` (not the claim's price), with
  `perClaimUpay` ≥ this claim's price and ≤ `totalUpay`, `totalUpay` ≤ 100 000 000 ($100), `until`
  ahead and at most 7 days out (+ 5 min skew), and an expiration of at least `until` — plus the
  issuer, oracle and facilitator checks. It approves the claim and keeps the delegation as the
  grant's standing authority (`mcp_standing`), this claim counted as spent. A grant is one app on
  this oracle, so one standing covers every source the user submits for through it. `spentUpay` is
  never reset: `totalUpay` is the auto-approve's total, a PUT must keep `totalUpay ≥ spentUpay`, and
  what is left is `totalUpay − spentUpay`.
  - A later `submit_claim` (any source of the user's) pays from it with no link when ONE batch
    lands: this request's lock row (`mcp_standing_spends`, PK `(grant, request digest)`) and the
    conditional `spentUpay` update that applies only if that lock is this attempt's, within
    `perClaimUpay`, `totalUpay` (an exhausted standing approves nothing, free claims included),
    `until`, and the grant's $20.00 day summed over the counter rows — which one-claim approvals'
    spends write too, so the two paths together never pass $20.00 a day. So concurrent submits never pass
    the total or the day's cap on this path, and a retry of the same request never spends or pays
    again: it gets the claim if it was recorded, else `APPROVAL_REQUIRED`. The answer carries
    `autoApproved: true` and `autoApproveRemaining`.
  - The price and the lock are given back only when nothing can have been reserved: no `/verify`
    left reserving, no card hold sent, no claim recorded, and a fixable refusal or one the route
    gave before `/verify` (400, 403, 409, 429). An ambiguous failure keeps both. 3-D Secure parks an
    `awaiting_3ds` approval holding the standing delegation, for the resume.
  - Revoking deletes the standing and expires every approval of the grant still holding ANY of its
    delegations (one a PUT replaced included) — never a one-claim approval's own exact-price one.
    An answer is one batch: each answer (with limits or without) claims the approval's one marker
    row first, and the flip and the standing write need that answer's own marker — so of two
    concurrent answers exactly one lands, the claim is counted once, and auto-approve is never
    turned on by an answer that did not land. Limits whose total would not cover what this
    auto-approve has already spent plus this claim are refused BEFORE anything changes: `400
    VALIDATION` (`details.reason: limits_invalid`), and the approval stays pending. The tool
    allowlist still applies. The housekeeping sweep deletes
    standing past `until` and lock rows after 2 days.
- **A daily cap per grant.** Each charge records `via: oauth:<gid>` (`api_charges.via`), and a
  grant's spends over the last 24 h stop at $20.00: `SPEND_CAP` (`details.reason: grant_daily_cap`).
  It is atomic across BOTH paths: one-claim approvals and auto-approve spend through the same
  per-grant counter (`mcp_standing_spends`) in the same batch that lets the payment happen, so
  concurrent submits on either path cannot pass $20.00 together. A restore gives its share back; a
  3-D Secure resume adds nothing. The check over all charges also runs before the payment is
  reserved.
- **Audit and timeline.** The approval's answer is an `api_audit` row with actor kind `oauth`
  (`clientId`, `clientName`); the claim's `accepted` event carries `via: oauth:<client_id>`. The
  delegation is never logged or returned, is nulled when the row is used, declined or expired, and
  the hourly housekeeping sweep deletes approvals past `expiresAt`.

### Calling the engine

Every authenticated engine call is a UCAN invocation self-signed by `did:web:<ORACLE_HOSTNAME>`,
addressed to the engine's did:web, carrying exactly ONE capability `{ with: "ixo:eval-engine",
can: <the route's action> }` — never `*`, never a delegated proof. A minted token lives 5 minutes and
is reused only for the same action until 30 s before it expires.

| Engine route | `can` | Called by |
| --- | --- | --- |
| `POST /v1/oracles/register` | `oracle/register` | the first decide, a `403 NOT_REGISTERED`, `decision-oracle engine register` |
| `GET /v1/oracles/me` | `oracle/me` | the billing cron, `/v1/admin/status`, `decision-oracle engine register` |
| `POST /v1/decisions` | `decision/decide` | the `evaluate` step |
| `GET /v1/decisions/:claimId` | `decision/read` | an `evaluate` retry |
| `POST /v1/decisions/preview` | `decision/preview` | `decision-oracle facts run --preview` |
| `POST /v1/reviews/:claimId` | `review/submit` | the `ReviewComplete` fold |
| `GET /v1/collections/:id/agreements` | `agreement/read` | `tools.agreement()` |
| `GET /v1/receipts/:cid/report` | `receipt/report` | the Decision API's report route |
| `GET /v1/sources/:oracleDid/:sourceId/company` | `company/read` | the Decision API's company sync (any non-2xx, a `404` included, is `503` and changes nothing — never read as "unlinked") |

The rubric manifest (`GET /v1/rubrics/manifest`) and an oracle's public stats are read with no
invocation.

### Cron (three triggers)

The oracle's `scheduled` handler routes by the trigger that fired (`event.cron`); each job runs in
exactly one tier, and every one of the three must be in `triggers.crons` (every env), or its tier
never runs. An unknown cron string is a `cron.unknown` warn and runs nothing — a provider's own
trigger is handled in its own `scheduled` before it delegates to the oracle's.

| Trigger | Tier | Jobs |
| --- | --- | --- |
| `* * * * *` | minute | 1, 2, 4 (the resume) and 5 below; `claims-feed` (6) before the rotation `scan`; with `API_LANE` the lane's source resume, webhook deliveries and retries, review-batch emails and backlog gauges; with `openhands`, `openhands-scans` (the coalesced `/openhands/scan` calls and the scans that found nothing, three retries each, 50 a minute) |
| `*/5 * * * *` | five | `ai-cost-backfill`; with a facilitator, `payments-reconcile` and `balance-watch`; with `openhands`, `openhands-backstop` ("The OpenHands add-on", §2) |
| `0 * * * *` | hour | `claims-replay` (6), 3 below; with `API_LANE` `retention` (housekeeping), `header-seal`, `review-billing-reminders`, `review-reminders`, `review-expiry`, `review-rooms`, `collection-links` |

Sentry cron monitors: `oracle-scan`, `oracle-claims-feed` and (with `API_LANE`) `oracle-api-lane` per minute,
`oracle-cron-five`, `oracle-cron-hour`.

1. `resumeEngineCredits` — while any collection is paused `engine-credits`, ask the engine's
   `GET /v1/oracles/me` **once** and resume them all when `billingStatus` is `ok`.
2. `resumeAdminFunds` — while any is paused `admin-unfunded`, read the collection admin's spendable
   balance, resume when it covers the largest single parked delivery per denom (the evaluation
   payment plus that decision's verdict payment: an ordinary approve's payout — the claim's own
   coins, else the collection's approval default — or a reject's rejection payment; an intent
   claim's approval is escrowed and adds nothing; no parked decision ⇒ the evaluation payment),
   send the paired notice, and **re-deliver** that collection's parked decisions (bounded per
   tick). Only once the evaluation payment alone is covered are the parked approves' claims
   read. At most 20 such collections are probed per
   tick, least recently probed first, 5 balance reads at a time; the resumes run one after another,
   and a collection still unfunded moves to the back, so the rest are reached on later minutes.
6. `claims-feed` — every minute, ONE chain-wide Blocksync read (`claims` by `submissionDate`,
   oldest first, 100 a page, at most 10 pages a tick) from the stored position minus a 2-minute
   overlap; only claims of an `active` watched collection start (the same unseen-ledger dedupe and
   idempotent instance ids as the rotation), and the position (`cron_state` `claims-feed`) moves only
   after their instances started. A tick that stops at its page cap continues from Blocksync's keyset
   cursor. The first run starts at now − 2 minutes (no backfill: a new collection's own first sweep
   reads its history). It also moves a collection's cursor (forward only) when that cursor lies
   inside the window the feed read; a NULL cursor is never moved. No active collection ⇒ no read.
   `claims-replay` (hourly) re-reads the slice that turned 24 h old (`claims-feed-replay`), so a
   claim indexed later than the overlap is still found within a day. The rotation (`scan`) keeps
   running beside it for first sweeps and as the backstop, over due rows only (`nextScanAt`,
   `CRON_COLLECTIONS_PER_TICK` in §11); a feed hit makes its collection due again, a paused one too,
   so the claims a pause held back are scanned as soon as it resumes.
3. `review-rooms` — hourly: the chain review rooms (20 a pass, least recently synced first) are
   brought in line with their reviewer grants — an expired grant's reviewer kicked, a failed invite
   retried.
4. `payer-billing-resume` (in the Decision API lane's tick, `cron/payer-billing.ts`) — while any
   per-review collection is paused `payer-credits` / `payer-delegation` and IXO's plan allows
   per-review billing: up to 20 a minute, oldest pause first, re-resolve who pays (the engine's
   company → a live delegation signed by the organisation's owner → price, cap left; for
   `payer-credits` also the payer's credits read with the delegation) and resume those that can pay
   — the paired notice, then restart (`CLAIM_WORKFLOW.get(id).restart()`, at most 20) the claims whose
   run gave up while paused (ledger `failed`, error `payer-billing:…`). Hourly, `review-billing-reminders`
   emails each delegation's payer 30 and 7 days before its `exp`, once each (`matrix_deliveries`
   `rb_expiring:<cid>:<30|7>`; to the payer's member address, else the source's contact); one
   ending later than 30 days out never gets one. The same job sends the cap warnings: a live
   delegation whose `spentUpay` has crossed 80% of its cap emails its payer once, and once more at
   95% (`rb_cap:<cid>:<80|95>`, same address rule; only rows whose current stage has no `sent` /
   `sending` row are read, 100 a run), with what is left in PAY, about how many reviews that is at
   the lowest published price, and how to raise it (`decision-oracle api review-billing enable --source <id> --cap <PAY>`, the OpenHands web app as the owner, or the Console's `CONSOLE_URL/billing/reviews`). Keyed by the CID, so a new
   delegation — a raised cap — starts its own pair.
5. `scan` — a bounded, rotating batch of active watched collections (least recently scanned first),
   a forward-only inclusive cursor per collection, one `ClaimIngest` instance per unseen claim.
   Duplicate-safe by construction: instance ids are idempotent and a claim with an existing ingest
   row is skipped. Cursors only advance after instances start, so a failed tick simply re-scans.

### Workflows

- **`ClaimIngest`** — one instance per claim. `record-queued` → `evaluate` → `act` →
  `mark-processed`, each checkpointed and retried independently. `evaluate` is pull → facts →
  decide in **one** step, because the FactSet contains the claim answers and may never be a step
  result; only the small decision summary leaves it. A retry that finds the decision row returns it
  — no second pull, no second handler run. A retry that finds the row mid-flight asks the engine's
  stored decision first. Per-review billing (active by IXO's plan; a collection registered
  with a paying source): before the pull, a claim id the facilitator cannot key (not a lowercase
  base32 CIDv1, `^b[a-z2-7]{50,100}$`) is recorded `skipped` (`error: unbillable-claim-id`, logged
  `reserve.unbillable-claim-id`) — no offer, no decide, no chain write, no pause — and who pays is
  resolved fresh (the engine's company → the organisation's live delegation → its signer still the
  chain owner → the published price → cap left; none ⇒ pause `payer-delegation`); AFTER the facts
  lane built, so a facts-lane `unusable` / `invalid_facts` never spends the cap, the price is
  reserved: `POST /offers` (`lane: "chain"`, `collectionId`, the claim; PAY only), the charge row
  `offered` with the spend on the delegation's mirror in one batch, `/verify` with the stored
  delegation wrapped in the offer's `PaymentPayload` → `reserved`. A refusal deletes the row and
  gives the spend back. By x402-ixo-pay 0.7.0's codes (never its `detail`): `insufficient_credits` /
  `spend_cap` pause `payer-credits`; `invalid_delegation` / `wrong_audience` pause
  `payer-delegation`; `expired` (an expired offer OR delegation) gets one fresh offer, and a second
  `expired` pauses `payer-delegation`; `review_billing_not_allowed` / `plan_inactive` (also an
  `/offers` 422) pause `oracle-plan`; `nonce_used` retries with a fresh offer; `terms_mismatch` and
  any unknown code page (`payments.review.verify-unexpected`) and retry. The decision settles on its
  receipt (an engine `unusable` included), a `pending_review` extends its hold (`lane` +
  `collectionId` on the extend), the fold settles, and a reserved claim that will never get a
  receipt (a re-pinned rebuild the facts lane could not build, an engine refusal, a run that gave
  up) is released (`POST /v1/reservations/release`, `x402/release`; the amount back on the deed and
  the cap). Paused for engine credits or for payment, a retried `evaluate` does nothing until the
  collection resumes — no pull, no handler, no AI spend. `act` runs only for a `decided` result (a
  `pending_review` or `unusable` row has nothing to sign). With `ANSWERS_ARCHIVE` bound, a `submitted`
  chain write is followed by an `archive-paid` step (`answer.json` says `paid: yes`; fail-soft).
- **`ReviewComplete`** — `fold` → `act` (again only for a `decided` fold). On a chain claim it first
  re-runs the reviewer gate over the stored `reviewerGrant` (a refusal is `foldError`
  `REVIEWER_GRANT_INVALID` / `REVIEWER_NOT_AUTHORIZED`, no pull, no engine call; a 503 retries).
  It re-pulls the claim (byte-identical, CID-verified), sends
  the stored answers and the grant to the engine, and the engine rebuilds its FactSet from the stored trace: **no
  new network, no new AI calls, no new charges**, and the same decision is amended rather than a
  second one created. The engine's own `REVIEWER_GRANT_INVALID` / `REVIEWER_NOT_AUTHORIZED` are
  non-retryable and land on `foldError` with its `details.reason`.

- **`OpenhandsHire`** (`openhands` on) — `verify` → `mint` → `register` → `scan`, one instance per
  collection (§2, "The OpenHands add-on"). A final "no" (an `admit` refusal, no meta-grant, a
  register gate's `402`/`403`/`404`/`409`) is a `NonRetryableError` with the reason on the
  instance; a refused broadcast or an outage retries (5 attempts, exponential from 30 s).

### Pause reasons, and who lifts them

| `pausedReason` | Set when | Lifted by |
| --- | --- | --- |
| `engine-credits` | The engine answered `402 INSUFFICIENT_CREDITS` — the **owner's** credits are exhausted. Every watched collection pauses | The billing cron, automatically, once the engine says `ok` |
| `engine-no-grant` | The engine refused with `NO_EVALUATE_GRANT` after one retry — this oracle is not (or no longer) hired for the collection | The owner grants again, then re-registers. With `openhands` on, the backstop re-mints under the poster's meta-grant and lifts it itself once the chain shows quota again |
| `rubric-unusable` | The engine's manifest could not bind the rubric, or a decide/fold answered `unusable`. Every claim would be refused the same way, so a broken catalog entry is paid for at most once | Fix the anchored rubric or `#vct`, then `POST /v1/collections/:id/resume` |
| `admin-unfunded` | The chain refused a payment: the collection's **admin account** (the deed's entity-module account) cannot pay what the `MsgEvaluateClaim` draws — the evaluation payment on every one, plus the approval payment on an ordinary approve or the rejection payment on a reject (the message fails whole unless every leg is covered). The notice names that full amount per denom | The funding cron, automatically, once the balance covers the largest single parked delivery — and it re-delivers the parked decisions |

| `payer-credits` | Per-review billing: the facilitator refused a review's reservation for want of the paying organisation's credits (`insufficient_credits`) or of its send permission (`spend_cap`) | The payer cron, automatically, once the payer's credits (read with the stored delegation) cover one review — and it restarts the claims that gave up meanwhile |
| `oracle-plan` | Per-review billing: the facilitator says IXO's plan does not let this oracle bill reviews (`review_billing_not_allowed` / `plan_inactive`), or the engine's plan no longer allows it. Pages IXO (`payments.review.oracle-plan`); the owner is NOT told — not theirs to fix | The payer cron, automatically, once the engine's plan allows per-review billing again |
| `payer-delegation` | Per-review billing: nobody can pay — no live delegation for the company, expired, its cap used up, its signer no longer the organisation's chain owner, the company `disputed`, or no published price (that one also pages: the operator's config) | The payer cron once someone pays again; a `PUT /v1/api/source/review-billing` resumes its organisation's collections at once |

An operator's own pause (`POST …/pause`) has no reason, and no cron ever lifts it.

### Notices

A review request and each pause/resume is delivered twice: a card in the collection's Matrix review
room (the primary channel, keyed on the stable `(collectionId, ownerDid)` pair) and its email twin
to the registered `contactEmail`. The room's members follow the collection's reviewer grants: every
live one is invited when a review request is sent (one `crvi:<collectionId>:<mxid>` delivery row
per person, the MXID derived from their `did:ixo`), a revoke kicks at once, and an hourly pass
(the hourly cron trigger) kicks expired grants and retries failed invites. Both lanes are **idempotent** (a durable delivery marker plus a
deterministic Matrix transaction id) and **fail-soft**: nothing here ever fails or blocks a claim,
and an unset `ORACLE_MATRIX_ACCESS_TOKEN` / `EMAIL_FROM_ADDRESS` turns its lane into a logged skip.
A pause is told once per collection, and a pause that follows a recovery is a fresh notice rather
than a suppressed one.

### Billing — who pays whom

- **You pay the model.** AI checks and the duplicate judge are your AI provider spend — Cloudflare AI Gateway credits by default (per-call USD in `ai_calls`), or OpenRouter.
- **The collection owner pays the engine.** Every decision is billed to the owner's IXO subscription,
  found through the `OWNER_DELEGATION_CAR` you posted at register. An exhausted balance is the `402`
  above: the owner tops up at the portal URL the engine names, and the cron resumes everything.
- **The chain pays you.** The collection's on-chain `Payments.evaluation` — the coin your
  `pricing.evaluation` gate insists on — is split by the chain (80% to the oracle, 10% to the network, 10% to the relayer node — the x/claims `network_fee_percentage` / `node_fee_percentage` chain params, governance-set, 10/10 on mainnet today; rounding remainder to the oracle) on every `MsgEvaluateClaim`
  you sign. That split is the oracle's income, which is why registering a collection that pays less
  than your minimum is refused.
- **The collection's admin account pays the evaluation leg**, on every evaluation, approve or reject
  — and, on the same message, the approval payment of an ordinary approve (an intent claim's comes
  from escrow) or the rejection payment of a reject. The chain fails the message whole unless the
  account covers every leg; a short one is the `admin-unfunded` pause.

### The chain write

`deliverChainEvaluation` reads everything from the decision row and the chain. It acts **only** on a
terminal `approve`/`reject`, only when `policies.autoSign` **and** `AUTO_SIGN` are both on, and only
when `ORACLE_SECP_MNEMONIC` is set (otherwise: one log line, skip). `verification_proof` is the
engine's signed receipt CID — a terminal decision without one is parked `blocked`, never delivered
with an invented proof. Delivered-once is enforced on `chainStatus`; an evaluation already on chain
records as `submitted` (ours) or `owner_submitted` (someone else's). Payment lane: an ordinary claim
echoes its coins, an intent (escrow) claim sends none.

`chainStatus` on the decision row is one of `pending` (not yet attempted), `submitted`,
`owner_submitted`, `blocked`, `skipped`, or **`held`** — before any of the guards above run, your own
`policies.act` hook gets one say (`ActContext`/`ActDecision`, "the `/runtime` subpath" above): `hold`
parks the row `held` — deliberately **not** treated as delivered by the delivered-once guard — for a
payout you compute later (a pooled batch, say). `signEvaluation` (`@ixo/decision-oracle/runtime`) is the
release pass: it re-runs every one of the lane's own guards and signs, so it is safe to call on a
row that already moved on (an already-`submitted` row just answers `"skipped"`).

---

## 13. Errors

Every HTTP error is one envelope:

```json
{ "error": { "code": "...", "message": "...", "requestId": "...", "details": {} } }
```

Inside a handler, throwing anything other than `fact.retryLater()` is an error fact, not an error
response — see §7.

A deployment whose vars fail validation (an `ENVIRONMENT` outside development / staging /
production, a missing mainnet `EVALUATION_DENOM`, …) answers every request `500 INTERNAL` with the
message `This oracle's configuration is invalid — check: <VAR>, …` — the var NAMES only; the full
validation text, which may describe a value, is in the log (`request_failed`).

### CLI exit codes

| Code | Meaning |
| --- | --- |
| `0` | Success — every declared fact answered, or the command did what it said |
| `1` | A real failure: the engine refused or was unreachable, the oracle's Decision API refused (`api …` prints the envelope's `code`, `message`, `requestId`), validation issues (`facts run`), a handler threw |
| `2` | The rubric is **unusable** — `protocol review` and `facts run` print the engine's issues; there is nothing to build against yet |
| `64` | Usage (`EX_USAGE`): a missing flag, an unknown command, or a handlers module that exports no `facts` |

The full command table lives in the package README. Commands used while writing handlers:
`protocol review <did>`, `facts scaffold <did>`, `facts run --protocol <did> --claim <file>
[--preview]`, `manifest <did>`.

`engine register [--env <name>] [--env-file <path>] [--engine-url <url>] [--network <n>] [--yes]
[--json]` registers the oracle with the decision engine explicitly (`POST /v1/oracles/register`: the
ADR-36 proof over `ORACLE_SECP_MNEMONIC`'s address plus `OWNER_DELEGATION_CAR`, minted as
`did:web:<ORACLE_HOSTNAME>` of the named wrangler env), then reads `GET /v1/oracles/me` and prints
the billing status. It reads `GET /v1/oracles/me` first too: when the engine already binds that
did:web to a DIFFERENT address it prints old → new and exits 1 unless `--yes` (an identity change —
the engine's record of the oracle moves to the new address); the same address, or not registered
yet, proceeds. The Worker registers lazily at its first decide; running this after the first deploy
makes the oracle payable at once — an oracle with no plan row of its own runs at the engine's
Standard plan terms (payable, listed). A third-party oracle needs nothing more; IXO staff move
IXO's OWN oracles to the IXO-owned plan. The engine resolves the did:web from the live host, so run
it against a deployed env. Idempotent. Exit 1 with the engine's code on a refusal (a 402 is the
owner's credits).

The Decision API lane's operator commands (`API_LANE` on) are `decision-oracle api …`: `source create
--name <n> --contact <email> --entity <did:ixo:entity:…>` (the login must control the organisation;
the webhook secret is printed once; the source stays `pending-link` until its link syncs), `source
link <sourceId> [--engine-url <url>]` (the console's "Add oracle" steps 2–3: the login signs the
`SourceCompanyLink` statement — `kid` the did:key of its
Ed25519 key, the payload canonical JSON, `payeeField` null — PUTs it to the engine's `PUT
/v1/sources/:oracleDid/:sourceId/company` as `{ with: "ixo:eval-engine", can: "company/link" }`, then
calls the oracle's `POST /v1/api/sources/:sourceId/company/sync` (`sources/company-sync`) and prints
the source, `active` or `disputed`; the entity is the source's own, read from `GET /v1/api/me`; a
source that is not `pending-link` is printed and left alone; an engine refusal prints its envelope
and exits 1 without syncing), `source delete <sourceId>` (the undo for a wrong link, and the
way out of `disputed`: the plan names the source's name, status and organisation, read from `GET
/v1/api/me`; `--yes` sends `DELETE /v1/api/sources/:sourceId` (`sources/delete`, root only — a
current controller, no proof), which deletes the source and every row off it, and prints `{
"deleted": true, "sourceId" }`; a refusal prints its envelope and exits 1), `sources`, `key create --source <id> --scopes <scope,…> [--name] [--max-spend-cents]` (the key is printed once), `keys --source <id>`, `key revoke --source <id> <prefix> [--grace-days <n>]` (the prefix, or the masked
`eo_test_<prefix>_…` / whole key — only the 8-char prefix is sent), and, for a DID in
`ORACLE_ADMIN_DIDS`, `admin sources | pause <id> [--note] | resume <id> | protocols <id> <did,…>|--none
| billing-mode <id> x402|none` (`--none` sets the empty allow-list: nothing is allowed), and
`review-billing enable | status | disable --source <id>` (below). `api
--help` prints the list. Each is one console invocation signed as the AuthHub `login`: audience the oracle's
did:web (read off its `/.well-known/did.json`), capability `{ with: "ixo:oracle/<did:web>", can: <the route's action> }` (its `x-ucan-action`) — `…/source/<id>` plus `X-Source-Id` on a source's
routes — 120 s, no proofs, so on a source the login must be its owner. Every POST/PUT/DELETE carries a fresh `Idempotency-Key`. The oracle
is `--kit`, else the project's `ORACLE_HOSTNAME` (a project whose config fails to load is reported
as that failure); `--network` defaults to the project's `IXO_NETWORK`, else devnet. `--json`
prints the answer as the API gave it and nothing else on stdout; a source link or delete, a revoke and
every `admin` change are dry runs without `--yes` (exit 0), their plan on stderr under `--json` — so a
`--json` dry run prints nothing on stdout.

`api review-billing …` is the Console's "Pay for reviews" (the review-billing routes above).
`enable --source <id> [--cap <PAY>]` refuses, before anything is signed and exit 1: an oracle whose
public `/v1/info` `reviewBilling.mode` is not `x402` (or that publishes no `pricePerReview`), a
`--cap` below one review's price, a source whose `billingMode` is not `x402`, and a login that is not
the CURRENT chain owner of the source's `companyEntityDid` (`GET /v1/api/me`, then Blocksync's
`entity.owner`, compared as `did:ixo:<owner>`). Then — a dry run without `--yes`, the plan naming
the payer, the audience, `nb.oracles`, the price, the cap and the expiry — the login's Ed25519 key
(`edSigningMnemonic`, the key its IID publishes) signs x402-ixo-pay's `buildPaymentDelegation`:
issuer the login's did:ixo, audience `did:web:<NETWORKS[network].facilitator host>`, one `{ can:
"billing/pay", with: "billing:decision-api", nb: { maxAmount, denom: "upay", oracles:
[<the oracle's /v1/info serviceDid>] } }`, `exp` now + 100 years, no `nbf`, no proofs, no facts.
`maxAmount` is `--cap` × 1 000 000 upay (default `1000` PAY, at most 6 decimals) — cumulative over
the delegation's whole life (the facilitator never refunds or resets it); `enable` again with a
higher `--cap` stores a new delegation that replaces the old one and starts at 0 spent. The token
is PUT as `{ delegation }` (`review-billing/set`), then deposited in the network's UCAN store
(`POST /api/delegations`, `{ can: "store/add", with: "ixo:ucan-store" }`, fail-soft: a miss is a
warning) — the store revokes by CID only what it holds, and the oracle never returns the token —
and the command prints `GET` (`review-billing/read`), as `status --source <id>` does on its own (a
`404` there is "none held", exit 0). `disable --source <id>` reads that state, then (with `--yes`)
`DELETE`s it (`review-billing/delete`) and, when the login is its `payer`, revokes its CID in the
UCAN store (`POST /api/revocations` `{ cid, reason }`, `{ can: "store/revoke" }` — permanent). A
store without the copy (`404`, e.g. one signed in the Console), another signer, or a store error
is printed as "not revoked" with what a full stop takes; the exit is still 0, since the oracle
no longer holds it and `nb.oracles` lets no other oracle charge it. `--json` prints the GET body
(enable, status) or `{ deleted, message, revoked, delegationCid }` (disable).

An organisation's own commands, signed as the same `login`: `org create --name <name> [--logo <https url>] [--network <n>] [--yes]` is the Decision Console's "Create organisation" — the profile
JSON-LD (`orgName`/`name`/`brand` = the name, `image`/`logo` = the logo) stored on the IXO Vault
under the login's key (else the IXO IPFS gateway) and anchored as `{id}#pro` (`Settings`, proof = the CIDv1 raw sha2-256 of its
bytes) inside ONE `MsgCreateEntity` `{ entityType: "dao", controller: [login DID], ownerDid,
ownerAddress }` on the network's relayer node; idempotent by name (a `dao` whose IID controllers include the login DID — the console's org
list, owned or co-controlled — whose `#pro` `name` equals `--name`, trimmed, is printed with `created: false` and nothing is
signed; an unreadable profile fails the command rather than risk a second organisation); after a
create it polls Blocksync up to ~30 s for the new entity. Stdout is one JSON line `{ entityDid,
created }`; the plan, the tx hash and progress go to stderr; a dry run without `--yes`. `org balance
--entity <did> [--network <n>]` prints `{ adminAddress, balances: [{ denom, amount }] }`: the
entity's `admin` account from Blocksync `accounts`, every denom from the chain's
`/cosmos/bank/v1beta1/balances/<address>` (all pages, `NETWORKS[network].rest`), amounts as the
chain's base-unit strings. `logout [--network <n>]` deletes `~/.ixo/publish-session-<network>.json`
and prints `{ removed, path }` (never the file's contents; `removed: false` when there was none).
