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";| Export | Kind | What it is | Use it when |
|---|---|---|---|
defineOracle | function | The whole runtime surface: validates + registers your config, returns everything the Workers runtime needs | Once, in src/index.ts |
OracleConfig | type | What you pass to defineOracle — name, listing, price, policies, facts, archive | Typing a config you build elsewhere |
OracleWorker | type | What defineOracle returns: fetch, scheduled, ClaimIngest, ReviewComplete, OpenhandsHire, SequenceManagerDO | Rarely — the three re-exports are structural |
OracleEnv | type | OracleBindings & OracleVars — the runtime contract your wrangler.jsonc must satisfy | Typing your own helpers that take env |
OracleBindings | type | Every binding by name (DB, CACHE_KV, …) | Extending it with your own bindings: OracleEnv & { MY_KV: KVNamespace } |
OracleVars | type | Every var and secret, as wrangler hands them over (strings, undefined when unset) | Same |
Config | type | The validated config — the output of the Zod schema, so the eight per-network URLs are string, never string | undefined | Typing a function that takes tools.config |
NETWORKS | const | Record<IxoNetwork, NetworkDefaults> — the one per-network map | Reading a network's engine/blocksync/portal host without hardcoding it |
networkDefaults | function | (network: IxoNetwork) => NetworkDefaults | Same, by name |
IxoNetwork | type | "devnet" | "testnet" | "mainnet" | Typing a network switch |
ActPolicy | type | (ctx: ActContext) => Promise<ActDecision> | ActDecision — the policies.act hook's own signature (src/act.ts) | Typing a policies.act function you write |
ActContext | type | Everything the act lane knows about the claim it is about to sign for — every field is documented where policies.act is (below) | Destructuring inside your act hook |
ActDecision | type | { mode: "sign", amount?, reason? } | { mode: "hold", reason? } | { mode: "skip", reason? } — what the hook tells the lane to do next | Returning from your act hook |
ActFacts | type | { ext, ai } — the frozen facts by name, as sent to the engine, never answer values | Typing ActContext["facts"] in a helper |
ArchiveHooks | type | { describe?, answer? } — the archive option's own shape (§2) | Typing the hooks you write |
ArchiveAsk / ArchiveDoc | type | What archive.describe is handed: the ask's collection and documents | Reading #done, #pro inside describe |
ArchiveAbout / ArchiveKind | type | about.json — the view describe returns part of | Returning from describe |
ArchiveAnswerView | type | { text?, place? } — what archive.answer returns | Returning from answer |
OpenhandsOptions | type | { admit?, maxCustomAmount?, onQuota? } — the openhands option's own shape (§2, "The OpenHands add-on") | Typing the hooks you write |
OpenhandsAsk / OpenhandsQuota | type | What admit and onQuota are handed | Same |
@ixo/decision-oracle/facts — node-safe, what a handler imports
import {
beforeAfter,
defineFacts,
fact,
group,
haversineMeters,
located,
photoFactsFromJpeg,
linkedDocUrl,
readAnswer,
readLinkedDoc,
RetryLater,
rootQuestionName,
withinRadius,
} from "@ixo/decision-oracle/facts";
import type {
AiHandler,
AiInput,
AiResult,
AiSendEntry,
AiTools,
ChainTools,
ClaimIntent,
ExtHandler,
ExtInput,
ExtResult,
ExtTools,
FactErrorDraft,
FactGroup,
FactInputBase,
FactsConfig,
FactsDeclaration,
FactTools,
FrequencyHandler,
FrequencyInput,
GroupHandler,
GroupInput,
GroupResult,
HttpJsonInit,
HttpJsonResult,
LinkedDocRead,
LinkedDocRef,
LinkedResource,
PhotoFacts,
ReadLinkedDocOptions,
UniqueHandler,
UniqueInput,
} from "@ixo/decision-oracle/facts";| Export | Kind | What it is | Use it when |
|---|---|---|---|
defineFacts | function | Normalizes your handler table into a FactsConfig | Once, in src/facts/index.ts |
group | function | group(["a", "b"], handler) — ONE handler answering several declared sources from one call | Four criteria judged by one model call |
fact | object | fact.ok, fact.ai.ok, fact.error, fact.retryLater — what a handler returns when a bare true isn't enough | In every handler that says why |
RetryLater | class | The tagged error fact.retryLater() builds | err instanceof RetryLater in your own wrapper |
readAnswer | function | (answers, "$q.row[*].cell") => unknown — the $path reader the package itself uses, the engine binder's grammar | Reading an answer the rubric's send map did not name |
rootQuestionName | function | ("$deliveryPhoto.0") => "deliveryPhoto" — the top-level question of a $ref | Matching a $ref against attachment.question |
ExtHandler / AiHandler / UniqueHandler / FrequencyHandler / GroupHandler | types | The five handler signatures | Typing every handler file |
ExtInput / AiInput / UniqueInput / FrequencyInput / GroupInput / FactInputBase | types | What each handler is handed | Destructuring in a helper |
ExtResult / AiResult / GroupResult / FactErrorDraft | types | What each may hand back | A helper that builds a return value |
FactGroup | type | What group(...) builds — an entry in defineFacts({ groups }) | A helper that assembles groups |
FactTools / ExtTools / AiTools | types | The injected machinery; the lane-typed aliases fix what stock() returns | A helper that takes tools |
FactsConfig / FactsDeclaration | types | defineFacts's output / input | Building a table programmatically |
HttpJsonInit / HttpJsonResult | types | tools.http.json's options and result | Wrapping http.json |
AiSendEntry | type | One model input: { kind: "text", key, value } or { kind: "attachment", key, attachment } | Re-slicing inputs before ai.judge |
ChainTools / LinkedResource | types | tools.chain and the shape its blocksync read answers with | A helper that takes a chain read's result |
readLinkedDoc | function | (resource, opts?) => Promise<LinkedDocRead> — a chain-anchored document by its LinkedResource ({ serviceEndpoint, proof? }). Reads the IXO Vault (https://<network.>vfs.ixo.earth/public/cid/<cid>), the IXO IPFS gateway (https://ipfs.gateway.ixo.world/ipfs/<cid>, or a bare ipfs:<cid> / ipfs://<cid> resolved through opts.ipfsGateway, default IXO's) and Matrix media download URLs. Hashes EXACTLY the bytes served against proof: a raw-sha256 CIDv1 (bafkrei…) or 64 hex (sha256); no proof ⇒ the CID the URL names. A mismatch is { ok: false, reason: "mismatch", retryable: false } — fail closed, never try another store. A proof of another shape (a dag-pb bafybei…) cannot be recomputed: the read is ok with verified: false. Failures: unsupported (matrix:<cid> room state, mxc://), unreachable / status (retryable on a network error, 408, 429 or 5xx), too-large (opts.maxBytes, default 2 MiB), not-json. opts.fetch swaps the transport (default global fetch, opts.timeoutMs 10 s) | An ask's #done / #payoutTerms, a #rub for its labels, any entity doc an adapter reads — if (!read.ok) throw read.retryable ? fact.retryLater(read.message) : … |
linkedDocUrl | function | (serviceEndpoint, ipfsGateway?) => string | null — the URL readLinkedDoc would fetch, or null for an address that is no document | Linking to a document without reading it |
LinkedDocRef / LinkedDocRead / ReadLinkedDocOptions | types | readLinkedDoc's input, result ({ ok: true, url, bytes, json, verified } or { ok: false, url, reason, retryable, status?, message }) and options | Wrapping it |
ClaimIntent | type | What tools.chain.claimIntent(...) answers — the agent's filed commitment, { time, transactionHash }, or null when it filed none | A helper that measures delivery from the intent, or a test that builds one |
withinRadius | handler | Ready-made ExtHandler — is the claim within meters of a centre? send: { lat?, lon?, photo?, centerLat, centerLon, meters }. The point comes from the answer's own lat/lon, else the photo attachment's EXIF GPS, else capturedAt.photo / capturedAt.after (geo evidence). No point from any source is an error (the rubric's onError), never false | Registering a "delivered nearby" source: ext: { deliveredNearby: withinRadius } |
located | handler | Ready-made ExtHandler — does the claim carry coordinates at all, from any of the same sources? send: { lat?, lon?, photo? }. The one geo check where "none" is false: presence is its question | A rubric that only needs "has a location" |
beforeAfter | handler | Ready-made ExtHandler — do two photo attachments show progress, in order and (optionally) nearby? send: { before, after, meters?, lat?, lon? }. after must be taken at or after before (both photos' EXIF, else both capturedAt readings); with meters, each photo's place (EXIF GPS, else its capturedAt, else the answer's lat/lon) must be within it. meters absent needs no GPS at all. A missing photo, time or place is an error, never false | A before/after delivery-photo gate |
haversineMeters | function | (a, b) => metres — great-circle distance between two { lat, lon } points | Building your own distance check on top of photoFactsFromJpeg |
photoFactsFromJpeg | function | (bytes) => PhotoFacts — a JPEG's EXIF GPS point and shutter time (DateTimeOriginal, falling back to DateTime), or nulls for anything that isn't a JPEG with EXIF | Reading a photo's own coordinates/timestamp outside the three handlers above |
PhotoFacts | type | { point: { lat, lon } | null; takenAt: string | null; software: string | null } — what photoFactsFromJpeg returns (software is IFD0 Software, verbatim) | Typing a helper that wraps it |
Geo evidence: grades and capturedAt
The geo handlers grade every time and place by who reported it, and write the grade into the
fact's reason (so it reaches the trace and the receipt), e.g. 412 m from centre, limit 500 m; evidence: camera (photo EXIF) or after is 600 s after before, 3 m apart; evidence: time browser-reported (capturedAt), place browser-reported (capturedAt.before) / camera (photo EXIF):
| Grade | Source |
|---|---|
camera | The photo's own EXIF (GPS, DateTimeOriginal) |
browser-reported | EXIF the capturing app wrote from the browser's reading into a photo that had none (IFD0 Software = openhands-app), or the claim's per-photo capturedAt |
claim | The answer's own send.lat / send.lon |
capturedAt is the supported form field for per-photo capture metadata: an object keyed by the
photo's role — before and after for beforeAfter, photo (else after) for withinRadius /
located — each { at: <ISO instant>, lat?, lon?, accuracy? }. A stamp with no zone is read as
UTC; accuracy is carried, not used.
{ "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";| Export | Kind | What it is | Use it when |
|---|---|---|---|
runFacts | function | The same runner production uses (minus the fact cache and D1), then the same validateFacts | Every handler test |
fakeTools | function | FactTools with every lane answered from a literal, plus a logs array | Every handler test |
httpResponse | function | httpResponse(503, body) — a canned answer whose status is the point | Testing a failure path |
sampleClaim | function | A PulledClaim whose body is your answers | Every handler test |
sampleManifest | function | The engine's work order for exactly the names you list | Every handler test |
sampleCtx | function | The frozen clock read + collection window | Pinning a date rule |
ProducedFacts | type | { ext, ai, unique?, frequency? } — what runFacts returns, plus issues | Naming the result |
FactIssue | type | { code, path, message } — one coverage failure | Asserting on issues |
FakeTools / FakeToolsOverrides / FakeHttpRoute / HttpResponseEnvelope / LogRecord | types | fakeTools's shape and options | A shared test helper |
ChainCall / ClaimIntentQuery / FakeChain | types | What chainCalls records, the query a chain.claimIntent function is handed ({ agentAddress, collectionId, before, after? }), and the chain override's own shape | Asserting on the chain reads a handler made |
RunFactsInput / SampleClaimOptions / SampleManifestDecl | types | The three builders' options | A shared test helper |
@ixo/decision-oracle/runtime — worker only, what a route/cron/Workflow calls directly
import {
AppError,
broadcastAsOracle,
decisions,
getCollection,
getDb,
getIxoSdk,
getOracleSigner,
getQueryClient,
loadConfig,
registerCollection,
rehireOpenhands,
scanCollection,
signEvaluation,
watchedCollections,
} from "@ixo/decision-oracle/runtime";
import type {
BlocksyncDeps,
ChainWriteOutcome,
CollectionNode,
Config,
Database,
DecisionRow,
OracleSigner,
RegisterInput,
WatchedCollection,
} from "@ixo/decision-oracle/runtime";Separate from the root because the root is what your entry module imports and /facts//testing
are what your node test suite imports; this subpath pulls in D1, the signer and the
sequence-manager DO, so it can only ever be loaded inside a Worker. Every function takes env
rather than the kit's internal deps — it assembles the config, the logger, the metrics, the trace
and the database handle from it exactly as the cron does (cron/scan.ts's cronContext).
| Export | Kind | What it is | Use it when |
|---|---|---|---|
signEvaluation | function | (env, claimId, amount?) => Promise<ChainWriteOutcome> — signs a decision the act policy held back (the release pass, lib/ixo/chain-write.ts). amount, when given, is the payout to put on MsgEvaluateClaim — the whole point of holding (a payout pooled across several claims) — under the same owner opt-in as the hook's (operatorAmounts; off ⇒ the lane's default, logged). Every guard of the lane still applies, so it is safe to call twice: an already-submitted row answers "skipped", never a second signature | Your own release trigger for a pooled payout — a batch job, an operator action, a cron of your own |
scanCollection | function | (env, collectionId) => Promise<number> — runs the cron's per-collection discovery pass, right now, for ONE collection. Builds its context with the SAME cronContext helper the tick itself uses, so a webhook and the cron racing on one claim start it exactly once. Throws notFound if the collection isn't registered, conflict if it is paused | Reacting to your own webhook the instant a claim is submitted, instead of waiting for the collection's next turn in the tick's rotation |
registerCollection | function | (env, input: RegisterInput) => Promise<WatchedCollection> — the SAME four gates (WHO/HIRED/PROTOCOL/PAID), delegation resolution, upsert and paused→active notice POST /v1/collections/register runs (lib/register.ts), as a function. input.cursor is the one thing the HTTP body schema never exposes: applied on FIRST registration only — a re-register can never rewind it (it is not in the upsert's conflict set). null scans the collection from claim zero (the existing NULL-cursor path, blocksync CLAIMS_FROM_START) for a collection whose claims may predate this oracle's registration; absent seeds the cursor to now, the HTTP route's own behaviour, unchanged | Your own Workflow registering a collection AS the oracle's own principal, after minting its own evaluate grant — there is no inbound request to authenticate or a body to validate |
broadcastAsOracle | function | (env, msgs, memo?) => Promise<{ code, transactionHash, rawLog? }> — signs and broadcasts arbitrary messages as THIS oracle's account, the same sequence-managed signer the act lane uses (getOracleSigner, lib/ixo/chain.ts). Memo defaults to your oracle's own name. Does not inspect res.code — unlike signEvaluation this is a raw broadcast primitive, not a decision lane, so a non-zero code is yours to interpret. Throws AppError.unavailable when ORACLE_SECP_MNEMONIC is unset, before the signer is ever built | Composing your own MsgExec around kit-issued messages |
rehireOpenhands | function | (env, collectionId) => Promise<"started" | "existing" | "restarted" | "throttled" | "failed"> — starts the OpenHands hire for a collection, or reruns its ended instance (spending the collection's backstop restart budget: three a day). A rerun mints only when the chain says the grant is stale or below 50 verdicts | openhands on, and your own act policy counts verdicts and sees an ask running low before the backstop's next visit |
AppError | class | The kit's typed error — .notFound(), .forbidden(), .conflict(), .unavailable(), .internal(), … (§13) | Catching/rethrowing what the four functions above throw, in your own error handling |
ChainWriteOutcome | type | "submitted" | "skipped" | "blocked" | "held" | "owner_submitted" — what signEvaluation resolves to, and (alongside "pending", the row's initial state before any write is attempted) the full decisions.chainStatus enum. A decision the act lane can never sign (an engine unusable, a decided review verdict) starts "skipped" instead; a pending_review starts "pending" and its fold acts | Branching on the result, or reading chainStatus off GET /v1/claims/:claimId |
RegisterInput | type | { collectionId, principal: { did, address }, contactEmail?, delegationCar?, cursor? }. No contactEmail sent and none stored ⇒ the row takes the contactEmail of the oldest-activated active Decision API source whose companyEntityDid is the collection's COMPANY as the engine derives it (GET /v1/collections/:id/company, UCAN collection/company-read: the collection's entity, or the org a deed is linked to both ways); a disputed company, a collection the engine does not know, or none such source ⇒ stays empty. Its 503 or any other failure never fails the register (logged register.company-unavailable, the row stays empty for a later register to fill). An explicit or stored contact is never overwritten. The SAME answer (one engine read per register) also links the collection to every active source of that company (api_source_collections, linkedBy = the company's entity DID, audit source.collection_linked by system:company-link; idempotent — an existing link is left alone); only the engine's ok links. The engine is asked only when no contact is sent or stored, or an active source is not yet linked to the collection. | Building the input to registerCollection |
WatchedCollection | type | The watched_collections row registerCollection and scanCollection read and write | Typing a helper that takes one |
Plumbing, verbatim. The chain/db pieces the four functions above are themselves built from,
re-exported here with no wrapper and no behaviour change — because the package's exports block
only opens the root, /facts, /testing and this subpath, a deep import is not an option:
| Export | From | What it's for |
|---|---|---|
getIxoSdk / getQueryClient / getOracleSigner / OracleSigner | lib/ixo/chain.ts | The lazily-loaded chain SDK, the memoized RPC query client, and the oracle account signer broadcastAsOracle itself calls |
loadConfig / Config | config.ts | The same validated config every lane reads |
getDb / Database | db/client.ts | The Drizzle handle over the DB binding |
watchedCollections / decisions / DecisionRow | db/schema.ts | The two tables (WatchedCollection is above), for a query of your own alongside these functions |
getCollection / BlocksyncDeps / CollectionNode | lib/ixo/blocksync | The on-chain collection read registerCollection itself calls |
policies.act, in full. ActPolicy (exported from the package root, §1 above — it's a
type-only seam with nothing to validate at registration, so it doesn't live under /runtime):
type ActPolicy = (ctx: ActContext) => Promise<ActDecision> | ActDecision;ActContext — everything the act lane knows about the claim it is about to sign for, handed once
every one of the lane's own guards has passed and immediately before it reads the authz grant:
| Field | Type | What it is |
|---|---|---|
claimId | string | |
collectionId | string | |
evaluationStatus | 1 | 2 | The chain's EvaluationStatus the lane is about to sign — 1 approve, 2 reject |
verdict | string | null | The engine's verdict string |
decision | Decision | The engine's decision object verbatim (decisions.decision) |
receiptCid | string | The engine's signed receipt CID |
facts | ActFacts | The facts the decision was made on, frozen on the decision row (decisions.facts) — ext.<name> / ai.<name> only, never answer values |
payment | ClaimPayment | undefined | The claim's payment plane (lib/ixo/payment.ts): its own coins, and whether an intent governs |
defaultAmount | { denom, amount } | undefined | What the lane would put on MsgEvaluateClaim without the policy — undefined ⇒ the chain's own default |
operatorAmounts | "off" | "allowed" | Whether a sign amount will stand: "allowed" only while the collection's current chain owner opted in (PATCH /v1/collections/:id/settings; a yes set by a previous owner reads "off"). On "off" the lane pays defaultAmount whatever you return — hold or skip if that is not the payout you mean |
claim | ClaimNode | The on-chain claim as Blocksync returns it (lib/ixo/blocksync getClaimById) |
env | OracleEnv | |
logger | Logger |
ActDecision is what the hook returns: { mode: "sign", amount?, reason? } proceeds (amount,
when given, replaces what the lane would have sent — denom included — but only on a collection
whose owner set operatorAmounts: "allowed" (PATCH /v1/collections/:id/settings) and still
owns it — a transfer turns the yes off until the new owner sets it again; while it is
"off", the default, the lane pays its own default and logs act.amount-ignored-not-opted-in,
and on an intent claim, where the chain overwrites it anyway, it logs act.amount-ignored-intent); { mode: "hold", reason? } parks the row
chainStatus: "held" for a later signEvaluation; { mode: "skip", reason? } leaves the row
untouched, the same clean no-op as AUTO_SIGN being off. The hook runs inside the act step's own
retry, so a throw is a retry — never a silent signature: the lane records the error on the row and
rethrows.
Writing one that cannot be gamed by a bad rubric. The lane guarantees only its own half: nothing
but an explicit sign reaches the grant read and the broadcast, and an answer it cannot read — a
typo, an old shape, undefined — parks the row blocked rather than signing it. The other half is
yours, and it turns on two questions.
Is the fact really there? ctx.facts is keyed by the rubric's names, so a name the rubric never
declared is simply absent, and a handler that failed leaves status: "error" with no value at all.
Written as facts.ext.siteWork?.value === true ? hold : sign, absent, errored and honestly-false all
collapse into one branch. Read presence and status before value, and give the unknown case a
branch of its own:
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
| Field | Type | Validation | Meaning / default |
|---|---|---|---|
name | string | non-empty | The marketplace label on GET /v1/info, and the memo on every MsgEvaluateClaim this oracle signs. Also the default EMAIL_FROM_NAME |
description | string | any string | The listing blurb |
supportedProtocols | string[] | every entry must start with did:ixo:entity: | Register gate 3: a collection whose protocol is not listed is refused 409. [] ⇒ no restriction. Convention: exactly one entry — one oracle, one protocol |
pricing.evaluation.denom | string | non-empty | The denom a collection's on-chain Payments.evaluation must be in |
pricing.evaluation.minAmount | string | /^\d+$/ — an integer base-unit string | The minimum that payment must carry, compared with BigInt. Below it, register is refused 402: the chain's split of that coin (80% to the oracle, 10% to the network, 10% to the relayer node — the x/claims network_fee_percentage / node_fee_percentage chain params, governance-set, 10/10 on mainnet today; rounding remainder to the oracle) is how the oracle earns. Both fields are replaced, per deployment, by the EVALUATION_DENOM / EVALUATION_MIN_AMOUNT vars when those are set (required on mainnet) — what register charges and /v1/info serves is the result (effectivePricing) |
pricing.byProtocol | Record<protocolDid, { denom, minAmount }> | optional; each entry the same shape/validation as pricing.evaluation | Per-protocol override, keyed by did:ixo:entity:…. Register's gate 4 resolves the collection's protocol against this map first, falling back to pricing.evaluation when unlisted. minAmount: "0" lets that protocol's collections register with an EMPTY Payments.evaluation leg — for an oracle whose income for it isn't the evaluation split. GET /v1/info advertises this map alongside pricing.evaluation |
pricing.api.amountMicroUsd | number | optional (pricing.api whole is optional); an integer ≥ 10 000 and a multiple of 10 000 (whole cents ≥ 1¢), micro-USD (1 upay = 1 µUSD; $1.20 = 1_200_000) | The Decision API lane's price per decision — a separate thing from the chain fees above. Published at GET /v1/api/protocols[/:did] as price (public, cache-control: public, max-age=300), quoted in every 402, and checked by the facilitator against every offer (price_mismatch otherwise). A function of the protocol alone — there are no per-source prices; a partner who should not pay gets billing mode none. Every price is a whole number of cents, at least 1¢ (a multiple of 10 000 µUSD — anything else fails at startup), so price.amountCents and price.amount (upay) are both exact |
payouts | Record<protocolDid, { amount: string; currency: string | { path: string } | { asset: string; decimals: number } }> | optional; amount / currency.path are a path like $.invoice.total or $.lines[0].amount ($ then .key / [n] only); currency an ISO 4217 code (EUR), or { asset: "eip155:<chain>/erc20:0x…", decimals }; nothing else (a payee or readers key is refused); with a non-empty supportedProtocols, every key must be in it (refused at module evaluation) | Decision Payouts: where a protocol's claims carry the amount to pay. The oracle sends it to the engine as the payout block (outside the facts) on the decide call and the fold of EVERY claim of the protocol; the engine reads the amount from the claim's answers and, when the claim is approved, binds the payment into the decision's one credential: ClaimRecord.decision.receipt is then also typed DecisionToPayCredential and carries the payment binding, public at the engine's /v1/receipts/:cid (contracts 1.13.0 removed the separate decisionToPay). The engine derives the payee — a chain claim's on-chain agent; an API claim's submitter, else the answer the source's company link names (payeeField) — and answers decisionToPayRefused: "NO_PAYEE" (a receipt with no binding, logged payout.refused) when there is none. Submitter ids must be opaque (never a name, email or phone), at most 256 characters: the public receipt names the payee by that id. Absent ⇒ no payout block, nothing changes |
pricing.api.byProtocol | Record<protocolDid, number> | optional; each value micro-USD held to the same rule as amountMicroUsd — an integer ≥ 10 000 and a multiple of 10 000 (whole cents ≥ 1¢); with a non-empty supportedProtocols, every key must be in it (refused at module evaluation) | Per-protocol override of pricing.api.amountMicroUsd. Resolution: byProtocol[did] → amountMicroUsd → the API_PRICE_USD_CENTS var × 10 000 → none: the protocol answers price: null, and a submit under it is 503 SOURCE_PAUSED { payments-disabled } (nothing is offered at a price the facilitator cannot check) |
Card is offered for a protocol only when its price is ≥ 50¢ (500 000 µUSD) per decision. That is Stripe's minimum capture, and the facilitator checks every claim separately, so a batch doesn't lift it. Below that only ixo-pay is offered. Changing the price is a redeploy (the code value, or API_PRICE_USD_CENTS when pricing.api is absent). The facilitator re-reads prices within ~5 min; an offer refused price_mismatch in that window is retried.
| policies.autoSign | boolean | required | Sign MsgEvaluateClaim on a terminal verdict unattended. The AUTO_SIGN var must be on too — both, or no chain write |
| policies.allowAiChecks | boolean | required | false ⇒ every AI fact freezes as error / ai-checks-disabled with no model call, and the rubric's own onError carries. Also disables the duplicate lane's same-event judge (aiOutcome: "skipped") |
| policies.allowImageChecks | boolean | required | Screen an AI check's image inputs for C2PA Content Credentials before the model call. Needs the C2PA_IMAGE_CHECK var on too. Fail-safe: it can only ever add a rejection |
| policies.actSummary | string | optional, 1–500 chars | How your act policy picks an amount, in your own plain words — published on GET /v1/info as payouts.operatorAmount.summary (only while act is set) so a company sees how it is paid before it opts in to operatorAmounts. The oracle never writes one for you |
| policies.allowStandingApprovals | boolean | required | Let an AI app's standing approval (the user's own auto-approve limits on the hosted MCP server) pay for a submit without a per-claim approval. Off ⇒ AuthHub cannot set one (an approval answer with standing, and PUT /v1/oauth/standing/:grantId, are 403 FORBIDDEN, details.reason: standing_approvals_off), a standing already stored never pays, is never read back (GET /v1/oauth/standing/:grantId is 404) and is deleted by the hourly housekeeping sweep, a card payment one parked for 3-D Secure is not resumed (FORBIDDEN, standing_approvals_off; the retry asks for the claim's own approval), and every OAuth submit needs the user's approval of that claim. GET /v1/info shows it under policies |
| policies.reviewerNote | boolean | optional | Make a reviewer note for every claim held for a person (pending_review): one AI call, on the same provider as the AI checks, that says in plain words what the answer claims and what no check could verify — { says, checkThese[], model, createdAt }. Outside the decision: never sent to the engine, never in the receipt, never moves a verdict, no recommendation. Served beside checks on GET /v1/reviews/:claimId and GET /v1/claims/:claimId (never the submitter's view), erased with the claim body. Absent ⇒ on for an OpenHands checker (openhands set), off otherwise. Its spend is an ai_calls row (fact: "review.note"). A failed note is just absent (review.note.failed log) |
| policies.act | ActPolicy | optional; typed, not validated by Zod (src/act.ts) | Your say over what the act lane does with a decided verdict, once every other guard has passed and just before it reads the authz grant: sign (optionally overriding the amount, where the collection's owner opted in — operatorAmounts), hold (parks the row chainStatus: "held" for a later signEvaluation), or skip (a clean no-op, same as AUTO_SIGN off). Absent ⇒ the lane signs on its own, as today. Full ActContext field list in the @ixo/decision-oracle/runtime section (§1) |
| facts | FactsConfig \| FactsProducer | carried through untouched | Absent ⇒ the stock lanes for everything |
| archive | ArchiveHooks | optional; typed, not validated by Zod | How the answer archive (ANSWERS_ARCHIVE) reads your asks and answers. describe(ask: ArchiveAsk) => Partial<ArchiveAbout> (may be async): ask is { askId, entity, alsoKnownAs, collection, docs, docsOf(did) } — the chain collection as Blocksync returns it, the entity's LinkedResources and settings as { id, type, json, error? } (up to 12, 256 KB each, proof-checked by readLinkedDoc), and the same read for another entity; return any of title, question, kind (photo · observation · url-check · other), place {name?, lat?, lon?, radiusM?}, reward {amount, denom}, postedBy {name?}, opensAt, closesAt — askId and proof are always the chain's. answer({ body }) => ArchiveAnswerView: { text?, place?: {lat, lon, accuracyM?, at?} }, exact. Absent ⇒ the #pro name (else alsoKnownAs), the collection's window and approval pay, nothing else (guide §12) |
| 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):
- verify — the collection on chain, your
admithook, 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. - 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 ⇒ yourmaxCustomAmount, 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. - 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). - scan — the claims filed before the hire, started now.
A backstop runs every five minutes so a lost webhook never strands an ask: it finds meta-grants
whose collections are not watched yet and hires them, re-mints a watched collection whose poster
raised the ceiling or whose quota fell below 50, and lifts an engine-no-grant pause once the
chain shows quota again (at least 5 min after the last read — the engine's grant cache). Its
window reads only the watched collections that can need it — never read, hired or claimed in the
last day, quota below a top-up, or not read for a week — so a quiet ask costs one read a week (a
raised ceiling on one arrives by the hire webhook, or within that week). Each
collection gets at most three hire reruns a day from the backstop and three from the webhook, on
separate budgets. POST /openhands/scan/<collectionId> scans a watched collection at once (at
most once a minute; extra calls are scanned on the next minute tick). A scan that starts nothing —
usually because Blocksync has not indexed the claim yet — is looked at again on each of the next
three minute ticks, and no webhook scan moves the collection's place in the rotation. GET /v1/info advertises
both paths under openhands, which is how the OpenHands app tells a checker from any other oracle.
OpenhandsOptions (all optional):
| Field | Type | Meaning / default |
|---|---|---|
admit | (ask: OpenhandsAsk, env) => string | null | Promise<…> | Refuse an ask before anything is minted, registered or spent: return a reason (the hire ends with it, final) or null to serve it. OpenhandsAsk is { collectionId, admin, entity }. Every engine decision is billed to your oracle's owner, so an oracle that charges no evaluation fee (pricing.evaluation.minAmount: "0") should bound whose asks it judges here — a per-owner allow-list, a daily budget. A throw retries the step. Absent ⇒ every ask that granted you is served; the register gates still apply |
maxCustomAmount | (approvalDenom: string | null) => { denom, amount }[] | The payout ceiling written on the minted grant when the poster's meta-grant names none. Absent ⇒ none — the collection's approval payment pays every approved claim, which is what a priced ask wants. Only an oracle whose policies.act names amounts needs one |
onQuota | (quota: OpenhandsQuota, env) => void | Promise<void> | Told every quota read (the hire's mint step, the backstop): { collectionId, remaining, mintQuota, endsAt? }. For an oracle that counts verdicts itself and tops up early with rehireOpenhands (/runtime). Fail-soft |
Bindings, with openhands on: the HIRE_WORKFLOW workflow (class OpenhandsHire) and, outside
ENVIRONMENT=development, the OPENHANDS_RATE_LIMITER ratelimit — unbound there, both webhooks
answer 503 (fail closed). decision-oracle init --openhands writes both. State lives in two
tables of your DB (openhands_state, openhands_hires, shipped migration 0025_openhands).
The whole-producer escape hatch
facts also accepts a single function, for the one case a per-name table cannot express — you
assemble the whole answer yourself:
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.
| Key | Type | Unlisted / absent means |
|---|---|---|
ext | Record<string, ExtHandler> | The stock external-source lane: a self-signed UCAN POST to the declared endpoint with the resolved send map plus claimBody, read against the strict { valid } / { score } contract (§ "How an external source is called") |
ai | Record<string, AiHandler> | The stock judge: the configured AI provider (AI_PROVIDER) with the rubric's own instructions over the declared inputs |
ai["*"] | AiHandler | The one wildcard — it catches every check the table does not name. A named handler still wins |
groups | FactGroup[] | Nothing grouped: every source is answered on its own. Each group(names, handler) takes over those ext names and answers them from ONE call (§4) |
unique | UniqueHandler | The stock duplicate lane: this oracle's D1 registries, a keyed body digest, CID + perceptual-hash attachment lanes, a JS trigram near-text lane and the gray-zone same-event judge |
frequency | FrequencyHandler | The stock submission count: one Blocksync read over the claim's own calendar window |
A table is a DIFF against stock, never a replacement of it. A handler for a name the manifest
does not declare produces nothing and logs facts.handler-unused once — a renamed rubric entry
shows up as that warning, not as a broken build.
One name, one handler. A name listed in ext and claimed by a group — or claimed by two
groups — throws at module evaluation (defineFacts: 'quality' is answered twice …), because the
alternative is a fact whose value depends on which lane wrote last.
4. Handlers
type ExtHandler = (input: ExtInput, tools: ExtTools) => Promise<ExtResult> | ExtResult;
type AiHandler = (input: AiInput, tools: AiTools) => Promise<AiResult> | AiResult;
type GroupHandler = (input: GroupInput, tools: FactTools) => Promise<GroupResult> | GroupResult;
type UniqueHandler = (input: UniqueInput, tools: FactTools) => Promise<UniqueFact> | UniqueFact;
type FrequencyHandler = (input: FrequencyInput, tools: FactTools) => Promise<FrequencyFact> | FrequencyFact;Synchronous handlers are fine — every signature accepts a value or a promise.
What every handler is handed (FactInputBase)
| Field | Type | What it is |
|---|---|---|
claim | PulledClaim | claimId, collectionId, network, lane (the store the body came from: "vfs" | "vfs-public" | "matrix" | "ipfs" | "cellnode"), metadata (agentDid, agentAddress, schemaType, submissionDate, amount, payment, evaluation), body, attachments (CID/URL descriptors, never bytes), media (that store: { origin, cidUrl(cid), auth?() } — holds its auth, never log it; null on the cellnode lane) |
answers | Record<string, unknown> | claim.body — the credentialSubject, verbatim |
ctx | CtxFacts | The build's one clock read and the collection window: { now, collection: { startDate, endDate }, claim: { submitter, submitterAddress, submissionDate, useIntent? } }. All dates are Z-suffixed ISO or null. Never read the clock in a handler — ctx.now is what the engine's date rules compare against, and a second read makes a fact that cannot be replayed |
settings | FactSettings | { allowAiChecks, allowImageChecks } — this build's policy, from defineOracle |
manifest | Manifest | The whole work order (§8): fields, derived, ctx, usedBy, every ext/ai declaration, unique/frequency, sizeProbes |
ExtHandler — one external source
ExtInput adds:
| Field | Type | What it is |
|---|---|---|
name | string | The <name> in ext.<name> — the work-order key this handler answers |
expected | ManifestExt | The declaration for this name: expect ("boolean" | "score"), onError, endpoint, audience, capability?, send, timeoutMs?, usedBy |
send | Record<string, unknown> | The declared send map already resolved against the answers and ctx: a $path ref becomes the answer (null when it walks off the data), a ctx.* token the frozen value, a literal itself, and a ~derived slot null |
unresolved | string[] | The ~derived refs that send names — reported, never guessed. Their expressions are on manifest.derived |
Return: boolean · an integer number (basis points 0–10000, for expect: "score") ·
fact.ok(...) · fact.error(...).
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:
| Field | Type | What it is |
|---|---|---|
inputs | AiSendEntry[] | The send map as the judge's typed, declared-order inputs: a scalar becomes { kind: "text", key, value }; a $ref whose root question carries files becomes one { kind: "attachment", key, attachment } per file (all of them, not just the first) |
Return: boolean · fact.ai.ok(...) · fact.error(...).
The common shape is a guard in front of the stock judge — tools.stock() is typed as this lane's
own fact, so it needs no cast:
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:
| Field | Type | What it is |
|---|---|---|
names | string[] | The names THIS call must answer: the group's names, minus any the manifest does not declare and any a previous attempt already paid for (a cache hit is kept, and the call it would have taken is what the cache saves) |
sources | Record<string, ExtInput> | Per name, the same input a single ExtHandler gets — its own expected declaration and its own resolved send |
Return: Record<name, ExtResult> — one fact per name, each of them anything a single ext handler
may return (boolean · integer basis points · fact.ok(...) · fact.error(...)).
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'sonError. Silence is not an answer and never reads as one. - A name nobody asked for is dropped with one
facts.group-extra-factwarning: a name the rubric does not declare, or one already answered from the cache. Nothing is overwritten. - The group is one slot in the fan-out cap, not one per name — the cap counts the call, which is what costs money.
tools.stock()throws inside a group. It could only mean "the stock lane for which of these names", so a grouped name's lane is the group; leave a name out of the group to keep its stock lane.
UniqueHandler — the duplicate fact
UniqueInput adds rule: UniqueRule, the manifest's declaration with the engine's own defaults
already applied:
| Field | Type | Default when the manifest omits it |
|---|---|---|
id | string | — (always the manifest's ruleId) |
by | (string | { ref: string; granularity?: "day" | "month" | "year" })[] | absent ⇒ the whole canonical body is the identity |
countPrior | ("approved" | "pending_review" | "queued")[] | ["approved", "pending_review", "queued"] |
attachments | boolean | true |
ai | { enabled: boolean; maxPairs: number } | { enabled: true, maxPairs: 3 } |
thresholds | { rejectAtBps: number; reviewAtBps: number } | { rejectAtBps: 9000, reviewAtBps: 5000 } |
What the stock lane counts as a prior claim: every claim this oracle registered for the
collection before this one (ingest order, so a later copy never flags the original when the
original's evaluate step retries), whose verdict countPrior lists, or that is still waiting for one.
A claim that never reached a decision (a facts-lane outcome such as invalid_facts, a content
refusal, an unusable rubric, an ingest that failed or ran out of retries) is marked void and
never counts, so an honest resubmission of the same evidence is not its duplicate. The same filter
applies to every lane: exact digest, attachment CID, perceptual hash and near text.
Who submitted it decides the exact-digest score: the SAME submitter repeating a claim scores
10000 (exact_body, the reject band), another submitter's copy 8800 (exact_body_cross_agent,
review: a front-run copy is held for a person, never auto-rejected). The submitter is the chain
claim's agent address, or the API claim's submitter (keyed as its HMAC digest, the same key the
frequency count uses, so it survives erasure). Unknown on either side ⇒ different submitters.
Return the wire shape (§9): ruleId, digest, scoreBps, band are what the engine reads;
anything else you add rides loose into its trace. tools.stock() throws here by design — a
custom unique handler replaces its lane wholesale.
import type { UniqueHandler } from "@ixo/decision-oracle/facts";
export const unique: UniqueHandler = async ({ rule, claim }, tools) => {
const res = await tools.http.json<{ digest?: unknown; scoreBps?: unknown }>(
"https://registry.example/duplicates",
{ method: "POST", body: { claimId: claim.claimId, collectionId: claim.collectionId } },
);
const digest = res.body?.digest;
if (!res.ok || typeof digest !== "string") {
// A registry we could not reach is never a pass.
return { ruleId: rule.id, digest: "registry-unavailable", scoreBps: 0, band: "review" };
}
const scoreBps = typeof res.body?.scoreBps === "number" ? res.body.scoreBps : 0;
return {
ruleId: rule.id,
digest,
scoreBps,
band:
scoreBps >= rule.thresholds.rejectAtBps
? "reject"
: scoreBps >= rule.thresholds.reviewAtBps
? "review"
: "pass",
vendor: "registry.example", // extra fields ride into the engine's trace
};
};band is the branch the engine takes: pass is silent, review downgrades the decision to a human
review, reject fails the claim. It is a closed enum — a typo fails validation here rather than
misrouting there.
FrequencyHandler — the submission-rate fact
FrequencyInput adds rule: FrequencyRule | null:
| Field | Type | Default |
|---|---|---|
id | string | the manifest's ruleId |
max | number | none — a declaration without it cannot run |
per | "day" | "week" | "month" | "year" | none — same |
onBreach | "review" | "reject" | "reject" (the engine's default; informational: the engine rejects every breach regardless) |
rule is null when the manifest declared the rule without max/per. The stock lane is
never asked in that case (the build is refused as FACT_MISSING "manifest lacks max/per"); a
custom handler still is, because it may know the limit another way.
import { fact, type FrequencyHandler } from "@ixo/decision-oracle/facts";
export const submissionRate: FrequencyHandler = async ({ rule, manifest, claim }, tools) => {
const ruleId = rule?.id ?? manifest.frequency?.ruleId ?? "frequency";
const max = rule?.max ?? 0;
const per = rule?.per ?? "day";
const res = await tools.http.json<{ count?: unknown }>(
`https://ledger.example/claims?agent=${claim.metadata.agentAddress ?? ""}&per=${per}`,
);
// An outage is not an answer: `unresolved` is a final reject on the engine, so retry instead.
if (!res.ok) throw fact.retryLater(`ledger answered ${res.status}`);
const count = res.body?.count;
if (typeof count !== "number") {
// `unresolved` is its own state — the engine fails it closed, never reads it as headroom.
return {
ruleId, count: 0, max, per,
windowStartISO: null, windowEndISO: null,
breach: false, unresolved: true,
};
}
return { ruleId, count, max, per, breach: count > max };
};count includes the claim under evaluation (it is on chain by the time it is judged), so
breach is count > max. Windows are UTC calendar periods; weeks are ISO weeks (Monday 00:00
UTC), derived from the claim's own on-chain submissionDate — never from evaluation time. The
rubric's Frequency declaration has no time-zone field, so there is no local day: "30 a day" means
30 per UTC day by chain submission time (a Sydney courier's working day spans two UTC days).
The stock lane freezes unresolved only when the window cannot be counted at all — the claim has
no submitter, or no usable on-chain submissionDate. A count that cannot be read (Blocksync
down, a 5xx, a timeout, an error payload) throws unavailable and the workflow step retries: the
engine rejects an unresolved fact for good, and an outage is not evidence. A custom handler owes
the same: throw fact.retryLater(...) on trouble, unresolved only for a deterministic miss.
What a return becomes
| Return | Frozen fact |
|---|---|
true / false (ext) | { status: "ok", value, reason: null } |
8200 (ext, expect: "score") | { status: "ok", value: 8200, reason: null } — an integer 0–10000, never clamped |
true / false (ai) | { status: "ok", value, reason: null, failedRules: [], score: 100 } |
fact.ok(...) / fact.ai.ok(...) | passed through verbatim |
fact.error(detail) | { status: "error", onError: <the manifest's>, detail } |
| anything thrown | the same error fact, logged facts.handler-threw |
throw fact.retryLater(detail) | not a fact — the workflow step retries; facts already answered are kept in the fact cache. On the step's last attempt: the error fact still unavailable on the last attempt: <detail> |
anything else (a string, undefined, null, a bare object) | an error fact naming the problem: handler returned <kind> — expected boolean/number/a fact |
5. fact helpers
fact.ok(
value: boolean | number,
extra?: { reason?: string | null; confidence?: number; transcript?: Transcript },
): ExtFact
fact.ai.ok(
value: boolean,
extra?: {
reason?: string | null;
failedRules?: string[];
score?: number;
confidence?: number;
transcript?: Transcript;
},
): AiFact
fact.error(detail: string, extra?: { transcript?: Transcript }): FactErrorDraft
fact.retryLater(detail: string): RetryLater // `throw` it| Helper | Produces | Defaults |
|---|---|---|
fact.ok | { status: "ok", value, reason } (+ confidence, transcript when given) | reason: null; confidence omitted unless passed (integer basis points 0–10000) |
fact.ai.ok | { status: "ok", value, reason, failedRules, score } (+ confidence, transcript) | reason: null, failedRules: [], score: 100. score is confidence in the verdict (0–100), not input quality |
fact.error | { status: "error", detail } — a draft | The runner stamps the onError |
fact.retryLater | A RetryLater error. Throwing it surfaces as AppError.unavailable, so the workflow step retries | — |
fact.error takes no onError and there is no way to pass one. The runner stamps the
manifest's own value over whatever a handler produced, in both directions. That is the invariant the
whole design rests on: a handler can never turn its own failure into an approval.
6. FactTools
Built per fact and injected, so stock() means "the stock lane for this fact". The type
parameter is which fact that is: ExtTools = FactTools<ExtFact> in an ext handler, AiTools = FactTools<AiFact> in an ai one — which is why return tools.stock() type-checks with no cast.
unique/frequency handlers see the bare union, and there stock() throws.
tools.stock(): Promise<ExtFact | AiFact>
Runs this fact's stock lane with exactly the input the runner would have handed it, and normalizes the answer the same way. Wrap it, fall back to it, or ignore it — but never call it and the declared endpoint yourself: that is two calls, and two charges, for one fact.
In a unique/frequency handler it throws AppError.internal explaining that such a handler
replaces the lane wholesale.
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, a408or a429burns an attempt and retries; any other 4xx ends the loop on the first answer (the same request gets the same answer, sook: false, thatstatus,attempts: 1). Passattempts: 1for 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 malformedLocationends the call asstatus: null, never retried. A hop to another origin carries none of your headers butacceptandcontent-type— noauthorization, no vendor key likex-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 containsauthorization,token,secret,password,credentialorsignature(proxy-authorization,cf-aig-authorization,x-auth-token…), andcookie/set-cookiealways go;idempotency-keyis kept. In the recorded URL, the value of a query parameter whose name containskey,token,secret,password,signature,sig,authorcredentialbecomes[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 ownbodyis still whole. Name the response type orbodyisunknown.
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'ssendmap resolved against the claim ($pathanswers,ctx.*values, literals) plus the whole credentialSubject under the reservedclaimBodykey (asendentry 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:portpercent-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 theAuthorizationorIdempotency-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:
200with{ "valid": boolean }(expect: "boolean") or{ "score": 0..10000 }(an integer,expect: "score"), optionalreason. A 5xx,408,429, network error or contract miss is retried in the call (3 attempts, ~30 s, aRetry-Afterhonoured when the budget allows). Down is not "no": if the last attempt was still a 5xx,408,429or no answer at all, the lane throwsunavailableand the workflow step retries (about 4 hours of backoff), so an outage never becomes a decision. Only a definitive answer becomes anerrorfact carrying the rubric'sonError: any other 4xx (one call), or a malformed or off-contract2xx. On the step's last attempt an outage that is still there freezes as thaterrorfact 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⇒ returnserror / ai-checks-disabledwith 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.modeloverrides the pinnedAI_CHECK_MODEL. - Images: sent at the pinned
AI_IMAGE_DETAIL(defaulthigh), and the transcript records it. - Provider:
AI_PROVIDER— the Cloudflare AI Gateway (theAIbinding +AI_GATEWAY_ID) by default, OpenRouter withOPENROUTER_API_KEY. Either way the call carriesreasoning.effort = AI_REASONING_EFFORT(defaultlow). A missing piece throwsAppError.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}, wherencounts 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 anerror(the rubric'sonError, detailundeterminable: R1 (<rule text>) — <model reason>), nevervalid: 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:
validis derived in code fromfailed_rules(the model's ownvalidis ignored);scoreis clamped 0–100; a score belowAI_CHECK_MIN_CONFIDENCE(default 60) is the unreachable path (anerrorfact), 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 THROWSunavailable(the workflow step retries; the fact cache keeps every answer already paid for), and freezes as theerrorfact 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) andusage.usage(tokens + USD) is set only when the provider reports cost at call time (OpenRouter); on the gateway it isnulland the USD of every billed attempt (a retry included) lands in theai_callstable within ~5 minutes (cronai-cost-backfill, from the gateway's log). A log not found for an hour, or with no price, is closedunavailable; the log API failing pauses the job without closing anything, unless it has refused a row for a day.unique.aiCostUsdis 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>;onfiltersclaim.attachmentsby the root question name —"deliveryPhoto", not"$deliveryPhoto". UserootQuestionName("$deliveryPhoto.0")to get one from a$ref.bytesdefaultsmaxBytesto 10 MiB — the same cap the AI lane uses, deliberately, so a handler reading a file itself never quietly gets a different limit.content-lengthis 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 itssizeis thenmaxBytes + 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.sizereadscontent-lengthwithout consuming the body. A store that answers 200 with nocontent-length(the VFS streams its files, so the header never arrives) has its body counted instead, up to 25 MiB.nullon any failure, an empty body, or past that cap — andnullis 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.bytesandsizefollow 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 isunfetchable/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 itsproofcontent hash. The same read the package's own rubric lane uses. To read one's bytes, hand it toreadLinkedDoc(Vault, IPFS or Matrix, checked against thatproof).claimIntent({ agentAddress, collectionId, before, after? })— the newestMsgClaimIntentthis agent filed on this collection beforebefore(normally the claim's submission date), bare or authz-wrapped: a delivery clock and the tx hash to cite for it, ornullwhen it filed none. Pass the agreement's consent time asafterso an earlier engagement's intent can never become the clock.query()— the@ixo/impactxclient-sdkquery 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
counterpartyfor the claim's own agent (claim.metadata.agentAddress) — the usual question. - Pass an explicit
nullfor the collection's own terms, the statement that names no counterparty. The two are different questions and do not collapse. - Returns
null(and warnsfacts.agreement-no-engineonce 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 orclaim.media.config— the validatedConfig: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 throwsAppError.internal("tools.env: this build carries no worker env")in tests and CLI dry runs, where there is none — loud beats reading a binding offundefinedthree 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.
- Normalization. A plain
true/8200becomes anokfact; afact.errordraft becomes a full error fact; a full fact passes through verbatim; anything else becomes an error fact that names the bad return. - Fail closed. An error fact carries the manifest's
onError, stamped here, overriding whatever the handler put there. A throw is the same thing. - One retry signal. Only
fact.retryLater()— and the package's ownAppErrors, 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, asunavailable, so the workflow step retries instead of freezing a verdict. Every other throw is an error fact, loggedfacts.handler-threw. Bounded: on the evaluate step's last attempt aretryLateror anunavailablefreezes as the rubric'sonError(still unavailable on the last attempt: …, loggedfacts.retries-exhausted), so an outage that outlives every retry still ends as a decision, never as a dead ingest. - The fact cache. Every resolved
ext/aifact 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#rubis a new work order: its facts run again, even the same-named ones.unique/frequencynever 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. - 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/frequencyrun beside the pool, one read each. Do not add your own concurrency. - Unused names. A handler for a name the manifest does not declare is skipped with one
facts.handler-unused { kind, name }warning — including aunique/frequencyhandler written for a rubric that declares no such rule. ai["*"]. The wildcard catches every check not named in the table; a named handler wins.- 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 — ownonError, own cache entry, a crash frozen on each — plus: a name the handler omitted is that name'shandler returned no fact for <name>error, and a name nobody asked for is dropped with afacts.group-extra-factwarning. Two handlers for one name is a boot error. - Derived refs. The stock lanes refuse a
sendmap naming a~derivedref (anerrorfact with the rubric'sonError, loggedstock.derived-unsupported). A custom handler is not short-circuited: it gets the refs inunresolvedand the expressions onmanifest.derived. - Rule-less frequency. A
frequencydeclaration withoutmax/peris warned (stock.frequency-unrunnable) and omitted from the FactSet — unless you wrote a custom handler, which is asked withrule: null. - Coverage.
validateFacts(§9) runs over the whole answer before anything is frozen.
And around the runner, in the build: ctx is resolved before any handler runs (one clock read,
one Blocksync read), and after the FactSet is frozen there is no network, no clock and no AI
anywhere downstream. That boundary is the replay guarantee a review fold depends on.
8. The work order — Manifest
What the engine's binder resolved the protocol's rubric to want, read from
GET /v1/rubrics/manifest?protocol=<did> (public, KV-cached for 5 minutes). This oracle never
parses a rubric — the manifest is the only work order, and bunx decision-oracle protocol review <did>
prints it in plain words.
| Field | Type | What it is |
|---|---|---|
rubricId | string | The verified rubric id — sha256 of its anchored bytes, canonical lowercase |
formProof | { pinned, live: string | null, drifted: boolean } | The rubric's pinned #vct proof vs the live claim form. drifted is the engine's call; protocol review prints a warning for it |
ext | Record<string, ManifestExt> | Every declared external source |
ai | Record<string, ManifestAi> | Every declared AI check |
hasAiChecks | boolean | Shorthand for "this rubric spends on a model" |
ctx | string[] | The ctx.* tokens the rules name explicitly |
fields | { path: string; kind: string }[] | Claim-form catalog paths the rules read, with the live form's kinds |
derived | { name: string; expression: string }[] | ~name values the engine computes — with the source expression, so a custom handler can reproduce one |
review | { tasks: string[]; taskDetails?: { id, prompt, answer }[] } (optional) | The human-review tasks a pending_review decision can name. answer is { type: "boolean" | "enum" | "number" | "text" } (enum carries options). taskDetails also lists the engine's synthetic tasks after the rubric's: unique:<ruleId> (a possible duplicate) and checks:held (held only by a failed check), answered "approve" or "reject" — never a boolean |
unique | ManifestUnique | null (optional) | ruleId plus the full post-default rule: by?, countPrior?, attachments?, ai?, thresholds? |
frequency | ManifestFrequency | null (optional) | ruleId, max?, per?, onBreach? |
sizeProbes | { path: string; maxSizeMB: number }[] (optional) | The bare root question names a maxSizeMB rule reads, with the minimum limit declared. The only attachments the package probes a byte size for |
ManifestExt: expect ("boolean" ⇒ the fact's value is a boolean · "score" ⇒ an integer
0–10000), onError ("review" | "reject", always present), endpoint, audience (the source's
did:web — it must be the endpoint's own, or the stock lane does not call it), capability? (the
rubric's; ignored — the oracle's developer owns the capability), send, timeoutMs?, usedBy
("gate:<code>" / "criterion:<code>" — every rule whose bound deps read this fact).
ManifestAi: expect: "boolean" (v1 checks are yes/no only), onError, instructions (the
plain-language rules — all must hold for a true), send, usedBy.
Every object is parsed loosely: the engine may add fields without a wire bump, so an unknown key is tolerated, never fatal.
The unusable answer. A rubric the engine cannot resolve or bind comes back as
{ status: "unusable", issues: [{ code, message, path }] } — a 200, not an HTTP error. That is
recorded as the claim's outcome with no handler run and no decide call, and the collection is
paused rubric-unusable. There is nothing to build against until the rubric or its #vct/#rub
anchoring is fixed.
9. Wire facts, and what validateFacts refuses
These are the shapes that ride to the engine inside a FactSet v1. The FactSet itself is strict
(the engine 400s on an unknown key) and is assembled by the package around your answer: version,
frozenAt (identical to ctx.now), answers, attachments, ctx, ext, ai, unique?,
frequency?, settings.
| Shape | Fields | Notes |
|---|---|---|
ExtFact (ok) | status: "ok", value: boolean | number, reason: string | null, confidence?, transcript? | Strict. confidence is integer basis points 0–10000 |
ExtFact (error) | status: "error", onError: "review" | "reject", detail: string, transcript? | Strict |
AiFact (ok) | status: "ok", value: boolean, reason: string | null, failedRules: string[], score: 0..100, confidence?, transcript? | Strict. value is derived in code, never the model's own |
AiFact (error) | same as ExtFact (error) | Strict |
UniqueFact | ruleId, digest, scoreBps: 0..10000, band: "pass" | "review" | "reject" | Loose — everything else you freeze (winner, candidates, aiOutcome, aiPairs, …) rides into the engine's trace |
FrequencyFact | ruleId, count: int, max: int, per?, windowStartISO?, windowEndISO?, breach: boolean, unresolved?: true | Loose. count/max compose the engine's reason text; breach/unresolved are its branches |
AttachmentFact | question, name, mediaType, cid: string | null, size: number | null | The package builds these; bytes never leave Matrix |
Transcript | request { url, body, headers }, response { status, body } | null, attempts: int, elapsedMs: int, latencyMs?, genId?, usage?, provenance? | Optional on the wire; the stock lanes always produce one. headers never carries a credential header and url has credential query values masked (http.json above); a response.body over 64 KiB is { truncated: true, bytes, sha256 }; response: null means no HTTP response at all |
Coverage: FactIssue codes
validateFacts mirrors the engine's own fact-coverage check and runs before anything is frozen
or sent, so a producer bug is loud here instead of a billed unusable there. runFacts returns the
same issues.
| Code | Raised when | Path |
|---|---|---|
FACT_MISSING | A declared ext/ai name was not produced; a declared unique/frequency rule produced no fact. For a frequency declaration the engine sent without max/per, the message is manifest lacks max/per | /facts/ext/<name> · /facts/ai/<name> · /facts/unique · /facts/frequency |
FACT_EXTRA | A name the rubric never declared is present (the engine would ignore it, but no rule can ever read it — so here it is a bug); a unique/frequency fact with no declared rule | same |
FACT_KIND | expect: "boolean" but the value is not a JS boolean; expect: "score" but the value is not an integer 0–10000 (never clamped); a unique/frequency fact naming a different ruleId than the manifest | same |
FACT_ON_ERROR | An error fact carries an onError other than the rubric's (only reachable from a whole-producer — the runner stamps it) | same |
FACT_SHAPE | The fact fails its wire schema; the ext/ai lane is not an object | /facts/ext · /facts/ai · per-name paths |
A failed validation is recorded as invalid_facts for the claim and fails the ingest loudly. It is
never sent.
10. Testing — @ixo/decision-oracle/testing
runFacts is the same runner production uses, minus the fact cache and D1, followed by the
same validateFacts. issues: [] means the engine would accept this FactSet.
runFacts(facts, input)
interface RunFactsInput {
claim: PulledClaim; // sampleClaim(...)
manifest: Manifest; // sampleManifest(...)
tools?: FactTools; // default: fakeTools()
ctx?: CtxFacts; // default: sampleCtx()
settings?: FactSettings; // default: { allowAiChecks: true, allowImageChecks: true }
/** Per-lane replacements for what an unlisted name — and `tools.stock()` — runs instead. */
stock?: Partial<{
ext: ExtHandler;
ai: AiHandler;
unique: UniqueHandler;
frequency: FrequencyHandler;
}>;
}
// → Promise<ProducedFacts & { issues: FactIssue[] }>Both policy lanes are open by default: a handler under test should run, not be gated by a policy the
test never mentioned. Pass settings to exercise the gated path.
The default stock lanes: ext and ai both call tools.stock() (so fakeTools({ stock }) is
what answers); unique throws no stock unique lane in tests — pass stock.unique or a facts.unique handler; frequency returns the fail-closed unresolved fact.
runFacts also binds tools.attachments.on and tools.ai.inputsFor to the claim you passed (they
are pure functions of it, and fakeTools has no claim). Only the fakeTools stubs are replaced —
an implementation you wrote yourself survives.
A groups entry runs here exactly as it does in production: declare every grouped name on the
sampleManifest, assert each one's fact, and test the half that matters — the failed call, where
one fact.error has to come back for every name the group owed.
fakeTools(overrides?)
| Override | Type | Default |
|---|---|---|
http | Record<string, FakeHttpRoute> | {}. A matched URL answers its value as the body under a 200; an httpResponse(status, body) envelope sets the status; a function of init returns either. An unmatched URL is a network failure: { ok: false, status: null, body: null } with attempts: 0 — exactly what the real lane produces for a URL it refused to call |
stock | ExtFact | AiFact | (() => …) | { status: "error", onError: "review", detail: "stock lane is not available in tests" }. There is no external source and no model behind a test; the runner re-stamps the manifest's onError over it, exactly as in production |
ai.judge | (instructions, inputs, opts?) => AiFact | fact.ai.ok(true) |
attachments | Record<string, Uint8Array> | {}, keyed by the attachment's url. bytes → { ok: false, reason: "unfetchable" } when absent; size → the byte length or null |
c2pa | Provenance | null | null |
agreement | Agreement | null | null |
chain | { linkedResources?: Record<string, LinkedResource[]>; claimIntent?: ClaimIntent | null | ((q: ClaimIntentQuery) => ClaimIntent | null); query?: QueryClient } | An entity DID the map does not name anchors [] (what the real read answers); claimIntent is null — an agent that filed nothing — unless you supply one, and a function is handed the ClaimIntentQuery so a test can assert on what was asked (or throw, to stand in for a blocksync outage, which a handler must let through rather than read as "no intent"); query() throws "not available in tests" unless you supply a client |
env | Record<string, unknown> | {} — reaches the handler as tools.env |
Plus, always: logs: LogRecord[] collecting everything the handler logged ({ level, msg, data? },
in order); chainCalls: ChainCall[] collecting every tools.chain call in order
({ call: "linkedResources", entityDid } / { call: "claimIntent", params } / { call: "query" }),
so a test can assert on the entity its handler read and on the intent query it asked
(ClaimIntentQuery — { agentAddress, collectionId, before, after? }, exported from
@ixo/decision-oracle/testing); a real config from the same Zod schema the Worker validates at boot (so
tools.config.IXO_NETWORK is "devnet", not a stub with holes); and http.json(url, { ucan }) /
http.ucanHeaders producing a readable fake token (Bearer fake-ucan-for-<audience>[-<can>-<with>],
the audience did:web:<url host> for ucan) so a test can assert on the audience and capability
its handler addressed — an IXO service capability throws there exactly as in production.
httpResponse(status, body) is a branded envelope — it exists precisely so that
{ body: "hello", sender: "bob" } reads as a body, not a status/body pair.
sampleClaim(answers, opts?)
| Option | Default |
|---|---|
claimId | "claim1" |
collectionId | "1" |
agentAddress | null (the dup/rate lanes key on it) |
attachments | [] — each entry is { question, name, mediaType, url?, cid? }; url defaults to "", and url is what fakeTools({ attachments }) is keyed by, so give each one its own as soon as more than one needs bytes |
network is "devnet", lane is "cellnode" and media is null (the no-store posture: nothing
to fetch bytes from in a test), and the claim is an ordinary unpaid, non-escrow one.
sampleManifest(decl?)
Only expect (a source) and instructions (a check) must be given; everything else is the call
detail the stock lane would need, which a handler under test usually ignores.
| Key | Default filled in |
|---|---|
ext[name] | onError: "review", endpoint: "https://source.example/<name>", audience: "did:web:source.example", send: {}, usedBy: [] |
ai[name] | expect: "boolean", onError: "review", send: {}, usedBy: [] |
unique / frequency | key present only when declared — the runner produces such a fact iff it is there |
fields / derived / ctx | [] |
rubricId is 64 zeros, formProof is undrifted, hasAiChecks is derived from the ai keys.
sampleCtx(over?)
now: "2026-01-01T00:00:00.000Z", an empty collection window and an empty claim plane — one fixed
instant, so digests repeat. Override any field.
Three worked tests
An ext handler over a canned route. Cover both halves — the answer, and the failure it will actually meet:
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
| Binding | Type | What it backs |
|---|---|---|
DB | D1Database | Every ledger: watched collections, claim ingests, decisions, reviews, the duplicate registries, the fact cache, Matrix delivery markers. Migrations ship with the package — point migrations_dir at node_modules/@ixo/decision-oracle/migrations and never run drizzle-kit |
CACHE_KV | KVNamespace | Caches only: UCAN principals, peer did:web documents, the engine-registered marker, the rubric manifest (5 min), chain grants, claim-bot tokens, the C2PA trust list. Nothing here is a source of truth |
API_RATE_LIMITER | RateLimit | The coarse per-IP guard on the whole API. limit/period live in wrangler.jsonc, not in code |
EMAIL | SendEmail (optional) | Cloudflare Email Sending for pause/resume/review notices, the API lane's member and review emails, and the balance.low email. Absent ⇒ the email lane no-ops |
API_KEY_RATE_LIMITER | RateLimit (optional) | The Decision API lane's per-key / per-source limit — its own namespace, read only with API_LANE on |
API_UPLOADS | R2Bucket (optional) | The Decision API lane's uploads. Absent ⇒ PUT /v1/api/uploads answers 501 UPLOADS_DISABLED; URL attachments still work |
ANSWERS_ARCHIVE | R2Bucket (optional) | The answer archive (guide §12, "The answer archive"): every evaluated ask and answer under <askId>-<slug>/ — about.json, then per claim answer.json, claim.json (the body verbatim) and the attachment bytes, each with custom metadata; shaped by OracleConfig.archive. Bound ⇒ on; absent ⇒ nothing is archived. Fail-soft: an R2 failure never fails an evaluation. Holds exact locations and photos — keep the bucket private |
METRICS | AnalyticsEngineDataset (optional) | Workers Analytics Engine: request, upstream, cron and cache metrics you can chart as p50/p95/p99 and rates (guide §12). Absent ⇒ every metric is a msg = "metric" log line only |
CLAIM_WORKFLOW | Workflow | ClaimIngest — one instance per claim |
REVIEW_WORKFLOW | Workflow | ReviewComplete — the review fold |
HIRE_WORKFLOW | Workflow (optional) | OpenhandsHire — one instance per hired OpenHands collection. Required with openhands on |
OPENHANDS_RATE_LIMITER | RateLimit (optional) | The OpenHands webhooks' limit, spent per caller address (an IPv6 caller by its /64) and per collection. Required with openhands on outside ENVIRONMENT=development (unbound ⇒ 503) |
SEQUENCE_MANAGER | DurableObjectNamespace | The SDK's SequenceManagerDO: one instance per signer, allocating account sequence numbers so concurrent chain writes never race |
Your own bindings ride alongside — the type is open (OracleEnv & YourEnv); only the names above
are reserved.
Vars and secrets (OracleVars)
Non-secret vars live in wrangler.jsonc vars, redeclared per named environment (named envs
inherit no bindings or vars). Secrets live in .dev.vars locally and wrangler secret put <NAME>
once deployed. Every value is validated by Zod at isolate start, so a bad env fails loudly and early.
| Var | Where | Default | Meaning |
|---|---|---|---|
ENVIRONMENT | vars | development | development | staging | production. development is the only one where an external source may be plain http |
LOG_LEVEL | vars | info | debug | info | warn | error |
LOG_PRETTY | .dev.vars only | false | Colorized local logs. Deployed envs must emit JSON — that is what Workers Logs ingests |
SERVICE_NAME / SERVICE_VERSION | vars | eval-oracle / 0.0.0 | Log and /health identity |
CORS_ORIGINS | vars | * | Comma-separated origins, or * |
CRON_COLLECTIONS_PER_TICK | vars | 25 | Watched collections visited per cron tick. Only DUE rows rotate (nextScanAt): each visit parks a collection by what Blocksync says — closed, or ended more than a day ago, for a week; never claimed for a day; otherwise an hour (a full page stays due) — and a webhook scan or a claims-feed hit makes it due again. A fleet larger than this rotates in across ticks, least-recently-scanned first |
IXO_NETWORK | vars | devnet | devnet | testnet | mainnet — supplies eight URLs below |
EVALUATION_DENOM / EVALUATION_MIN_AMOUNT | vars | unset — required when IXO_NETWORK is mainnet | The evaluation fee for this network: never upay on an oracle IXO does not run (register refuses it 409 pay_requires_ixo_managed; mainnet third parties charge USDC.inj ibc/77C12480BD8C29BF0ACEC2143736C15BAF8CD232CA27BAE09336677B828955E6 or uixo). the denom a collection's Payments.evaluation must be in (matched exactly) and its minimum, an integer base-unit string. Set both or neither (one alone fails at isolate start). When set they replace pricing.evaluation at register and on /v1/info; a pricing.byProtocol entry still wins for its protocol. No default in code: on mainnet an unset pair fails config validation at isolate start, so a stand-in coin written in defineOracle can never be charged there. The template sets uixo / 1000000 on devnet and testnet and Noble USDC (ibc/6BBE9BD4246F8E04948D5A4EEE7164B2630263B9EBB5E7DC5F0A46C62A2FF97B, 6 decimals) / 1000000 on production |
BLOCKSYNC_GRAPHQL_URL | vars | per network | Override only for a self-hosted indexer |
CHAIN_RPC_URL | vars | per network | The SDK query client and the chain write |
MATRIX_HOMESERVER_URL | vars | per network | Fallback home server when an entity declares none |
UCAN_STORE_URL | vars | per network | Where an owner→oracle delegation is fetched from |
VFS_URL | vars | per network | The IXO Virtual Filesystem — the first claim-body stores: the collection's claims lane (read under your Evaluate grant), then public copies |
IPFS_GATEWAY_URL | vars | per network | The IPFS gateway a claim body is read from when neither the VFS nor Matrix has it |
PORTAL_BASE_URL | vars | per network | Deep links in notices. No trailing slash |
EVAL_ENGINE_HOSTNAME | vars | per network | The engine you ask for decisions. A host you call — its did:web is the invocation audience. Set it for a local engine or a tunnel |
CLAIM_BOT_URL_OVERRIDE | .dev.vars | derived | Local harnesses only — the bot URL is normally derived as https://claim.bot.<homeserver domain> |
IXO_CLIENT_SEQUENCE_MIN_DELAY_MS | vars | 1000 | Read by the sequence keeper off env: the minimum gap between two sequence allocations |
ORACLE_HOSTNAME | vars | required | Your canonical did:web host. Every inbound invocation's audience is pinned to this, never the request Host; it is also the issuer of every invocation you mint. It must equal the Custom Domain in routes for that env, or, with no routes, the env's <worker>.<account>.workers.dev host |
ORACLE_DID | vars | required | did:ixo:<address> — the runtime chain identity |
ORACLE_ADDRESS | vars | required | Must be the ORACLE_SECP_MNEMONIC wallet's address — asserted at first use, fails loud |
ORACLE_MATRIX_USER_ID | vars | required | The MXID derived from ORACLE_DID (cross-checked, never re-derived) |
ORACLE_ENTITY_DID | vars | unset | The on-chain oracle entity (did:ixo:entity:…, from oracle init) — a different DID from ORACLE_DID. Required for a deployed oracle: unset while ORACLE_HOSTNAME is not local (localhost, 127.0.0.1, *.localhost), readiness fails its identity check (/health/ready 503) — the worker still boots, so oracle init can read it. The portal resolves your API from its #eval service, so the Matrix review card names it. Unset (or "") ⇒ falls back to ORACLE_DID: the card is still delivered but can only be answered from the portal's claim page |
AI_CHECK_MODEL | vars | openai/gpt-6-luna | The pinned model for AI checks (any provider — the gateway's compat route takes the same id) |
AI_PROVIDER | vars | cloudflare | cloudflare: every AI call goes through the Cloudflare AI Gateway via the AI binding ("ai": { "binding": "AI" }), paid from Unified Billing credits or the gateway's stored keys. openrouter: the OpenRouter API with OPENROUTER_API_KEY |
AI_REASONING_EFFORT | vars | low | low / medium / high — the judge's reasoning effort on every AI call, either provider. Higher thinks more: slower answers and more output tokens, so measure latency and cost before raising it |
AI_GATEWAY_ID | vars | unset | The gateway the cloudflare provider routes through. Unset (or no AI binding) ⇒ an AI fact throws AppError.internal at use |
AI_AUDIO_VIDEO | vars | false | Send audio/video attachments to the model. OpenRouter only: on with any other provider, config refuses to load. Off ⇒ each reaches the model as a "content not sent" descriptor (a change from earlier versions, where OpenRouter always got them) |
AI_CHECK_MIN_CONFIDENCE | vars | 60 | A verdict scoring below this is the unreachable path (an error fact), not a trusted answer |
AI_IMAGE_DETAIL | vars | high | low / high / auto — the image_url.detail every image rides to the model at (an AI check's photos, the same-event judge's pair evidence). high because a photographed screen (moiré, a pixel grid, a cursor) or a generated image (garbled text) is only visible at full resolution. Cost: high spends more image tokens per call than low's flat handful — set low only when no check turns on fine detail |
C2PA_IMAGE_CHECK | vars | false | The provenance gate in the stock AI lane. Needs policies.allowImageChecks on too |
AUTO_SIGN | vars | true | Sign MsgEvaluateClaim unattended. Needs policies.autoSign on too |
API_LANE | vars | false | Mount the Decision API router (/v1/api). Off ⇒ the lane is dormant and the chain lane runs exactly as before |
API_PAYMENTS | vars | false | The x402 gate. Off ⇒ every new submit answers 503 SOURCE_PAUSED { reason: payments-disabled } — nothing is accepted unpaid |
API_PRICE_USD_CENTS | vars | unset | The fallback price per decision in whole USD cents, for every protocol pricing.api does not price (the config wins). Unset with no pricing.api ⇒ price: null and the gate refuses loudly at use |
X402_FACILITATOR_URL | vars | unset | The x402 facilitator (verify + settle). No trailing slash. Required when API_PAYMENTS is on — config refuses to load without it |
X402_NETWORK | vars | unset | The chain every 402 names, CAIP-2: cosmos:<chain-id> (cosmos:devnet-1, cosmos:pandora-8, cosmos:ixo-5) |
EARNINGS_ADDRESS | vars | unset | Where your earnings for decisions land in PAY and USDC on IXO: your own ixo1… account, sent as each offer's payTo (the facilitator checks it, never picks one). Checked at start, checksum included. Needed only when IXO's plan for the oracle takes PAY (the engine's GET /v1/oracles/me accepts holds upay; KV-cached 5 min): unset then ⇒ new x402 submits answer 503 SOURCE_PAUSED { reason: payments-disabled }. A plan without PAY (card and/or USDC on Base) sends no ixo-pay terms and never reads it; a plan the engine cannot tell is the same 503 |
EARNINGS_BASE_WALLET | vars | unset | Your Base wallet (0x…) for USDC on Base. Unset ⇒ that way to pay is not offered |
BILLING_ENGINE_URL | vars | unset | Charge lookups and the spend figures on /v1/api/me. No trailing slash |
CONSOLE_URL | vars | unset | The Decision Console — every error's docsUrl, the 402's fundingUrl, and every email link (the balance.low email's is <CONSOLE_URL>/billing/credits). Unset ⇒ the API lane's emails are logged skips. No trailing slash |
AUTH_HUB_URL | vars | per IXO_NETWORK (https://dev.auth.ixo.earth, https://test.auth.ixo.earth, https://auth.ixo.earth) | IXO AuthHub, the OAuth server of /v1/mcp: the protected-resource metadata's authorization_servers, the exact iss of an access token (its JWKS at <url>/.well-known/jwks.json), the approve link's host, and the one did:web (did:web:<its host>) the approvals back-channel accepts. Set it only for a local AuthHub. No trailing slash |
ORACLE_ADMIN_DIDS | vars | unset | Comma-separated did:ixo admins of this oracle — the oracle's operator's own people — allowed on GET /v1/admin/status and /v1/api/admin/*. Unset ⇒ nobody |
STATUS_READER_DIDS | vars | unset | Comma-separated DIDs of any method (did:web:…, did:ixo:…, each checked as did:<method>:<id>) allowed to read GET /v1/admin/status and nothing else — for a monitoring service, e.g. your decision engine's status board, identified by its DID. Kept apart from ORACLE_ADMIN_DIDS so a monitor is never made an operator. Unset ⇒ nobody |
SERVICE_ED25519_MNEMONIC | secret | — | The did:web key: sha256(mnemonic) → Ed25519 seed. Signs every UCAN invocation you mint and backs /.well-known/did.json |
ORACLE_SECP_MNEMONIC | secret | — | The chain account key (secp256k1) that signs MsgEvaluateClaim. Unset ⇒ the chain-write lane logs one line and skips — the decision itself is unaffected |
OWNER_DELEGATION_CAR | secret | — | The oracle owner's UCAN delegation to the engine (base64 CAR), sent once at register so the engine can find and bill their subscription. Its issuer is the oracle's owner, who may not review (oracle-owner). Unset ⇒ register refuses, loudly |
ORACLE_MATRIX_ACCESS_TOKEN | secret | — | The oracle bot's Matrix token, produced by decision-oracle oracle init (its matrix step creates the account and writes this line into .dev.vars; wrangler secret put it for a deployed env). Unset ⇒ every notification lane logs one line and skips; no claim is ever blocked on it |
OPENROUTER_API_KEY | secret | — | Only with AI_PROVIDER=openrouter: required when a rubric asks for an AI fact (or a unique rule with its judge on) |
UNIQUE_HMAC_KEY | secret | — | Keyed digest for duplicate detection and for recording an API submit's submitter. Required when API_LANE is on (the worker refuses to start without it) and when a rubric declares unique. One long-lived secret per oracle — rotating it orphans every registered digest |
WEBHOOK_ENCRYPTION_KEY | secret | — | Encrypts the Decision API lane's bearer secrets at rest (v1: AES-256-GCM): webhook signing secrets (every endpoint's and each source's own), every verified PAYMENT-SIGNATURE (on api_charges.paymentHeader and the KV x402-paid: copy — it embeds the payer's reusable payment delegation; rows written before 2026-10-14 are sealed by the hourly housekeeping job header-seal, and read either way meanwhile) and each source's review-billing delegation. One per deployment, for life — rotating it strands every stored secret. Required when API_LANE is on: config load fails without it |
SENTRY_DSN | secret | — | Unset ⇒ error reporting is a no-op |
SENTRY_RELEASE | stamped by your deploy | — | Never in wrangler.jsonc |
SENTRY_TRACES_SAMPLE_RATE | vars | 0.1 | Share of requests Sentry traces, 0–1 |
SENTRY_ENVIRONMENT | vars | ENVIRONMENT | The Sentry environment tag — set it to tell apart two deploys that share an ENVIRONMENT (testnet, mainnet) |
OPS_ALERT_WEBHOOK_URL | secret | — | A Slack-compatible incoming webhook for ops alerts — one message per condition per 30 minutes (guide §12). Unset ⇒ alerts reach Sentry only |
EMAIL_FROM_ADDRESS | secret | — | Unset ⇒ the whole email lane no-ops (the same "unset ⇒ off" contract) |
EMAIL_FROM_NAME | secret | the oracle's name | Display name on outbound notices |
After any binding or var change: bun run cf-typegen.
The identities behind those vars
Four identities, three of which have on-chain or homeserver state that the vars alone do not create.
decision-oracle oracle init is what creates that state, once per network — thirteen idempotent steps
(fund, iid, key, matrix, entity, orz, profile, domain, acard, linked, service,
backlink, index), a dry run unless --yes, --only <step> for one. Only iid, key and
matrix need the oracle's own mnemonics; the rest run on the AuthHub session and the public vars. A full on-chain identity
is mandatory: the entity, its #pro profile, its #dmn domain card and its #acard Agent Card
(validated against the engine's Agent Card schema). --logo <url> is optional and recommended —
without it the portal and the console show a placeholder. --openhands adds a check-openhands
service to the Agent Card (the OpenHands app's checker marker), priced in PAY from /v1/info's
pricing.api for --protocol, else --price-pay <n> (1 PAY = 1 USD).
Where the documents live. Every document the CLI anchors — oracle init's #orz, #pro,
#dmn, #acard, publish-protocol's #vct / #rub / #pro (--name), org create's #pro — is stored on the
IXO Vault (POST <vfs>/api/fs/files?public=true under a one-write fs/write UCAN from the AuthHub
session's Ed25519 key, the entity owner) and anchored by its public, unauthenticated
<vfs>/public/cid/<cid> URL. A Vault refusal prints one ⚠ line and the document goes to the IXO
IPFS gateway instead (https://ipfs.gateway.ixo.world/ipfs/<cid>); nothing is uploaded to Matrix
media any more. The proof rules are unchanged: sha256 hex for #vct / #rub / #orz, the
bafkrei… CID for #pro / #dmn / #acard. An oracle init re-run moves an #orz, #pro,
#dmn or #acard still on Matrix media onto the Vault (same content, delete + add in one tx);
readers keep reading Matrix media (readLinkedDoc).
| Identity | What exists, and where | Who creates it | Why it matters |
|---|---|---|---|
did:web:<ORACLE_HOSTNAME> | /.well-known/did.json, served by this worker from the SERVICE_ED25519_MNEMONIC key | you, by deploying to that host | the issuer of every invocation this oracle mints, and the audience every inbound one is pinned to |
did:ixo:<ORACLE_ADDRESS> — its IID document and the Ed25519 verification method carrying the SERVICE_ED25519_MNEMONIC public key (authentication + assertionMethod) | on chain | oracle init, steps iid and key, signed by the oracle's own wallet (only its key may write its own document) | the claim bot, the UCAN Store and the VFS claims lane resolve this DID and check the invocation's signature against that key. Without the document, or without the key on it, all three answer 401 and no claim body can be pulled from Matrix or the VFS claims lane. The wallet also needs a balance (step fund) to sign MsgEvaluateClaim |
ORACLE_ENTITY_DID | the oracle entity on chain, with #eval, #orz, #pro, #dmn (domain card), #acard (Agent Card) and the protocol edges | oracle init, steps entity onwards, signed by the AuthHub session that owns it. Set (per network: env.<network>.vars, else top-level; oracle init writes it into .dev.vars the moment it creates the entity), it pins the entity for every later run — refused unless the entity is bound to this oracle; unset, oracle init finds THIS oracle's among the session's oracle entities by #orz granting ORACLE_ADDRESS, else #eval = --kit-url — never another oracle's | how the portal finds this oracle from a protocol, what the domain indexer / portal / console list it by, and what the Matrix review card resolves an API from |
ORACLE_MATRIX_USER_ID | the oracle bot's Matrix account on the network's homeserver | oracle init, step matrix — it also writes ORACLE_MATRIX_ACCESS_TOKEN into .dev.vars | the account the review-card lane posts as. No account, no token, and that lane is a logged skip |
Per-network defaults
IXO_NETWORK supplies all of these; any var set explicitly wins. A wrong network still fails
loud at the first did.json fetch.
| devnet | testnet | mainnet | |
|---|---|---|---|
| engine host | decision-engine.devnet.ixo.earth | decision-engine.testnet.ixo.earth | decision-engine.ixo.earth |
| blocksync | devnet-blocksync-graphql.ixo.earth/graphql | testnet-blocksync-graphql.ixo.earth/graphql | blocksync-graphql.ixo.earth/graphql |
| rpc | devnet.ixo.earth/rpc/ | testnet.ixo.earth/rpc/ | impacthub.ixo.world/rpc/ |
| rest | devnet.ixo.earth/rest | testnet.ixo.earth/rest | impacthub.ixo.world/rest |
| matrix | devmx.ixo.earth | testmx.ixo.earth | mx.ixo.earth |
| ucan store | devnet.store.ucan.ixo.earth | testnet.store.ucan.ixo.earth | store.ucan.ixo.earth |
| vfs | devnet.vfs.ixo.earth | testnet.vfs.ixo.earth | vfs.ixo.earth |
| ipfs gateway | ipfs.gateway.ixo.world | ipfs.gateway.ixo.world | ipfs.gateway.ixo.world |
| portal | dev.portal.qi.space | test.portal.qi.space | portal.qi.space |
| authHub | dev.auth.ixo.earth | test.auth.ixo.earth | auth.ixo.earth |
| facilitator | facilitator.devnet.ixo.earth | facilitator.testnet.ixo.earth | facilitator.ixo.earth |
| relayer node | the network's relayer entity DID (used by the publishing commands only) | — | — |
facilitator is the CLI's only (the audience api review-billing enable signs to, as the Decision
Console's preset does); the worker's facilitator is X402_FACILITATOR_URL, never this row.
NETWORKS[network] and networkDefaults(network) expose the same row to your code and to the CLI's
--network flag.
The policy triangle
Three switches decide what actually runs. Each has a config half (defineOracle) and, for two of
them, an env half — both must be on.
| Behaviour | Config | Env | Off means |
|---|---|---|---|
Sign MsgEvaluateClaim unattended | policies.autoSign | AUTO_SIGN | The decision is still recorded and receipted; submitting it is a human's job |
| Run AI checks at all | policies.allowAiChecks | — | Every AI fact freezes error / ai-checks-disabled with no model call, and the rubric's onError carries. The duplicate lane's judge is skipped too |
| Screen images for Content Credentials | policies.allowImageChecks | C2PA_IMAGE_CHECK | The stock AI lane makes no provenance read. tools.c2pa.screen() still works — a handler asking for the read gets it |
12. What the package does at runtime
Everything in this section already exists. Do not rebuild any of it in a handler.
HTTP routes
Middleware order is fixed: observability → database → secureHeaders → cors → rateLimit → bodyLimit → prettyJSON → routes → onError/notFound. The body cap is 1 MiB. Every /v1/* route requires a
UCAN invocation addressed to did:web:<ORACLE_HOSTNAME> (never the request Host) unless it is
on the explicit public allowlist. It is signed by the caller's own key — a delegated chain (root ≠
invoker) is 401 — and carries exactly ONE capability, { with: "ixo:eval-oracle", can: <action> },
where the action is the route's own, named in the Auth column below (src/middleware/capability.ts
ROUTE_ACTIONS; HEAD takes its GET's). A different action is 403, and so is any action on a
route with no entry; a can that is none of the table's actions is 401 before it is validated. The
legacy can: "*" is still accepted on every route while its callers switch, and each use is logged
at info as ucan.wildcard (route, invoker) and counted as the ucan.wildcard metric (route
only) — a strict release will refuse it. The package's own CLI (register, scan, relink, erase) signs each route's action; send the
route's action from your own clients too.
An invocation is short-lived: it must carry an exp at most 600 s ahead, and when it states an
nbf, exp − nbf may not exceed 600 s either (an invocation has no issued-at, so a far-future exp
would otherwise pass in its last 10 minutes) — each is 401 ("valid for too long — at most 600 s",
"it has no expiry"). The CLI signs 120 s. Replay protection is off: a token may be reused within its
window, and the verified token is cached (in-isolate and KV) no longer than that window.
That proves key possession, audience and the action — authorization is each route's own identity
check.
| Route | Auth | Gate |
|---|---|---|
GET /health | public, rate-limit exempt | — |
GET /health/ready | public, rate-limited (per IP) | { ok } only — 200 when every critical check passes, 503 otherwise. Checks d1, kv, engine (its /.well-known/did.json) and, with API_LANE and API_PAYMENTS on, facilitator (X402_FACILITATOR_URL/supported), in parallel, 2 s each, plus identity (a config read: fails when ORACLE_ENTITY_DID is unset on a non-local ORACLE_HOSTNAME) and owner-delegation (fails when OWNER_DELEGATION_CAR is set but is not a UCAN delegation; unset passes); the settled answer is cached per isolate for 15 s. A failure before the route runs (a config that does not load) is also 503 { ok: false }. Names no check — the detail is /v1/admin/status's |
GET /.well-known/did.json | public, rate-limit exempt | Needs SERVICE_ED25519_MNEMONIC |
GET /.well-known/oauth-protected-resource/v1/mcp | public, rate-limit exempt, Access-Control-Allow-Origin: *, cache-control: public, max-age=300 | RFC 9728 metadata for the MCP server: { resource: "https://<ORACLE_HOSTNAME>/v1/mcp", authorization_servers: [AUTH_HUB_URL], scopes_supported: ["claims:write", "claims:read"], bearer_methods_supported: ["header"] }. 404 unless API_LANE is on. Outside the /v1/api contract |
GET /v1/info | public (the only public /v1 path) | —. The marketplace listing: name, description, did, address, entityDid (the on-chain oracle entity from ORACLE_ENTITY_DID, null when unset), serviceDid, supportedProtocols, protocols (the same list readably: [{ did, name, fee }] — name the protocol's #pro profile name, else its claim form's title, cached 1 h, null when neither reads; fee the { denom, minAmount } register charges for it), pricing, policies, earnings: { address, baseWallet } (the operator's EARNINGS_ADDRESS / EARNINGS_BASE_WALLET, null when unset — read-only, set in the oracle's config), payouts: { decisionToPay, operatorAmount } — how a payout amount is chosen, for a portal to show before a company opts in: decisionToPay is the config's payouts map verbatim (per protocol DID, { amount: <answer path>, currency: <ISO 4217 code> | { path } | { asset, decimals } }; {} when none, and a protocol absent from it gets no decision to pay), operatorAmount is { summary } from policies.actSummary when an act policy is configured, else null (actSummary is served only here, never under policies), and reviewBilling: { mode, pricePerReview } — how a collection pays for its reviews: mode: "x402" (per-review billing ACTIVE: IXO's plan for the oracle allows it — the engine's GET /v1/oracles/me reviewBilling, KV-cached 5 min — on an oracle running API_LANE: per review from the company's credits, an empty evaluation leg) with pricePerReview: { denom: "upay", amount } the published per-review price (the one the supported protocols share, else pricing.api.amountMicroUsd / API_PRICE_USD_CENTS; null when none is published), or mode: "escrow" (the collection's on-chain evaluation payment, pricing) with pricePerReview: null. cache-control: public, max-age=300. With openhands set, also openhands: { hire: "/openhands/hire/:collectionId", scan: "/openhands/scan/:collectionId" } — absent on any other oracle. A portal is built against these exact field names |
POST /openhands/hire/:collectionId | public, OPENHANDS_RATE_LIMITER (per caller and per collection; unbound outside development ⇒ 503), openhands on only | Starts the collection's hire (202 { started: true }) after reading that it exists and its admin granted this oracle a live meta-grant: 404 no such collection (not cached), 409 no meta-grant (cached 10 min). A hire in flight, or an ended one the chain says is healthy, is 200 { existing: true } (the healthy answer cached 1 min); an ended one the chain says is stale or low is rerun from the webhook's own budget (200 { restarted: true, reason }, three a day, then { throttled: true }). 400 a malformed id, 405 not POST, 503 when the chain or Blocksync cannot answer |
POST /openhands/scan/:collectionId | public, OPENHANDS_RATE_LIMITER, openhands on only | Scans one watched collection now: 200 { started }, 202 { coalesced: true } within a minute of the last (scanned on the next minute tick), 404 not watched, 409 paused. A 200 { started: 0 } (or a failed scan) is retried on the next three minute ticks |
POST /v1/collections/register | UCAN collections/register | The four gates, in order: who (the collection entity's chain owner, or a holder of an active on-chain EvaluateClaimAuthorization — read uncached) · hired (this oracle's ORACLE_ADDRESS holds that grant too, else 409) · protocol (in supportedProtocols, else 409 — checked before payment) · paid (Payments.evaluation matches pricing, else 402; while per-review billing is active (IXO's plan), the collection's company — ONE engine read, GET /v1/collections/:id/company, its blip or a disputed company 503 company_unavailable — holding a live review-billing delegation on its Decision API source, still signed by the organisation's current chain owner, with a published price and cap left, registers with an EMPTY evaluation leg (billingSourceId set, never billed twice) once the payer's credits cover one review: GET /v1/balances with the stored delegation in X-Payment-Delegation, both available and delegationRemaining ≥ the price, else 402 insufficient_credits (details.price, details.fundingUrl), an unreadable answer 503 credits_unavailable; no usable delegation ⇒ the escrow gate as before, its refusal 402 no_review_billing naming both ways in). Body: { collectionId, delegationCar?, contactEmail? }. No contactEmail sent and none stored ⇒ the row takes the contactEmail of the oldest-activated active Decision API source whose companyEntityDid is the collection's COMPANY as the engine derives it (GET /v1/collections/:id/company, UCAN collection/company-read: the collection's entity, or the org a deed is linked to both ways); a disputed company, a collection the engine does not know, or none such source ⇒ stays empty. Its 503 or any other failure never fails the register (logged register.company-unavailable, the row stays empty for a later register to fill). An explicit or stored contact is never overwritten. The SAME answer (one engine read per register) links the collection to every active Decision API source of that company, so its chain claims read with the source's (contracts 1.21.0: LinkedCollection.linkedBy = the company's entity DID, audit source.collection_linked by system:company-link, re-checked hourly against the engine and dropped not_company when it names another company or disputed; only such a positive finding hides a link — an unreachable engine keeps it in the reads, contracts 1.23.0); idempotent, and only the engine's ok links. The engine is asked only when no contact is sent or stored, or an active source is not yet linked to the collection. Every refusal carries a stable machine code in details.reason beside the human message: 404 collection_not_found, 403 owner_unresolvable · not_owner_or_grantee · contact_change_forbidden · resume_forbidden, 409 not_hired · unsupported_protocol, 402 evaluation_underpaid · no_review_billing · insufficient_credits, 503 company_unavailable · credits_unavailable. |
GET /v1/collections | UCAN collections/read | The rows the caller owns or enrolled |
POST /v1/collections/:id/pause | UCAN collections/pause + read gate + owner, enroller (while it holds a live grant) or ORACLE_ADMIN_DIDS (a plain grant holder is 403) | Sets status: paused, clears the reason — the cron enqueues nothing |
POST /v1/collections/:id/resume | UCAN collections/resume + read gate + owner, enroller (while it holds a live grant) or ORACLE_ADMIN_DIDS (a plain grant holder is 403) | Clears whichever reason the engine set; sends the paired "resumed" notice on a real flip |
POST /v1/collections/:id/scan | UCAN collections/scan + read gate | Runs the cron's per-collection pass now. 409 if paused. Same dedupe and cursor rules, so an eager caller can never double-start a claim |
PATCH /v1/collections/:id/settings | UCAN collections/settings (never *) + the collection's current chain owner (the entity's owner, read live off Blocksync; no enroller, grant holder or ORACLE_ADMIN_DIDS) — 403 otherwise, 404 unwatched | { operatorAmounts: "off" | "allowed" } (strict). Whether the act seam's on-chain amount (policies.act, signEvaluation) may stand on this collection: "off", the default, ⇒ the lane pays its default and logs act.amount-ignored-not-opted-in. Recorded with the owner who set it: "allowed" stands only while that address still owns the collection's entity, so after a transfer it reads "off" until the new owner sets it. Answers the collection row, as GET /v1/collections does |
GET /v1/collections/:id/settings | UCAN collections/settings-read (never *) + the collection's current chain owner, as the PATCH — 403 otherwise, 404 unwatched, 503 on chain trouble | { operatorAmounts, operatorAmountsBy, effective }: what the owner set, the address that set it, and whether it is in force NOW — effective is the act lane's own reading (operatorAmountsEffective, lib/ixo/chain-write.ts): true only when operatorAmounts is "allowed" and operatorAmountsBy is the live owner, so after a transfer the value reads "allowed" with effective: false until the new owner says yes |
GET /v1/claims?collectionId=&limit=&cursor= | UCAN claims/read + read gate | Summary list, newest first, keyset-paginated (limit default 100, max 500) |
POST /v1/collections/:id/reviewers | UCAN collections/reviewers-grant (never *) + a current controller of the collection's entity (read live off Blocksync), who must be the grant's issuer | { delegation } — a base64 CAR the controller signed: a reviewer grant (below). Checked as the review gate checks it (no revocation step), then filed in collection_reviewers. A controller may name themselves, and a grant to the collection's owner is filed like any other: one person may post an ask and review it (the owner's seat in the review room only lets them see reviews; answering always takes a grant). 201 + the grant row (memberDid, rootDid, delegationCid, delegation, expiresAt, revokedAt, createdAt). The same CAR again answers the same row; a revoked one is 409 (sign a new one). 400 REVIEWER_GRANT_INVALID / 403 REVIEWER_NOT_AUTHORIZED as the gate, 404 unwatched |
GET /v1/collections/:id/reviewers | UCAN collections/reviewers-read (never *) | A current controller: { reviewers: [...] }, every grant on the collection; a reviewer: only their own, CAR included (the Portal sends it with an answer); anyone else 403 |
POST /v1/collections/:id/reviewers/:delegationCid/revoke | UCAN collections/reviewers-revoke (never *) + a current controller | Stamps revokedAt (once) and kicks the reviewer from the collection's review room. Every later answer and every pending fold under that grant is refused. 404 for an unknown grant |
GET /v1/claims/:claimId | UCAN claims/read + read gate, or a live reviewer of the claim's collection (a filed, unrevoked grant inside its window whose root is still a controller — read live, so Blocksync down is 503), or the claim's own submitter (matched by EITHER the claim's on-chain agent DID or its agent address, invoking as themselves; one Blocksync claim read, cached a day — a claim Blocksync does not know yet is not cached — tried only after the other doors; Blocksync down is 503) | The full record: ingest ⟕ decision ⟕ review. The submitter sees the decision and its public receipt (which names who reviewed), but never the review's answers: review.answers and review.reviewerDid come back null, and so do the internal failure texts ingest.error, decision.chainError and decision.erroredFacts. 404 when this oracle has not seen the claim. The submitter gets no other read: not the list, not /v1/reviews, not another claim. Plus checks — the breakdown: every check the decision was made on, [{ name, status, value, reason }] (reason the checker's own words, a passed AI check's included; an error's detail), never a transcript or a prompt, null when the claim has no decision or its row has no facts. The submitter's copy is cleaned: URLs, emails, DIDs, CIDs and other identifiers, exact coordinates, phone-shaped and long numbers, plate-shaped tokens and long quotes become [hidden], capped at 280 characters — they read why, not another claim's ids or someone's details. Plus reviewerNote (policies.reviewerNote) for every reader but the submitter, who gets null |
DELETE /v1/claims/:claimId/body | UCAN claims/erase (never *) + the collection's current chain owner (read live off Blocksync; the enroller, grant holders and ORACLE_ADMIN_DIDS are 403) — 404 when this oracle has not seen the claim or it is a Decision API claim, 503 on chain trouble | 204; 409 CONFLICT while the claim is still queued or processing (erase it once it is decided). Erases the claim's answer copies now: strips every transcript from decisions.facts (the AI prompts and HTTP payloads; the fact values stay), nulls the duplicate registry's normalizedText, drops its fact-cache rows, and stamps claim_ingests.erasedAt. The decision, its receipt and the registry digests stay, so the receipt still verifies and duplicates are still caught. Idempotent: 204 again, the first erasedAt kept. Logged claims.erased, counted claims.erased (op: chain). The CLI's decision-oracle erase loops over it The reviewer note (reviewerNote) is erased with them |
GET /v1/reviews/:claimId | UCAN reviews/read + read gate, or a live reviewer of the claim's collection | The pending tasks, the stored answers, why a fold failed, erroredFacts (below; null when none errored), and checks — every frozen ext / ai fact the decision was made on, [{ name: "ai.judge", status: "ok" | "error", value, reason }], reason the checker's own words (a passed AI check's reasoning included; an error's detail), never a transcript or a prompt; null on a row with no facts (a facts-lane outcome). What a reviewer reads before deciding (the same list GET /v1/claims/:claimId serves). Plus reviewerNote — { says, checkThese[], model, createdAt } (policies.reviewerNote), null when none was made |
GET /v1/admin/status | UCAN admin/status + ORACLE_ADMIN_DIDS or STATUS_READER_DIDS (403 otherwise — everyone, while both are unset) | { readiness: { ok, checks: { <name>: { ok, ms, critical, detail? } } }, backlog: { review: { pending }, collections: { paused: { total, <reason>: n } }, sources?: { paused }, webhooks?: { due, oldestAgeS } }, engine: { billingStatus, manageSubscriptionUrl } | null } — sources/webhooks only with API_LANE on; engine is the engine's GET /v1/oracles/me, null when it did not answer within 2 s; detail is set only on a failed identity or owner-delegation check and names the command to run. Outside the /v1/api contract |
POST /v1/admin/collections/relink | UCAN admin/collections-relink (strict: never *) + ORACLE_ADMIN_DIDS (403 otherwise) | The operator's backfill of register's company links. Body { collectionIds?: string[] } (1–100 ids); absent ⇒ every watched collection, lowest id first, up to 100 (truncated: true says more exist — name them). Per collection, the same engine read and the same link register makes (no active source left unlinked ⇒ unchanged, no engine call) → { items: [{ collectionId, outcome: "linked" | "unchanged" | "no-company" | "disputed" | "unavailable" | "not-watched", linkedSources }], truncated }. Idempotent. CLI: decision-oracle relink [--collection <id>]…. Outside the /v1/api contract |
POST /v1/admin/claims/:claimId/reviewer-note | UCAN admin/reviewer-note (strict: never *) + ORACLE_ADMIN_DIDS (403 otherwise) | (Re)makes the reviewer note for a claim held for a person — one decided before the oracle made notes, or one whose note failed. Re-pulls the body through the claim's own lane, spends one model call, stores the note. 200 { claimId, written: true }, 503 { written: false } when the model gave none, 404 no decision, 409 not pending_review. The note itself is read on GET /v1/reviews/:claimId. CLI: decision-oracle reviewer-note <claimId>. Outside the /v1/api contract |
POST /v1/admin/claims/:claimId/redeliver | UCAN admin/redeliver (strict: never *) + ORACLE_ADMIN_DIDS (403 otherwise) | Releases a decided approve/reject with a receipt whose delivery is pending/blocked — made for the row the evaluate step's recovery parked without facts. With facts null it re-pulls the body through the claim's own lane, rebuilds the facts (buildFacts, the collection's protocol) and stores their ext/ai on the row with chainStatus: pending in one statement — the act policy's inputs only, never sent to the engine, whose decision and receipt stay as recorded — then runs the idempotent deliverChainEvaluation. 200 { claimId, outcome, factsDigestMatches, chainStatus, chainError, txHash } (factsDigestMatches null when the row had facts already, false when the rebuild differs from the engine's factsDigest, e.g. an AI fact answering differently — stored anyway); 404 no decision; 409 not a receipted approve/reject waiting for the chain, a rubric-unusable / invalid-facts rebuild (details.reason, details.issues; nothing written), or a protocol now pinned to another rubric than the decision's; transient trouble is a 503 to run again. CLI: decision-oracle redeliver <claimId> --yes. Outside the /v1/api contract |
GET /v1/oauth/approvals/:approvalId | UCAN oauth/approval/read, invoker AuthHub's did:web only (403 otherwise; * is never accepted here); no X-Auth-Type needed | What AuthHub's approve page shows: { did, grantId, clientName, protocol: { did, name | null }, price: { display, maxAmountUpay }, billingMode: "x402" | "none", status, expiresAt } (expiresAt in unix seconds). 404 unless the approval exists, is pending and has not expired. Never the delegation. 404 without API_LANE. Outside the /v1/api contract |
POST /v1/oauth/approvals/:approvalId | UCAN oauth/approval/approve, same invoker rule | { decision: "approve", did, delegation: string | null } or { decision: "decline", did } → { status: "approved" | "declined" }. 404 unless pending and did is the approval's. For billing x402 the delegation must be a payment delegation (parsePaymentDelegation) whose root issuer is did, whose oracles name this oracle's did:web, whose audience is this oracle's facilitator (X402_FACILITATOR_URL's did.json), whose maxAmount is exactly maxAmountUpay (a total across rails: more would be standing authority), and which has not expired — else 400 VALIDATION with details.reason one of invalid_delegation, issuer_mismatch, oracle_mismatch, facilitator_mismatch (also logged oauth.approval.facilitator_mismatch with both DIDs: AuthHub and this oracle name different facilitators), amount_mismatch, expired, and the approval stays pending. price.display is informational; AuthHub computes what it shows from maxAmountUpay. For none nothing is kept. Approving gives the app 15 minutes to use it. Audited as oauth.approval_approved / oauth.approval_declined (actor kind oauth). Outside the /v1/api contract |
GET /v1/oauth/standing/:grantId | UCAN oauth/standing/read, AuthHub's did:web only, never * | A grant's auto-approve: { perClaimUpay, totalUpay, spentUpay, until } (upay strings, unix seconds), never the delegation. 404 when there is none, it is revoked, until has passed, or policies.allowStandingApprovals is off |
PUT /v1/oauth/standing/:grantId | UCAN oauth/standing/set, same | 403 FORBIDDEN (details.reason: standing_approvals_off) unless policies.allowStandingApprovals. { did, delegation, perClaimUpay, totalUpay, until } replaces it: the same delegation checks (maxAmount = totalUpay, expiration ≥ until); did must be the grant's user as this oracle has seen it (its standing, else an approval) — else 404. spentUpay is never reset (the total is this auto-approve's total across every delegation): a total below it is 400 VALIDATION (details.reason: limits_invalid), nothing changed |
DELETE /v1/oauth/standing/:grantId | UCAN oauth/standing/revoke, same | { did }: deletes it, delegation and all; 200 whether or not it existed; another user's did is 404 |
POST /v1/reviews/:claimId | UCAN reviews/submit (never *) + the reviewer gate (below) — never the read gate: an evaluate grant alone does not answer | { answers: { taskId → boolean | string }, attestation, reviewerGrant } — the answers must cover exactly the pending tasks (all-or-nothing). 202 + the review row, and the ReviewComplete workflow spawns. Answered-once until folded: a re-POST before the fold replaces the answers and the grant and re-spawns (that is also how a foldError is cleared); after the fold it 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:
- 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:ixopublishes on chain; its capabilities are exactly[{ with: "ixo:oracle/<this oracle's did:web>/collection/<collectionId>", can: "claims/review" }](no wildcard); issuer and audience aredid:ixo:ixo1…accounts (the same one is allowed: a controller may grant themselves);nbfandexpare both set,exp − nbf≤ 30 days (UCANs carry no issued-at),nbf ≤ now + 60 s(the engine's clock skew) andnow < exp. - The reviewer — its audience is the invoking principal (= the attestation's
issuer). - Revocation — its canonical CID is a
collection_reviewersrow of this collection, not revoked. - No oracle — none of
ORACLE_DID,did:ixo:<ORACLE_ADDRESS>, the did:web orORACLE_ENTITY_DIDis in it (oracle), nor the oracle's owner — the issuer ofOWNER_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. - Live root — the issuer is a current controller of the collection's entity (uncached).
- No evaluator — neither the issuer nor the reviewer holds an
EvaluateClaimAuthorizationon the collection (read uncached): that power already signsMsgEvaluateClaimand moves the collection's payments, so it never also reviews.
POST /v1/collections/:id/reviewers runs 1, 4, 5 and 6, and requires the caller to be the issuer.
Every refusal carries details.reason in the engine's vocabulary — 400 REVIEWER_GRANT_INVALID:
absent, unparseable, extra-block, shape, audience, identity, capability, resource,
lifetime, not-yet-valid, expired, too-long, signature; 403 REVIEWER_NOT_AUTHORIZED:
root-not-controller, oracle, oracle-owner, evaluator, and the oracle's own revoked. A collection with no
entity is a 503 (fail closed). A fold refused either way stores foldError as
CODE (reason): message.
The collection's review room follows the grants that stand (unrevoked, in their window, root still
a controller): the hourly pass invites them and kicks the rest — never the owner; Blocksync down
skips the pass.
attestation is the reviewer's own signed statement over those answers — a compact JWS
ClaimReviewAttestation credential, signed with the key their did:ixo publishes on chain, whose
credentialSubject binds { claimId, oracleDid, answeredAgainst, answers }. It is required on
every review: a review answer IS a signed statement, and there is no flag and no unsigned path.
It is verified before anything is stored (issuer = the invoking principal first, then kid ∈ the
issuer's published keys, the signature, and each binding) and refused otherwise — a refused
attestation never replaces stored answers, which is what keeps a stored answer signed: replacing
one takes another signature. The bytes are kept verbatim and forwarded to the engine, which
verifies them again; the review row reports signed: true, and the JWS itself is never returned by
a read route. The fold's Matrix card edit then names the reviewer and the attestation's CID.
| Refusal | Status | When |
|---|---|---|
ATTESTATION_INVALID | 400 | Not a compact JWS / not a ClaimReviewAttestation / payload is not canonical JSON / the signature does not verify |
ATTESTATION_KEY_UNKNOWN | 400 | The issuer publishes no such key — including a DID that resolved to no document or to no Ed25519 method. Deterministic: retrying cannot change it |
ATTESTATION_ISSUER_MISMATCH | 403 | issuer is not the invoking principal |
ATTESTATION_MISMATCH | 400 | oracleDid / claimId / answeredAgainst / answers do not bind this review |
ATTESTATION_UNRESOLVABLE | 503 | The resolver could not REACH its source (Blocksync down, host unreachable). Transient — retry |
ATTESTATION_REQUIRED | 400 | No attestation on the request — refused before any lookup or write |
REVIEWER_GRANT_INVALID | 400 | No reviewerGrant (details.reason: "absent", refused before any lookup or write), or gate checks 1–2 fail |
REVIEWER_NOT_AUTHORIZED | 403 | Gate checks 3–6 fail: never filed or revoked, an oracle identity (or the oracle's owner, oracle-owner) in it, a root no longer a controller, an evaluator in it |
UNAVAILABLE | 503 | Blocksync, the chain or a DID resolver could not be reached while checking the grant — never a pass. Retry |
Deploy order. The portal ships first — an older package's review body schema is not strict, so
the extra attestation field rides along harmlessly. The engine and this package then deploy
together: both require the field, and the engine's fold body schema is strict, so a package
that forwards attestation to an older engine turns every fold into a 400 recorded as foldError.
A review row written before this (no stored attestation) cannot fold at all — the workflow says so
on the row and the reviewer answers again, signed.
The read gate (assertCanRead) is the chain's answer now, never the row's memory: the
collection entity's current chain owner (read off Blocksync, cached 5 min), or any holder of a live
evaluate grant for it (cached 5 min) — the same permission surface as the portal's "Grant Role"
flow. The enroller passes only while it still holds a grant, and an owner who transferred the
entity reads nothing. A collection this oracle does not watch is a 404. One claim's record also
opens to that claim's submitter (its on-chain agent DID or agent address) — that claim only,
never the collection.
There is deliberately no claim-ingestion endpoint: the chain is truth, so a pushed claimId adds nothing worth trusting.
The Decision API lane's routes (/v1/api/*, API_LANE on) are specified by
openapi/decision-api.yaml, shipped in the package and served at GET /v1/api/openapi.json. A
console invocation (X-Auth-Type: ucan) carries ONE capability whose can is the operation's own
x-ucan-action (API_ACTIONS in src/contracts/capabilities.ts, e.g. claims/read for GET /v1/api/claims); any other can — another operation's action, or the legacy * — is 403 FORBIDDEN, with a message naming the action the route takes. API keys are unaffected.
Console roles (ROLE_CAPABILITIES, src/contracts/capabilities.ts) are the five presets a
member grant must match exactly: owner = source/* (covers every capability), developer =
claims/read · claims/review · keys/manage · webhooks/manage · spend/read · audit/read,
reviewer = claims/read · claims/review, viewer = claims/read · spend/read, and
submitter = claims/submit · claims/read (v1.9). claims/submit is what submitting (one claim
or a batch), PUT /v1/api/uploads and POST /v1/api/uploads/links require; claims/read covers a
claim's record, receipt and report and GET /v1/api/uploads/:uploadId — source-wide, never the
answers.
A source's organisation. An organisation IS its on-chain
entity, and a source is that organisation's access to this oracle — it never exists without one,
and there is no personal root. POST /v1/api/sources takes { name, contactEmail, entityDid } and
is 403 unless the invoker is a CURRENT controller of entityDid (Blocksync entity.controller,
did:ixo:ixo1… accounts, read live and uncached; 503 when it cannot be read); the creator pays
(ownerDid) but gets no membership. The source starts pending-link: it answers its lists,
GET /v1/api/me, the sync, revoke/remove and delete, and refuses every other route — claims,
uploads, keys (even an existing one, on any route), invites and their acceptance, reviews,
settings, webhooks — with 409 SOURCE_PENDING_LINK, checked where the caller is built (console,
key, OAuth). Until it is first active (activatedAt, set once; on the Source view since 1.17.0,
null for a source that never held claims), only its organisation's controllers can list it — not
the operator (/admin/sources) — and its id is in no log line, metric, alert or Sentry event (logs
carry sourceIdHash, the first 12 hex of its sha256; /v1/api log lines carry the route pattern,
not the path; the D1 audit row keeps the id). The organisation's link lives at the decision
engine: a controller signs a SourceCompanyLink, the console PUTs it there, then calls POST /v1/api/sources/:sourceId/company/sync (sources/company-sync), which
reads the engine's stored state (company/read) and mirrors it: a link for the source's own
entity ⇒ active (a source with a pausedReason stays paused, read in the same statement)
with payeeField and the link (verbatim) — a read older than the stored link is
ignored; a link for another entity, or the engine's disputed ⇒ disputed (409 SOURCE_DISPUTED on the same routes; the api.company-link.disputed alert); nothing stored ⇒
nothing changes (a source already linked keeps its state — an engine-side transient — with the
api.company-link.unlinked alert). The sync never writes pausedReason. Any
non-2xx from the engine (a 404 included) is 503 and changes nothing. The oracle never
verifies a link JWS. Roots: the entity's current controllers, wherever the API used to say
"owner" — a controller invokes any source route as root with no proof and no membership
(X-Source-Id, with: ixo:oracle/<did:web>/source/<id>, the route's action; GET /v1/api/me
answers caller.kind: "controller"), every member grant must be rooted at one, and a former
controller with no membership is a stranger (404) at once. DELETE /v1/api/sources/:sourceId
(sources/delete, a controller only) deletes the source and every row hanging off it — the way
out of disputed; its uploads and exports are made due at once, so the hourly housekeeping sweep
deletes their R2 objects, then the rows. GET /v1/api/organisations/:entityDid/sources
(listOrganisationSources, sources/list-organisation, 1.17.0) answers an organisation's sources
here in EVERY state — pending-link, active, paused, disputed — newest first, keyset-paged
{ items, nextCursor }, to a current controller of the entity only (read live; 403 otherwise,
503 when the chain cannot be read). It is how a controller finds a source an abandoned "Add
oracle" or a CLI sign-up left pending-link (sync it, or delete it — nothing deletes one
automatically). GET /v1/api/sources (sources/list) is the account's memberships alone, each
source carrying its companyEntityDid; a stale ?entity= on it is 400 VALIDATION, naming the
new route.
Review billing (per-review checker billing for chain claims, contracts 1.22.0). Only while it
is active — IXO's plan for the oracle allows it (the engine's GET /v1/oracles/me
reviewBilling, IXO-admin only, default false, KV-cached 5 min; an oracle never turns it on
by itself, and the facilitator — the real lock — refuses chain-lane reservations otherwise) on an
oracle running API_LANE + API_PAYMENTS with a 0.7.0 facilitator — otherwise all three are
404. A collection registered per review while it was active, whose plan no longer allows it,
pauses payer-delegation rather than be decided unpaid. PUT /v1/api/source/review-billing (review-billing/set, keys/manage: a member's grant or a
controller) stores the source's ONE standing payment delegation: { delegation }, an x402-ixo-pay
payment delegation (base64 CAR, ≤ 16 KiB) — one billing/pay on billing:decision-api addressed to
the facilitator (X402_FACILITATOR_URL's did:web), nb: { maxAmount, denom: "upay", oracles: [<this oracle's did:web>] } (exactly this oracle), an exp at least 24 h out (any later one,
however far), an nbf (if any) not more than 5 min ahead, no proofs, no facts, its signature
verified against the issuer's keys on chain. Its issuer must be the organisation's CURRENT chain
owner (getEntityOwner on the source's companyEntityDid, uncached): 403 FORBIDDEN reason: not_org_owner otherwise, whoever stores it — the facilitator debits the issuer's subscription deed.
Also refused, nothing stored: 409 CONFLICT billing_mode_none (the source is not billed) or
review_billing_elsewhere (another source of the same organisation holds a live one;
details.sourceId — one paying source per organisation), 503 SOURCE_PAUSED payments-disabled
(API_PAYMENTS off or no facilitator), and 400 VALIDATION with reason
not_a_payment_delegation · wrong_audience · not_oracle_bound · expires_too_soon ·
not_yet_valid · cap_below_price (maxAmount below the lowest published price) ·
bad_signature. Stored sealed in review_billing_delegations (one row per source, v1: under
WEBHOOK_ENCRYPTION_KEY; audited source.review_billing_set by CID, payer, maxAmount,
expiresAt — never the token); the same CID again keeps its count, a new one starts at 0. GET
(review-billing/read, spend/read) answers ReviewBilling — state (active · expired ·
exhausted · payer_not_owner), payer, delegationCid, maxAmount, spent / remaining (the
oracle's own count of what it reserved against maxAmount: the facilitator exposes none, and the
delegation names this oracle alone), expiresAt, credits (the payer's facilitator balance, null
until readable), pricePerReview, pausedCollections — or 404. DELETE (review-billing/delete,
keys/manage) drops it: 200 { deleted, message }, idempotent; the delegation itself stays valid
until its exp — a full stop is a revoke in the UCAN store (the Console's). A row is deleted with
its source, and 30 days past its exp by the housekeeping sweep.
Who a claim is about (submitter). ctx.claim.submitter on the API lane is
the caller's explicit submitter, else the answer the source's organisation link names
(answers[payeeField]: a string or finite integer, trimmed, 1–256 characters — the engine's own
submitterFor rule, lib/api-lane/submitter.ts), else null — never an oracle-side fill. A protocol whose work order has a frequency rule refuses a
claim with neither 400 VALIDATION at submitter — at answers.<payeeField> when the source has
one — before any payment (MCP: SUBMITTER_REQUIRED, before any approval). An AI app over OAuth acts
for one person: an explicit submitter other than the signed-in user's DID is 400 VALIDATION at
submitter. ClaimRecord.submittedBy is who SENT the claim, set server-side from the caller (the
OAuth user's DID; null for an API key or a console submit) — never part of the content.
ClaimRecord.submittedVia (v1.12.0) is WHICH AI app sent it over OAuth, { name, verified }; null
otherwise. name is the client's registered name (the AuthHub token's client_name, else its
client_id), trimmed and capped at 100 characters — the app declares it at dynamic registration, so
it is a display hint, never an authorisation input. verified is the token's verified claim
(exactly true, else false): AuthHub sets it only when every redirect URI the client registered is
Claude's or ChatGPT's own callback, so a local app (Claude Code, Codex) is false. Stored as
api_claims.submitted_via + submitted_via_verified.
A URL attachment's mediaType is detected at accept when the caller sends none (an AI app
over MCP sends a photo as { question, url }). recordApiClaims (lib/api-lane/media-type.ts)
reads it from the URL path's extension (jpg/jpeg, png, webp, gif, heic/heif, avif, pdf, mp4, mov,
webm), else from one HEAD through the SSRF guard (3 s, at most 6 at once across a batch,
Content-Type parameters stripped, a 403/405 left unknown — no GET fallback), and stores it on
the claim's attachment, so every evaluate, retry and review fold reads the same value. A declared
mediaType always wins; one it cannot tell stays absent and the facts lane's
application/octet-stream default applies. Detection never fails the submit, and the
Idempotency-Key comparison is still over the caller's own request. An upload's type is the one
sniffed at upload.
Who answers an API claim's review (POST /v1/api/claims/:claimId/review, contracts 1.11.0).
Every answer carries reviewerGrant, a base64 CAR (≤ 16 KiB): the engine refuses a company
decision's fold without one (every source has a company), so a missing one is 400 REVIEWER_GRANT_INVALID (details.reason: "absent") before any read or write. Every link must
state nbf and exp with exp − nbf ≤ 30 days, nbf ≤ now + 60 s, now < exp, carry a
claims/review capability on ixo:oracle/<did:web>/source/<sourceId>, and no wildcard in any
can or with; no identity of this oracle may appear in it — its owner (the issuer of
OWNER_DELEGATION_CAR) included: 403 REVIEWER_NOT_AUTHORIZED, details.reason: "oracle-owner",
before anything is stored, whether the owner answers as a controller, as a member or is the
controller who granted a member's access (contracts 1.15.0).
- A member signs the attestation with the
did:keythey registered on the source (api_members.reviewerDid) and must holdclaims/reviewover the claim's protocol now (mayReview: the stored grant's capabilities andnb.protocols— the engine does not readnb.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 againstapi_members.delegationCid— anything else is403 REVIEWER_NOT_AUTHORIZED,revoked), issued by a current controller of the organisation (403,root-not-controller) to the member'sdid:ixo; then (2) the member'sdid:ixo→ that registereddid: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 anowner-role grant (source/*, a wildcard) cannot review: the member needs areviewerordevelopergrant straight from a controller. So the first link can stand, a role grant carryingclaims/review(PUT /v1/api/members/:memberId/delegation) must havenbfandexpat most 30 days apart (else400 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
issueris its owndid:ixo, thekidadid:keyon that DID's document (resolved like the chain lane's), and its grant is ONE delegation from the controller to itself, exactlyclaims/reviewon the source, signed by that samekid. 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:
| Tool | Arguments | Calls | Returns |
|---|---|---|---|
submit_claim | protocolDid?, answers, attachments?, submitter?, metadata?, webhookUrl?, idempotencyKey?, resumePayment? (after a card 3-D Secure refusal: its paymentRequired, once the payer has confirmed at confirmUrl) | POST /v1/api/claims with Prefer: wait=90 and an Idempotency-Key — the argument, else mcp_ + hex sha256(canonicalJson(request)); a 402 is paid by at most one attempt per rail with X-Payment-Delegation, as the SDK's payer does (the card authorize goes to this oracle's X402_FACILITATOR_URL, never the delegation's audience; a resumePayment that is not this oracle's card 402 is a VALIDATION refusal) | the decided record, or { status: "pending", claimId, pollUrl } |
upload_file | sourceId? (OAuth only) | POST /v1/api/uploads/links | { uploadId, uploadUrl, expiresAt } as structuredContent; the text is instructions instead of the JSON (the upload card, or curl -sS -T <path> '<uploadUrl>', then submit_claim with { question, uploadId }) |
get_decision | claimId | GET /v1/api/claims/:claimId | the record |
list_protocols | protocolDid? | GET /v1/api/protocols, or …/:did for one protocol's work order (answers JSON Schema, files, facts, price, example) | the list ({ protocolDid, name, price }: name is the protocol entity's #pro profile name — hash-checked, KV-cached 1 h — else its claim form's title, else null; a failed read never fails the list), or the work order |
verify_receipt | claimId, or jws (+ cid?) | GET /v1/api/claims/:claimId/receipt (with claimId alone; given both, the jws is verified and must name that claimId), then verifyReceipt trusting only this oracle's engine (did:web:<EVAL_ENGINE_HOSTNAME>) | { valid: true, issuer, claimId, verdict, status, outcome, protocolDid, oracleDid, kind, evaluatedAt } or { valid: false, reason, message } |
An attachment named by uploadId alone gets its cid filled in. Before anything else is
asked or charged, submit_claim reads GET /v1/api/uploads/:uploadId on the source the submit
will use and sets the attachment's cid; a 404 (the link not used yet) is the tool's own
UPLOAD_PENDING refusal, with no approval filed and nothing submitted.
The default Idempotency-Key is derived from the request, not random. It hashes the request
after the 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/submitto submit or upload,claims/readto read) is used;submit_claimandupload_filechoose it the same way. With several,submit_claimtakessourceId, and without it answersSOURCE_REQUIREDwithsources: [{ sourceId, name }]. With none,NO_SOURCEwithconsoleUrl(CONSOLE_URL, elsePORTAL_BASE_URL). A named source that is not linked answersSOURCE_PENDING_LINK/SOURCE_DISPUTEDbefore any approval. OAuth never creates an account or a source. - Every submit is approved. The first
submit_claimfor a request files anmcp_approvalsrow (bound to the grant and tosha256over the protocol, source, canonical body, attachments and idempotency key) and answersAPPROVAL_REQUIREDwithapproveUrl(<AUTH_HUB_URL>/connect/approve?oracle=<ORACLE_HOSTNAME>&id=<id>, the id 128 random bits),priceandexpiresAt(15 minutes). The same request answersAPPROVAL_PENDINGuntil the user answers on AuthHub. These three asks (APPROVAL_REQUIRED,APPROVAL_PENDING,APPROVAL_STALE) are NOTisErrorresults — a host renders no card for an error, and the card holds the approve button:structuredContentis{ status: "approval_required", error: { code, message, approveUrl, price, expiresAt } }, and the text opensNOT SUBMITTED. Needs the user's approval on IXO: <message>before the same JSON. Every other refusal staysisError. Once approved, one conditionalUPDATE(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 toapproved(one conditionalUPDATE … WHERE status='used' AND expires_at > now, delegation kept) ONLY when the payment is proven to have reserved nothing — the submit route never left/verifyreserving 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's503before/verify— neverCARD_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/verifyreserving, 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 asawaiting_3ds, bound to that offer and PaymentIntent: only asubmit_claimwhoseresumePaymentnames that offer may spend it (anything else, orX-Payment-Prefer: upay, isAWAITING_CARD_CONFIRMATION), and a resume carrying any other payment is409 CONFLICTbefore 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:/verifymay have reserved) — retires the delegation, and the same request asks for a new approval. A price or billing mode that changed since isAPPROVAL_STALE, with a newapproveUrl; nothing is paid at the new price.maxAmountUpayis the published price in upay (PAY is USD-pegged, so a card-only price in cents × 10 000 is the same number); billing modenonestill 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 carryingstandingis403 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 withstanding: { perClaimUpay, totalUpay, until }(upay strings, unix seconds) beside the delegation; that delegation must then havemaxAmountEQUAL tototalUpay(not the claim's price), withperClaimUpay≥ this claim's price and ≤totalUpay,totalUpay≤ 100 000 000 ($100),untilahead and at most 7 days out (+ 5 min skew), and an expiration of at leastuntil— 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.spentUpayis never reset:totalUpayis the auto-approve's total, a PUT must keeptotalUpay ≥ spentUpay, and what is left istotalUpay − 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 conditionalspentUpayupdate that applies only if that lock is this attempt's, withinperClaimUpay,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, elseAPPROVAL_REQUIRED. The answer carriesautoApproved: trueandautoApproveRemaining. - The price and the lock are given back only when nothing can have been reserved: no
/verifyleft 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 anawaiting_3dsapproval 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 pastuntiland lock rows after 2 days.
- A later
- 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_auditrow with actor kindoauth(clientId,clientName); the claim'sacceptedevent carriesvia: 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 pastexpiresAt.
Calling the engine
Every authenticated engine call is a UCAN invocation self-signed by did:web:<ORACLE_HOSTNAME>,
addressed to the engine's did:web, carrying exactly ONE capability { with: "ixo:eval-engine", can: <the route's action> } — never *, never a delegated proof. A minted token lives 5 minutes and
is reused only for the same action until 30 s before it expires.
| Engine route | can | Called by |
|---|---|---|
POST /v1/oracles/register | oracle/register | the first decide, a 403 NOT_REGISTERED, decision-oracle engine register |
GET /v1/oracles/me | oracle/me | the billing cron, /v1/admin/status, decision-oracle engine register |
POST /v1/decisions | decision/decide | the evaluate step |
GET /v1/decisions/:claimId | decision/read | an evaluate retry |
POST /v1/decisions/preview | decision/preview | decision-oracle facts run --preview |
POST /v1/reviews/:claimId | review/submit | the ReviewComplete fold |
GET /v1/collections/:id/agreements | agreement/read | tools.agreement() |
GET /v1/receipts/:cid/report | receipt/report | the Decision API's report route |
GET /v1/sources/:oracleDid/:sourceId/company | company/read | the Decision API's company sync (any non-2xx, a 404 included, is 503 and changes nothing — never read as "unlinked") |
The rubric manifest (GET /v1/rubrics/manifest) and an oracle's public stats are read with no
invocation.
Cron (three triggers)
The oracle's scheduled handler routes by the trigger that fired (event.cron); each job runs in
exactly one tier, and every one of the three must be in triggers.crons (every env), or its tier
never runs. An unknown cron string is a cron.unknown warn and runs nothing — a provider's own
trigger is handled in its own scheduled before it delegates to the oracle's.
| Trigger | Tier | Jobs |
|---|---|---|
* * * * * | minute | 1, 2, 4 (the resume) and 5 below; claims-feed (6) before the rotation scan; with API_LANE the lane's source resume, webhook deliveries and retries, review-batch emails and backlog gauges; with openhands, openhands-scans (the coalesced /openhands/scan calls and the scans that found nothing, three retries each, 50 a minute) |
*/5 * * * * | five | ai-cost-backfill; with a facilitator, payments-reconcile and balance-watch; with openhands, openhands-backstop ("The OpenHands add-on", §2) |
0 * * * * | hour | claims-replay (6), 3 below; with API_LANE retention (housekeeping), header-seal, review-billing-reminders, review-reminders, review-expiry, review-rooms, collection-links |
Sentry cron monitors: oracle-scan, oracle-claims-feed and (with API_LANE) oracle-api-lane per minute,
oracle-cron-five, oracle-cron-hour.
resumeEngineCredits— while any collection is pausedengine-credits, ask the engine'sGET /v1/oracles/meonce and resume them all whenbillingStatusisok.resumeAdminFunds— while any is pausedadmin-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.claims-feed— every minute, ONE chain-wide Blocksync read (claimsbysubmissionDate, oldest first, 100 a page, at most 10 pages a tick) from the stored position minus a 2-minute overlap; only claims of anactivewatched collection start (the same unseen-ledger dedupe and idempotent instance ids as the rotation), and the position (cron_stateclaims-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_TICKin §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.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.payer-billing-resume(in the Decision API lane's tick,cron/payer-billing.ts) — while any per-review collection is pausedpayer-credits/payer-delegationand 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; forpayer-creditsalso 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 (ledgerfailed, errorpayer-billing:…). Hourly,review-billing-remindersemails each delegation's payer 30 and 7 days before itsexp, once each (matrix_deliveriesrb_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 whosespentUpayhas 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 nosent/sendingrow 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'sCONSOLE_URL/billing/reviews). Keyed by the CID, so a new delegation — a raised cap — starts its own pair.scan— a bounded, rotating batch of active watched collections (least recently scanned first), a forward-only inclusive cursor per collection, oneClaimIngestinstance 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.evaluateis 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 recordedskipped(error: unbillable-claim-id, loggedreserve.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 ⇒ pausepayer-delegation); AFTER the facts lane built, so a facts-laneunusable/invalid_factsnever spends the cap, the price is reserved:POST /offers(lane: "chain",collectionId, the claim; PAY only), the charge rowofferedwith the spend on the delegation's mirror in one batch,/verifywith the stored delegation wrapped in the offer'sPaymentPayload→reserved. A refusal deletes the row and gives the spend back. By x402-ixo-pay 0.7.0's codes (never itsdetail):insufficient_credits/spend_cappausepayer-credits;invalid_delegation/wrong_audiencepausepayer-delegation;expired(an expired offer OR delegation) gets one fresh offer, and a secondexpiredpausespayer-delegation;review_billing_not_allowed/plan_inactive(also an/offers422) pauseoracle-plan;nonce_usedretries with a fresh offer;terms_mismatchand any unknown code page (payments.review.verify-unexpected) and retry. The decision settles on its receipt (an engineunusableincluded), apending_reviewextends its hold (lane+collectionIdon 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 retriedevaluatedoes nothing until the collection resumes — no pull, no handler, no AI spend.actruns only for adecidedresult (apending_revieworunusablerow has nothing to sign). WithANSWERS_ARCHIVEbound, asubmittedchain write is followed by anarchive-paidstep (answer.jsonsayspaid: yes; fail-soft). -
ReviewComplete—fold→act(again only for adecidedfold). On a chain claim it first re-runs the reviewer gate over the storedreviewerGrant(a refusal isfoldErrorREVIEWER_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 ownREVIEWER_GRANT_INVALID/REVIEWER_NOT_AUTHORIZEDare non-retryable and land onfoldErrorwith itsdetails.reason. -
OpenhandsHire(openhandson) —verify→mint→register→scan, one instance per collection (§2, "The OpenHands add-on"). A final "no" (anadmitrefusal, no meta-grant, a register gate's402/403/404/409) is aNonRetryableErrorwith the reason on the instance; a refused broadcast or an outage retries (5 attempts, exponential from 30 s).
Pause reasons, and who lifts them
pausedReason | Set when | Lifted by |
|---|---|---|
engine-credits | The engine answered 402 INSUFFICIENT_CREDITS — the owner's credits are exhausted. Every watched collection pauses | The billing cron, automatically, once the engine says ok |
engine-no-grant | The engine refused with NO_EVALUATE_GRANT after one retry — this oracle is not (or no longer) hired for the collection | The owner grants again, then re-registers. With openhands on, the backstop re-mints under the poster's meta-grant and lifts it itself once the chain shows quota again |
rubric-unusable | The engine's manifest could not bind the rubric, or a decide/fold answered unusable. Every claim would be refused the same way, so a broken catalog entry is paid for at most once | Fix the anchored rubric or #vct, then POST /v1/collections/:id/resume |
admin-unfunded | The chain refused a payment: the collection's admin account (the deed's entity-module account) cannot pay what the MsgEvaluateClaim draws — the evaluation payment on every one, plus the approval payment on an ordinary approve or the rejection payment on a reject (the message fails whole unless every leg is covered). The notice names that full amount per denom | The funding cron, automatically, once the balance covers the largest single parked delivery — and it re-delivers the parked decisions |
| payer-credits | Per-review billing: the facilitator refused a review's reservation for want of the paying organisation's credits (insufficient_credits) or of its send permission (spend_cap) | The payer cron, automatically, once the payer's credits (read with the stored delegation) cover one review — and it restarts the claims that gave up meanwhile |
| oracle-plan | Per-review billing: the facilitator says IXO's plan does not let this oracle bill reviews (review_billing_not_allowed / plan_inactive), or the engine's plan no longer allows it. Pages IXO (payments.review.oracle-plan); the owner is NOT told — not theirs to fix | The payer cron, automatically, once the engine's plan allows per-review billing again |
| payer-delegation | Per-review billing: nobody can pay — no live delegation for the company, expired, its cap used up, its signer no longer the organisation's chain owner, the company disputed, or no published price (that one also pages: the operator's config) | The payer cron once someone pays again; a PUT /v1/api/source/review-billing resumes its organisation's collections at once |
An operator's own pause (POST …/pause) has no reason, and no cron ever lifts it.
Notices
A review request and each pause/resume is delivered twice: a card in the collection's Matrix review
room (the primary channel, keyed on the stable (collectionId, ownerDid) pair) and its email twin
to the registered contactEmail. The room's members follow the collection's reviewer grants: every
live one is invited when a review request is sent (one crvi:<collectionId>:<mxid> delivery row
per person, the MXID derived from their did:ixo), a revoke kicks at once, and an hourly pass
(the hourly cron trigger) kicks expired grants and retries failed invites. Both lanes are idempotent (a durable delivery marker plus a
deterministic Matrix transaction id) and fail-soft: nothing here ever fails or blocks a claim,
and an unset ORACLE_MATRIX_ACCESS_TOKEN / EMAIL_FROM_ADDRESS turns its lane into a logged skip.
A pause is told once per collection, and a pause that follows a recovery is a fresh notice rather
than a suppressed one.
Billing — who pays whom
- You pay the model. AI checks and the duplicate judge are your AI provider spend — Cloudflare AI Gateway credits by default (per-call USD in
ai_calls), or OpenRouter. - The collection owner pays the engine. Every decision is billed to the owner's IXO subscription,
found through the
OWNER_DELEGATION_CARyou posted at register. An exhausted balance is the402above: 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 yourpricing.evaluationgate insists on — is split by the chain (80% to the oracle, 10% to the network, 10% to the relayer node — the x/claimsnetwork_fee_percentage/node_fee_percentagechain params, governance-set, 10/10 on mainnet today; rounding remainder to the oracle) on everyMsgEvaluateClaimyou 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-unfundedpause.
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
| Code | Meaning |
|---|---|
0 | Success — every declared fact answered, or the command did what it said |
1 | A real failure: the engine refused or was unreachable, the oracle's Decision API refused (api … prints the envelope's code, message, requestId), validation issues (facts run), a handler threw |
2 | The rubric is unusable — protocol review and facts run print the engine's issues; there is nothing to build against yet |
64 | Usage (EX_USAGE): a missing flag, an unknown command, or a handlers module that exports no facts |
The full command table lives in the package README. Commands used while writing handlers:
protocol review <did>, facts scaffold <did>, facts run --protocol <did> --claim <file> [--preview], manifest <did>.
engine register [--env <name>] [--env-file <path>] [--engine-url <url>] [--network <n>] [--yes] [--json] registers the oracle with the decision engine explicitly (POST /v1/oracles/register: the
ADR-36 proof over ORACLE_SECP_MNEMONIC's address plus OWNER_DELEGATION_CAR, minted as
did:web:<ORACLE_HOSTNAME> of the named wrangler env), then reads GET /v1/oracles/me and prints
the billing status. It reads GET /v1/oracles/me first too: when the engine already binds that
did:web to a DIFFERENT address it prints old → new and exits 1 unless --yes (an identity change —
the engine's record of the oracle moves to the new address); the same address, or not registered
yet, proceeds. The Worker registers lazily at its first decide; running this after the first deploy
makes the oracle payable at once — an oracle with no plan row of its own runs at the engine's
Standard plan terms (payable, listed). A third-party oracle needs nothing more; IXO staff move
IXO's OWN oracles to the IXO-owned plan. The engine resolves the did:web from the live host, so run
it against a deployed env. Idempotent. Exit 1 with the engine's code on a refusal (a 402 is the
owner's credits).
The Decision API lane's operator commands (API_LANE on) are decision-oracle api …: source create --name <n> --contact <email> --entity <did:ixo:entity:…> (the login must control the organisation;
the webhook secret is printed once; the source stays pending-link until its link syncs), source link <sourceId> [--engine-url <url>] (the console's "Add oracle" steps 2–3: the login signs the
SourceCompanyLink statement — kid the did:key of its
Ed25519 key, the payload canonical JSON, payeeField null — PUTs it to the engine's PUT /v1/sources/:oracleDid/:sourceId/company as { with: "ixo:eval-engine", can: "company/link" }, then
calls the oracle's POST /v1/api/sources/:sourceId/company/sync (sources/company-sync) and prints
the source, active or disputed; the entity is the source's own, read from GET /v1/api/me; a
source that is not pending-link is printed and left alone; an engine refusal prints its envelope
and exits 1 without syncing), source delete <sourceId> (the undo for a wrong link, and the
way out of disputed: the plan names the source's name, status and organisation, read from GET /v1/api/me; --yes sends DELETE /v1/api/sources/:sourceId (sources/delete, root only — a
current controller, no proof), which deletes the source and every row off it, and prints { "deleted": true, "sourceId" }; a refusal prints its envelope and exits 1), sources, key create --source <id> --scopes <scope,…> [--name] [--max-spend-cents] (the key is printed once), keys --source <id>, key revoke --source <id> <prefix> [--grace-days <n>] (the prefix, or the masked
eo_test_<prefix>_… / whole key — only the 8-char prefix is sent), and, for a DID in
ORACLE_ADMIN_DIDS, admin sources | pause <id> [--note] | resume <id> | protocols <id> <did,…>|--none | billing-mode <id> x402|none (--none sets the empty allow-list: nothing is allowed), and
review-billing enable | status | disable --source <id> (below). api --help prints the list. Each is one console invocation signed as the AuthHub login: audience the oracle's
did:web (read off its /.well-known/did.json), capability { with: "ixo:oracle/<did:web>", can: <the route's action> } (its x-ucan-action) — …/source/<id> plus X-Source-Id on a source's
routes — 120 s, no proofs, so on a source the login must be its owner. Every POST/PUT/DELETE carries a fresh Idempotency-Key. The oracle
is --kit, else the project's ORACLE_HOSTNAME (a project whose config fails to load is reported
as that failure); --network defaults to the project's IXO_NETWORK, else devnet. --json
prints the answer as the API gave it and nothing else on stdout; a source link or delete, a revoke and
every admin change are dry runs without --yes (exit 0), their plan on stderr under --json — so a
--json dry run prints nothing on stdout.
api review-billing … is the Console's "Pay for reviews" (the review-billing routes above).
enable --source <id> [--cap <PAY>] refuses, before anything is signed and exit 1: an oracle whose
public /v1/info reviewBilling.mode is not x402 (or that publishes no pricePerReview), a
--cap below one review's price, a source whose billingMode is not x402, and a login that is not
the CURRENT chain owner of the source's companyEntityDid (GET /v1/api/me, then Blocksync's
entity.owner, compared as did:ixo:<owner>). Then — a dry run without --yes, the plan naming
the payer, the audience, nb.oracles, the price, the cap and the expiry — the login's Ed25519 key
(edSigningMnemonic, the key its IID publishes) signs x402-ixo-pay's buildPaymentDelegation:
issuer the login's did:ixo, audience did:web:<NETWORKS[network].facilitator host>, one { can: "billing/pay", with: "billing:decision-api", nb: { maxAmount, denom: "upay", oracles: [<the oracle's /v1/info serviceDid>] } }, exp now + 100 years, no nbf, no proofs, no facts.
maxAmount is --cap × 1 000 000 upay (default 1000 PAY, at most 6 decimals) — cumulative over
the delegation's whole life (the facilitator never refunds or resets it); enable again with a
higher --cap stores a new delegation that replaces the old one and starts at 0 spent. The token
is PUT as { delegation } (review-billing/set), then deposited in the network's UCAN store
(POST /api/delegations, { can: "store/add", with: "ixo:ucan-store" }, fail-soft: a miss is a
warning) — the store revokes by CID only what it holds, and the oracle never returns the token —
and the command prints GET (review-billing/read), as status --source <id> does on its own (a
404 there is "none held", exit 0). disable --source <id> reads that state, then (with --yes)
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).