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

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";
ExportKindWhat it isUse it when
defineOraclefunctionThe whole runtime surface: validates + registers your config, returns everything the Workers runtime needsOnce, in src/index.ts
OracleConfigtypeWhat you pass to defineOracle — name, listing, price, policies, facts, archiveTyping a config you build elsewhere
OracleWorkertypeWhat defineOracle returns: fetch, scheduled, ClaimIngest, ReviewComplete, OpenhandsHire, SequenceManagerDORarely — the three re-exports are structural
OracleEnvtypeOracleBindings & OracleVars — the runtime contract your wrangler.jsonc must satisfyTyping your own helpers that take env
OracleBindingstypeEvery binding by name (DB, CACHE_KV, …)Extending it with your own bindings: OracleEnv & { MY_KV: KVNamespace }
OracleVarstypeEvery var and secret, as wrangler hands them over (strings, undefined when unset)Same
ConfigtypeThe validated config — the output of the Zod schema, so the eight per-network URLs are string, never string | undefinedTyping a function that takes tools.config
NETWORKSconstRecord<IxoNetwork, NetworkDefaults> — the one per-network mapReading a network's engine/blocksync/portal host without hardcoding it
networkDefaultsfunction(network: IxoNetwork) => NetworkDefaultsSame, by name
IxoNetworktype"devnet" | "testnet" | "mainnet"Typing a network switch
ActPolicytype(ctx: ActContext) => Promise<ActDecision> | ActDecision — the policies.act hook's own signature (src/act.ts)Typing a policies.act function you write
ActContexttypeEverything 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
ActDecisiontype{ mode: "sign", amount?, reason? } | { mode: "hold", reason? } | { mode: "skip", reason? } — what the hook tells the lane to do nextReturning from your act hook
ActFactstype{ ext, ai } — the frozen facts by name, as sent to the engine, never answer valuesTyping ActContext["facts"] in a helper
ArchiveHookstype{ describe?, answer? } — the archive option's own shape (§2)Typing the hooks you write
ArchiveAsk / ArchiveDoctypeWhat archive.describe is handed: the ask's collection and documentsReading #done, #pro inside describe
ArchiveAbout / ArchiveKindtypeabout.json — the view describe returns part ofReturning from describe
ArchiveAnswerViewtype{ text?, place? } — what archive.answer returnsReturning from answer
OpenhandsOptionstype{ admit?, maxCustomAmount?, onQuota? } — the openhands option's own shape (§2, "The OpenHands add-on")Typing the hooks you write
OpenhandsAsk / OpenhandsQuotatypeWhat admit and onQuota are handedSame

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

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";
ExportKindWhat it isUse it when
defineFactsfunctionNormalizes your handler table into a FactsConfigOnce, in src/facts/index.ts
groupfunctiongroup(["a", "b"], handler) — ONE handler answering several declared sources from one callFour criteria judged by one model call
factobjectfact.ok, fact.ai.ok, fact.error, fact.retryLater — what a handler returns when a bare true isn't enoughIn every handler that says why
RetryLaterclassThe tagged error fact.retryLater() buildserr instanceof RetryLater in your own wrapper
readAnswerfunction(answers, "$q.row[*].cell") => unknown — the $path reader the package itself uses, the engine binder's grammarReading an answer the rubric's send map did not name
rootQuestionNamefunction("$deliveryPhoto.0") => "deliveryPhoto" — the top-level question of a $refMatching a $ref against attachment.question
ExtHandler / AiHandler / UniqueHandler / FrequencyHandler / GroupHandlertypesThe five handler signaturesTyping every handler file
ExtInput / AiInput / UniqueInput / FrequencyInput / GroupInput / FactInputBasetypesWhat each handler is handedDestructuring in a helper
ExtResult / AiResult / GroupResult / FactErrorDrafttypesWhat each may hand backA helper that builds a return value
FactGrouptypeWhat group(...) builds — an entry in defineFacts({ groups })A helper that assembles groups
FactTools / ExtTools / AiToolstypesThe injected machinery; the lane-typed aliases fix what stock() returnsA helper that takes tools
FactsConfig / FactsDeclarationtypesdefineFacts's output / inputBuilding a table programmatically
HttpJsonInit / HttpJsonResulttypestools.http.json's options and resultWrapping http.json
AiSendEntrytypeOne model input: { kind: "text", key, value } or { kind: "attachment", key, attachment }Re-slicing inputs before ai.judge
ChainTools / LinkedResourcetypestools.chain and the shape its blocksync read answers withA helper that takes a chain read's result
readLinkedDocfunction(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) : …
linkedDocUrlfunction(serviceEndpoint, ipfsGateway?) => string | null — the URL readLinkedDoc would fetch, or null for an address that is no documentLinking to a document without reading it
LinkedDocRef / LinkedDocRead / ReadLinkedDocOptionstypesreadLinkedDoc's input, result ({ ok: true, url, bytes, json, verified } or { ok: false, url, reason, retryable, status?, message }) and optionsWrapping it
ClaimIntenttypeWhat tools.chain.claimIntent(...) answers — the agent's filed commitment, { time, transactionHash }, or null when it filed noneA helper that measures delivery from the intent, or a test that builds one
withinRadiushandlerReady-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). No point from any source is an error (the rubric's onError), never falseRegistering a "delivered nearby" source: ext: { deliveredNearby: withinRadius }
locatedhandlerReady-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 questionA rubric that only needs "has a location"
beforeAfterhandlerReady-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 falseA before/after delivery-photo gate
haversineMetersfunction(a, b) => metres — great-circle distance between two { lat, lon } pointsBuilding your own distance check on top of photoFactsFromJpeg
photoFactsFromJpegfunction(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 EXIFReading a photo's own coordinates/timestamp outside the three handlers above
PhotoFactstype{ 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):

GradeSource
cameraThe photo's own EXIF (GPS, DateTimeOriginal)
browser-reportedEXIF 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
claimThe 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.

{ "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

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";
ExportKindWhat it isUse it when
runFactsfunctionThe same runner production uses (minus the fact cache and D1), then the same validateFactsEvery handler test
fakeToolsfunctionFactTools with every lane answered from a literal, plus a logs arrayEvery handler test
httpResponsefunctionhttpResponse(503, body) — a canned answer whose status is the pointTesting a failure path
sampleClaimfunctionA PulledClaim whose body is your answersEvery handler test
sampleManifestfunctionThe engine's work order for exactly the names you listEvery handler test
sampleCtxfunctionThe frozen clock read + collection windowPinning a date rule
ProducedFactstype{ ext, ai, unique?, frequency? } — what runFacts returns, plus issuesNaming the result
FactIssuetype{ code, path, message } — one coverage failureAsserting on issues
FakeTools / FakeToolsOverrides / FakeHttpRoute / HttpResponseEnvelope / LogRecordtypesfakeTools's shape and optionsA shared test helper
ChainCall / ClaimIntentQuery / FakeChaintypesWhat chainCalls records, the query a chain.claimIntent function is handed ({ agentAddress, collectionId, before, after? }), and the chain override's own shapeAsserting on the chain reads a handler made
RunFactsInput / SampleClaimOptions / SampleManifestDecltypesThe three builders' optionsA shared test helper

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

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

ExportKindWhat it isUse it when
signEvaluationfunction(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 signatureYour own release trigger for a pooled payout — a batch job, an operator action, a cron of your own
scanCollectionfunction(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 pausedReacting to your own webhook the instant a claim is submitted, instead of waiting for the collection's next turn in the tick's rotation
registerCollectionfunction(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, unchangedYour 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
broadcastAsOraclefunction(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 builtComposing your own MsgExec around kit-issued messages
rehireOpenhandsfunction(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 verdictsopenhands on, and your own act policy counts verdicts and sees an ask running low before the backstop's next visit
AppErrorclassThe kit's typed error — .notFound(), .forbidden(), .conflict(), .unavailable(), .internal(), … (§13)Catching/rethrowing what the four functions above throw, in your own error handling
ChainWriteOutcometype"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 actsBranching on the result, or reading chainStatus off GET /v1/claims/:claimId
RegisterInputtype{ 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
WatchedCollectiontypeThe watched_collections row registerCollection and scanCollection read and writeTyping 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:

ExportFromWhat it's for
getIxoSdk / getQueryClient / getOracleSigner / OracleSignerlib/ixo/chain.tsThe lazily-loaded chain SDK, the memoized RPC query client, and the oracle account signer broadcastAsOracle itself calls
loadConfig / Configconfig.tsThe same validated config every lane reads
getDb / Databasedb/client.tsThe Drizzle handle over the DB binding
watchedCollections / decisions / DecisionRowdb/schema.tsThe two tables (WatchedCollection is above), for a query of your own alongside these functions
getCollection / BlocksyncDeps / CollectionNodelib/ixo/blocksyncThe 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):

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:

FieldTypeWhat it is
claimIdstring
collectionIdstring
evaluationStatus1 | 2The chain's EvaluationStatus the lane is about to sign — 1 approve, 2 reject
verdictstring | nullThe engine's verdict string
decisionDecisionThe engine's decision object verbatim (decisions.decision)
receiptCidstringThe engine's signed receipt CID
factsActFactsThe facts the decision was made on, frozen on the decision row (decisions.facts) — ext.<name> / ai.<name> only, never answer values
paymentClaimPayment | undefinedThe claim's payment plane (lib/ixo/payment.ts): its own coins, and whether an intent governs
defaultAmount{ denom, amount } | undefinedWhat 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
claimClaimNodeThe on-chain claim as Blocksync returns it (lib/ixo/blocksync getClaimById)
envOracleEnv
loggerLogger

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:

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.

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)

// 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

FieldTypeValidationMeaning / default
namestringnon-emptyThe marketplace label on GET /v1/info, and the memo on every MsgEvaluateClaim this oracle signs. Also the default EMAIL_FROM_NAME
descriptionstringany stringThe listing blurb
supportedProtocolsstring[]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.denomstringnon-emptyThe denom a collection's on-chain Payments.evaluation must be in
pricing.evaluation.minAmountstring/^\d+$/ — an integer base-unit stringThe 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.byProtocolRecord<protocolDid, { denom, minAmount }>optional; each entry the same shape/validation as pricing.evaluationPer-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.amountMicroUsdnumberoptional (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
payoutsRecord<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.byProtocolRecord<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) | | 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):

FieldTypeMeaning / 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:

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 })

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

KeyTypeUnlisted / absent means
extRecord<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")
aiRecord<string, AiHandler>The stock judge: the configured AI provider (AI_PROVIDER) with the rubric's own instructions over the declared inputs
ai["*"]AiHandlerThe one wildcard — it catches every check the table does not name. A named handler still wins
groupsFactGroup[]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)
uniqueUniqueHandlerThe 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
frequencyFrequencyHandlerThe 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

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)

FieldTypeWhat it is
claimPulledClaimclaimId, 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)
answersRecord<string, unknown>claim.body — the credentialSubject, verbatim
ctxCtxFactsThe 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
settingsFactSettings{ allowAiChecks, allowImageChecks } — this build's policy, from defineOracle
manifestManifestThe whole work order (§8): fields, derived, ctx, usedBy, every ext/ai declaration, unique/frequency, sizeProbes

ExtHandler — one external source

ExtInput adds:

FieldTypeWhat it is
namestringThe <name> in ext.<name> — the work-order key this handler answers
expectedManifestExtThe declaration for this name: expect ("boolean" | "score"), onError, endpoint, audience, capability?, send, timeoutMs?, usedBy
sendRecord<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
unresolvedstring[]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(...).

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:

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:

FieldTypeWhat it is
inputsAiSendEntry[]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:

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:

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:

FieldTypeWhat it is
namesstring[]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)
sourcesRecord<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(...)).

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:

FieldTypeDefault when the manifest omits it
idstring— (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"]
attachmentsbooleantrue
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.

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:

FieldTypeDefault
idstringthe manifest's ruleId
maxnumbernone — 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.

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

ReturnFrozen 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 thrownthe 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

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
HelperProducesDefaults
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 draftThe runner stamps the onError
fact.retryLaterA 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.

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

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

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

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

tools.attachments

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

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.

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

// "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.
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 AppErrors, 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.

FieldTypeWhat it is
rubricIdstringThe 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
extRecord<string, ManifestExt>Every declared external source
aiRecord<string, ManifestAi>Every declared AI check
hasAiChecksbooleanShorthand for "this rubric spends on a model"
ctxstring[]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
uniqueManifestUnique | null (optional)ruleId plus the full post-default rule: by?, countPrior?, attachments?, ai?, thresholds?
frequencyManifestFrequency | 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.

ShapeFieldsNotes
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
UniqueFactruleId, digest, scoreBps: 0..10000, band: "pass" | "review" | "reject"Loose — everything else you freeze (winner, candidates, aiOutcome, aiPairs, …) rides into the engine's trace
FrequencyFactruleId, count: int, max: int, per?, windowStartISO?, windowEndISO?, breach: boolean, unresolved?: trueLoose. count/max compose the engine's reason text; breach/unresolved are its branches
AttachmentFactquestion, name, mediaType, cid: string | null, size: number | nullThe package builds these; bytes never leave Matrix
Transcriptrequest { 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.

CodeRaised whenPath
FACT_MISSINGA 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_EXTRAA 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 rulesame
FACT_KINDexpect: "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 manifestsame
FACT_ON_ERRORAn error fact carries an onError other than the rubric's (only reachable from a whole-producer — the runner stamps it)same
FACT_SHAPEThe 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)

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?)

OverrideTypeDefault
httpRecord<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
stockExtFact | 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?) => AiFactfact.ai.ok(true)
attachmentsRecord<string, Uint8Array>{}, keyed by the attachment's url. bytes → { ok: false, reason: "unfetchable" } when absent; size → the byte length or null
c2paProvenance | nullnull
agreementAgreement | nullnull
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
envRecord<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?)

OptionDefault
claimId"claim1"
collectionId"1"
agentAddressnull (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.

KeyDefault 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 / frequencykey 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:

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:

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:

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

BindingTypeWhat it backs
DBD1DatabaseEvery 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_KVKVNamespaceCaches 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_LIMITERRateLimitThe coarse per-IP guard on the whole API. limit/period live in wrangler.jsonc, not in code
EMAILSendEmail (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_LIMITERRateLimit (optional)The Decision API lane's per-key / per-source limit — its own namespace, read only with API_LANE on
API_UPLOADSR2Bucket (optional)The Decision API lane's uploads. Absent ⇒ PUT /v1/api/uploads answers 501 UPLOADS_DISABLED; URL attachments still work
ANSWERS_ARCHIVER2Bucket (optional)The answer archive (guide §12, "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
METRICSAnalyticsEngineDataset (optional)Workers Analytics Engine: request, upstream, cron and cache metrics you can chart as p50/p95/p99 and rates (guide §12). Absent ⇒ every metric is a msg = "metric" log line only
CLAIM_WORKFLOWWorkflowClaimIngest — one instance per claim
REVIEW_WORKFLOWWorkflowReviewComplete — the review fold
HIRE_WORKFLOWWorkflow (optional)OpenhandsHire — one instance per hired OpenHands collection. Required with openhands on
OPENHANDS_RATE_LIMITERRateLimit (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_MANAGERDurableObjectNamespaceThe 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.

VarWhereDefaultMeaning
ENVIRONMENTvarsdevelopmentdevelopment | staging | production. development is the only one where an external source may be plain http
LOG_LEVELvarsinfodebug | info | warn | error
LOG_PRETTY.dev.vars onlyfalseColorized local logs. Deployed envs must emit JSON — that is what Workers Logs ingests
SERVICE_NAME / SERVICE_VERSIONvarseval-oracle / 0.0.0Log and /health identity
CORS_ORIGINSvars*Comma-separated origins, or *
CRON_COLLECTIONS_PER_TICKvars25Watched 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_NETWORKvarsdevnetdevnet | testnet | mainnet — supplies eight URLs below
EVALUATION_DENOM / EVALUATION_MIN_AMOUNTvarsunset — required when IXO_NETWORK is mainnetThe 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_URLvarsper networkOverride only for a self-hosted indexer
CHAIN_RPC_URLvarsper networkThe SDK query client and the chain write
MATRIX_HOMESERVER_URLvarsper networkFallback home server when an entity declares none
UCAN_STORE_URLvarsper networkWhere an owner→oracle delegation is fetched from
VFS_URLvarsper networkThe IXO Virtual Filesystem — the first claim-body stores: the collection's claims lane (read under your Evaluate grant), then public copies
IPFS_GATEWAY_URLvarsper networkThe IPFS gateway a claim body is read from when neither the VFS nor Matrix has it
PORTAL_BASE_URLvarsper networkDeep links in notices. No trailing slash
EVAL_ENGINE_HOSTNAMEvarsper networkThe 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.varsderivedLocal harnesses only — the bot URL is normally derived as https://claim.bot.<homeserver domain>
IXO_CLIENT_SEQUENCE_MIN_DELAY_MSvars1000Read by the sequence keeper off env: the minimum gap between two sequence allocations
ORACLE_HOSTNAMEvarsrequiredYour 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_DIDvarsrequireddid:ixo:<address> — the runtime chain identity
ORACLE_ADDRESSvarsrequiredMust be the ORACLE_SECP_MNEMONIC wallet's address — asserted at first use, fails loud
ORACLE_MATRIX_USER_IDvarsrequiredThe MXID derived from ORACLE_DID (cross-checked, never re-derived)
ORACLE_ENTITY_DIDvarsunsetThe 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_MODELvarsopenai/gpt-6-lunaThe pinned model for AI checks (any provider — the gateway's compat route takes the same id)
AI_PROVIDERvarscloudflarecloudflare: 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_EFFORTvarslowlow / 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_IDvarsunsetThe gateway the cloudflare provider routes through. Unset (or no AI binding) ⇒ an AI fact throws AppError.internal at use
AI_AUDIO_VIDEOvarsfalseSend 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_CONFIDENCEvars60A verdict scoring below this is the unreachable path (an error fact), not a trusted answer
AI_IMAGE_DETAILvarshighlow / 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_CHECKvarsfalseThe provenance gate in the stock AI lane. Needs policies.allowImageChecks on too
AUTO_SIGNvarstrueSign MsgEvaluateClaim unattended. Needs policies.autoSign on too
API_LANEvarsfalseMount the Decision API router (/v1/api). Off ⇒ the lane is dormant and the chain lane runs exactly as before
API_PAYMENTSvarsfalseThe x402 gate. Off ⇒ every new submit answers 503 SOURCE_PAUSED { reason: payments-disabled } — nothing is accepted unpaid
API_PRICE_USD_CENTSvarsunsetThe 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_URLvarsunsetThe x402 facilitator (verify + settle). No trailing slash. Required when API_PAYMENTS is on — config refuses to load without it
X402_NETWORKvarsunsetThe chain every 402 names, CAIP-2: cosmos:<chain-id> (cosmos:devnet-1, cosmos:pandora-8, cosmos:ixo-5)
EARNINGS_ADDRESSvarsunsetWhere 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_WALLETvarsunsetYour Base wallet (0x…) for USDC on Base. Unset ⇒ that way to pay is not offered
BILLING_ENGINE_URLvarsunsetCharge lookups and the spend figures on /v1/api/me. No trailing slash
CONSOLE_URLvarsunsetThe 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_URLvarsper 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_DIDSvarsunsetComma-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_DIDSvarsunsetComma-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_MNEMONICsecret—The did:web key: sha256(mnemonic) → Ed25519 seed. Signs every UCAN invocation you mint and backs /.well-known/did.json
ORACLE_SECP_MNEMONICsecret—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_CARsecret—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_TOKENsecret—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_KEYsecret—Only with AI_PROVIDER=openrouter: required when a rubric asks for an AI fact (or a unique rule with its judge on)
UNIQUE_HMAC_KEYsecret—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_KEYsecret—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_DSNsecret—Unset ⇒ error reporting is a no-op
SENTRY_RELEASEstamped by your deploy—Never in wrangler.jsonc
SENTRY_TRACES_SAMPLE_RATEvars0.1Share of requests Sentry traces, 0–1
SENTRY_ENVIRONMENTvarsENVIRONMENTThe Sentry environment tag — set it to tell apart two deploys that share an ENVIRONMENT (testnet, mainnet)
OPS_ALERT_WEBHOOK_URLsecret—A Slack-compatible incoming webhook for ops alerts — one message per condition per 30 minutes (guide §12). Unset ⇒ alerts reach Sentry only
EMAIL_FROM_ADDRESSsecret—Unset ⇒ the whole email lane no-ops (the same "unset ⇒ off" contract)
EMAIL_FROM_NAMEsecretthe oracle's nameDisplay 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).

IdentityWhat exists, and whereWho creates itWhy it matters
did:web:<ORACLE_HOSTNAME>/.well-known/did.json, served by this worker from the SERVICE_ED25519_MNEMONIC keyyou, by deploying to that hostthe 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 chainoracle 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_DIDthe oracle entity on chain, with #eval, #orz, #pro, #dmn (domain card), #acard (Agent Card) and the protocol edgesoracle 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'show 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_IDthe oracle bot's Matrix account on the network's homeserveroracle init, step matrix — it also writes ORACLE_MATRIX_ACCESS_TOKEN into .dev.varsthe 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.

devnettestnetmainnet
engine hostdecision-engine.devnet.ixo.earthdecision-engine.testnet.ixo.earthdecision-engine.ixo.earth
blocksyncdevnet-blocksync-graphql.ixo.earth/graphqltestnet-blocksync-graphql.ixo.earth/graphqlblocksync-graphql.ixo.earth/graphql
rpcdevnet.ixo.earth/rpc/testnet.ixo.earth/rpc/impacthub.ixo.world/rpc/
restdevnet.ixo.earth/resttestnet.ixo.earth/restimpacthub.ixo.world/rest
matrixdevmx.ixo.earthtestmx.ixo.earthmx.ixo.earth
ucan storedevnet.store.ucan.ixo.earthtestnet.store.ucan.ixo.earthstore.ucan.ixo.earth
vfsdevnet.vfs.ixo.earthtestnet.vfs.ixo.earthvfs.ixo.earth
ipfs gatewayipfs.gateway.ixo.worldipfs.gateway.ixo.worldipfs.gateway.ixo.world
portaldev.portal.qi.spacetest.portal.qi.spaceportal.qi.space
authHubdev.auth.ixo.earthtest.auth.ixo.earthauth.ixo.earth
facilitatorfacilitator.devnet.ixo.earthfacilitator.testnet.ixo.earthfacilitator.ixo.earth
relayer nodethe 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.

BehaviourConfigEnvOff means
Sign MsgEvaluateClaim unattendedpolicies.autoSignAUTO_SIGNThe decision is still recorded and receipted; submitting it is a human's job
Run AI checks at allpolicies.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 Credentialspolicies.allowImageChecksC2PA_IMAGE_CHECKThe 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.

RouteAuthGate
GET /healthpublic, rate-limit exempt—
GET /health/readypublic, 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.jsonpublic, rate-limit exemptNeeds SERVICE_ED25519_MNEMONIC
GET /.well-known/oauth-protected-resource/v1/mcppublic, rate-limit exempt, Access-Control-Allow-Origin: *, cache-control: public, max-age=300RFC 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/infopublic (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/:collectionIdpublic, OPENHANDS_RATE_LIMITER (per caller and per collection; unbound outside development ⇒ 503), openhands on onlyStarts 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/:collectionIdpublic, OPENHANDS_RATE_LIMITER, openhands on onlyScans 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/registerUCAN collections/registerThe 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/collectionsUCAN collections/readThe rows the caller owns or enrolled
POST /v1/collections/:id/pauseUCAN 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/resumeUCAN 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/scanUCAN collections/scan + read gateRuns 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/settingsUCAN 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/settingsUCAN 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 gateSummary list, newest first, keyset-paginated (limit default 100, max 500)
POST /v1/collections/:id/reviewersUCAN 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/reviewersUCAN 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/revokeUCAN collections/reviewers-revoke (never *) + a current controllerStamps 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/:claimIdUCAN 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/bodyUCAN 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 trouble204; 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/:claimIdUCAN reviews/read + read gate, or a live reviewer of the claim's collectionThe 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/statusUCAN 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/relinkUCAN 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-noteUCAN 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/redeliverUCAN 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/:approvalIdUCAN oauth/approval/read, invoker AuthHub's did:web only (403 otherwise; * is never accepted here); no X-Auth-Type neededWhat 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/:approvalIdUCAN 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/:grantIdUCAN 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/:grantIdUCAN oauth/standing/set, same403 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/:grantIdUCAN 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/:claimIdUCAN 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 409s

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.

RefusalStatusWhen
ATTESTATION_INVALID400Not a compact JWS / not a ClaimReviewAttestation / payload is not canonical JSON / the signature does not verify
ATTESTATION_KEY_UNKNOWN400The 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_MISMATCH403issuer is not the invoking principal
ATTESTATION_MISMATCH400oracleDid / claimId / answeredAgainst / answers do not bind this review
ATTESTATION_UNRESOLVABLE503The resolver could not REACH its source (Blocksync down, host unreachable). Transient — retry
ATTESTATION_REQUIRED400No attestation on the request — refused before any lookup or write
REVIEWER_GRANT_INVALID400No reviewerGrant (details.reason: "absent", refused before any lookup or write), or gate checks 1–2 fail
REVIEWER_NOT_AUTHORIZED403Gate 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
UNAVAILABLE503Blocksync, 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:

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:

ToolArgumentsCallsReturns
submit_claimprotocolDid?, 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_filesourceId? (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_decisionclaimIdGET /v1/api/claims/:claimIdthe record
list_protocolsprotocolDid?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_receiptclaimId, 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 cids 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 PUTs 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 routecanCalled by
POST /v1/oracles/registeroracle/registerthe first decide, a 403 NOT_REGISTERED, decision-oracle engine register
GET /v1/oracles/meoracle/methe billing cron, /v1/admin/status, decision-oracle engine register
POST /v1/decisionsdecision/decidethe evaluate step
GET /v1/decisions/:claimIddecision/readan evaluate retry
POST /v1/decisions/previewdecision/previewdecision-oracle facts run --preview
POST /v1/reviews/:claimIdreview/submitthe ReviewComplete fold
GET /v1/collections/:id/agreementsagreement/readtools.agreement()
GET /v1/receipts/:cid/reportreceipt/reportthe Decision API's report route
GET /v1/sources/:oracleDid/:sourceId/companycompany/readthe 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.

TriggerTierJobs
* * * * *minute1, 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 * * * *fiveai-cost-backfill; with a facilitator, payments-reconcile and balance-watch; with openhands, openhands-backstop ("The OpenHands add-on", §2)
0 * * * *hourclaims-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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

pausedReasonSet whenLifted by
engine-creditsThe engine answered 402 INSUFFICIENT_CREDITS — the owner's credits are exhausted. Every watched collection pausesThe billing cron, automatically, once the engine says ok
engine-no-grantThe engine refused with NO_EVALUATE_GRANT after one retry — this oracle is not (or no longer) hired for the collectionThe 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-unusableThe 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 onceFix the anchored rubric or #vct, then POST /v1/collections/:id/resume
admin-unfundedThe 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 denomThe 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:

{ "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

CodeMeaning
0Success — every declared fact answered, or the command did what it said
1A 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
2The rubric is unusable — protocol review and facts run print the engine's issues; there is nothing to build against yet
64Usage (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) DELETEs 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).