@ixo/decision-oracle 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -3
- package/dist/cli/commands/init.d.ts +3 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +42 -2
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/oracle-init.d.ts.map +1 -1
- package/dist/cli/commands/oracle-init.js +13 -5
- package/dist/cli/commands/oracle-init.js.map +1 -1
- package/dist/cli/index.js +6 -3
- package/dist/cli/index.js.map +1 -1
- package/dist/cron/openhands.d.ts +9 -0
- package/dist/cron/openhands.d.ts.map +1 -0
- package/dist/cron/openhands.js +30 -0
- package/dist/cron/openhands.js.map +1 -0
- package/dist/cron/scan.d.ts.map +1 -1
- package/dist/cron/scan.js +3 -0
- package/dist/cron/scan.js.map +1 -1
- package/dist/db/schema.d.ts +158 -0
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/schema.js +26 -0
- package/dist/db/schema.js.map +1 -1
- package/dist/define-oracle.d.ts +3 -0
- package/dist/define-oracle.d.ts.map +1 -1
- package/dist/define-oracle.js +13 -1
- package/dist/define-oracle.js.map +1 -1
- package/dist/env.d.ts +12 -0
- package/dist/env.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/lib/ixo/blocksync/index.d.ts +4 -0
- package/dist/lib/ixo/blocksync/index.d.ts.map +1 -1
- package/dist/lib/ixo/blocksync/index.js +47 -1
- package/dist/lib/ixo/blocksync/index.js.map +1 -1
- package/dist/lib/ixo/blocksync/queries.d.ts +2 -0
- package/dist/lib/ixo/blocksync/queries.d.ts.map +1 -1
- package/dist/lib/ixo/blocksync/queries.js +17 -0
- package/dist/lib/ixo/blocksync/queries.js.map +1 -1
- package/dist/lib/ixo/chain.d.ts +1 -1
- package/dist/lib/ixo/chain.d.ts.map +1 -1
- package/dist/lib/openhands/backstop.d.ts +131 -0
- package/dist/lib/openhands/backstop.d.ts.map +1 -0
- package/dist/lib/openhands/backstop.js +513 -0
- package/dist/lib/openhands/backstop.js.map +1 -0
- package/dist/lib/openhands/chain.d.ts +218 -0
- package/dist/lib/openhands/chain.d.ts.map +1 -0
- package/dist/lib/openhands/chain.js +392 -0
- package/dist/lib/openhands/chain.js.map +1 -0
- package/dist/lib/openhands/options.d.ts +13 -0
- package/dist/lib/openhands/options.d.ts.map +1 -0
- package/dist/lib/openhands/options.js +28 -0
- package/dist/lib/openhands/options.js.map +1 -0
- package/dist/lib/openhands/routes.d.ts +73 -0
- package/dist/lib/openhands/routes.d.ts.map +1 -0
- package/dist/lib/openhands/routes.js +355 -0
- package/dist/lib/openhands/routes.js.map +1 -0
- package/dist/lib/openhands/store.d.ts +74 -0
- package/dist/lib/openhands/store.d.ts.map +1 -0
- package/dist/lib/openhands/store.js +148 -0
- package/dist/lib/openhands/store.js.map +1 -0
- package/dist/lib/review/reviewer-grant.js +1 -1
- package/dist/lib/review/reviewer-grant.js.map +1 -1
- package/dist/oracle.d.ts +52 -1
- package/dist/oracle.d.ts.map +1 -1
- package/dist/oracle.js +6 -0
- package/dist/oracle.js.map +1 -1
- package/dist/routes/v1/info.d.ts.map +1 -1
- package/dist/routes/v1/info.js +16 -1
- package/dist/routes/v1/info.js.map +1 -1
- package/dist/runtime.d.ts +6 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +10 -0
- package/dist/runtime.js.map +1 -1
- package/dist/workflows/openhands-hire.d.ts +65 -0
- package/dist/workflows/openhands-hire.d.ts.map +1 -0
- package/dist/workflows/openhands-hire.js +203 -0
- package/dist/workflows/openhands-hire.js.map +1 -0
- package/docs/api.md +72 -7
- package/docs/guide.md +33 -0
- package/migrations/0025_openhands.sql +12 -0
- package/migrations/meta/0025_snapshot.json +2976 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/skills/README.md +1 -1
- package/skills/add-openhands-support/SKILL.md +96 -86
- package/skills/create-oracle/SKILL.md +10 -4
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OpenHands hire: how an OpenHands ask becomes a collection this oracle watches (the
|
|
3
|
+
* `openhands` add-on, lib/openhands/).
|
|
4
|
+
*
|
|
5
|
+
* verify → mint → register → scan
|
|
6
|
+
*
|
|
7
|
+
* One durable instance per collection, and the instance id IS the collection id, so the webhook the
|
|
8
|
+
* OpenHands app calls right after the poster grants (lib/openhands/routes.ts) and the backstop that
|
|
9
|
+
* finds what the webhook missed (lib/openhands/backstop.ts) converge on exactly one run — whichever
|
|
10
|
+
* arrives second is an "already exists" the caller treats as success. A rerun (a raised ceiling,
|
|
11
|
+
* low quota) is cheap: verify re-reads, mint fires only when the chain says so, register skips a
|
|
12
|
+
* collection already watched, scan dedupes.
|
|
13
|
+
*
|
|
14
|
+
* A `NonRetryableError` is a real, final "no" (no ask on chain, an ask `admit` refuses, no
|
|
15
|
+
* meta-grant, a register gate) and ends the run with the reason on the instance. Step results stay
|
|
16
|
+
* small and serializable.
|
|
17
|
+
*/
|
|
18
|
+
import { WorkflowEntrypoint } from "cloudflare:workers";
|
|
19
|
+
import { NonRetryableError } from "cloudflare:workflows";
|
|
20
|
+
import { loadConfig } from "../config.js";
|
|
21
|
+
import { cronContext } from "../cron/scan.js";
|
|
22
|
+
import { getDb } from "../db/client.js";
|
|
23
|
+
import { AppError } from "../lib/errors.js";
|
|
24
|
+
import { getCollection } from "../lib/ixo/blocksync/index.js";
|
|
25
|
+
import { getIxoSdk } from "../lib/ixo/chain.js";
|
|
26
|
+
import { scopedStep, withMetrics } from "../lib/metrics.js";
|
|
27
|
+
import { agentQuotaFor, approvalDenomOf, beforeDateFor, capForMint, holdsMatchingEvaluateGrant, metaGrantFor, mintEvaluateAuthzMsg, needsTopUp, readEvaluateGrantsFrom, remainingEvaluateQuota, } from "../lib/openhands/chain.js";
|
|
28
|
+
import { openhandsOptions, reportQuota } from "../lib/openhands/options.js";
|
|
29
|
+
import { refreshHire } from "../lib/openhands/store.js";
|
|
30
|
+
import { broadcastAsOracle, registerCollection, scanCollection } from "../runtime.js";
|
|
31
|
+
/** Chain reads, a broadcast and a Blocksync round trip: five attempts over ~8 minutes. */
|
|
32
|
+
const RETRIES = {
|
|
33
|
+
retries: { limit: 5, delay: "30 seconds", backoff: "exponential" },
|
|
34
|
+
};
|
|
35
|
+
const COLLECTION_ID_RE = /^[0-9]+$/;
|
|
36
|
+
/** Register refusals a retry cannot fix: 402 fee, 403 who, 404 no collection, 409 grant/protocol. */
|
|
37
|
+
const FINAL_REGISTER_STATUSES = new Set([402, 403, 404, 409]);
|
|
38
|
+
const asMessage = (err) => (err instanceof Error ? err.message : String(err));
|
|
39
|
+
export class OpenhandsHire extends WorkflowEntrypoint {
|
|
40
|
+
async run(event, step) {
|
|
41
|
+
// The instance id is the collection id, so a hand-created instance needs no params at all; a
|
|
42
|
+
// revived one (backstop.ts `revivedHireId`) carries it in its params.
|
|
43
|
+
const collectionId = event.payload?.collectionId ?? event.instanceId;
|
|
44
|
+
if (!COLLECTION_ID_RE.test(collectionId)) {
|
|
45
|
+
throw new NonRetryableError(`Invalid collectionId ${JSON.stringify(collectionId)}`);
|
|
46
|
+
}
|
|
47
|
+
const ctx = cronContext(this.env);
|
|
48
|
+
const scope = { metrics: ctx.metrics, logger: ctx.logger };
|
|
49
|
+
return withMetrics(scope, () => this.hire(scopedStep(step, scope, "openhands-hire"), ctx, collectionId));
|
|
50
|
+
}
|
|
51
|
+
async hire(step, ctx, collectionId) {
|
|
52
|
+
const { logger } = ctx;
|
|
53
|
+
const config = loadConfig(this.env);
|
|
54
|
+
const options = openhandsOptions();
|
|
55
|
+
try {
|
|
56
|
+
// 1 · VERIFY. Reads only: a bogus webhook (anyone may call it) costs this oracle a few reads.
|
|
57
|
+
const verified = await step.do("verify", RETRIES, async () => {
|
|
58
|
+
const collection = await getCollection(ctx.deps, collectionId);
|
|
59
|
+
if (!collection)
|
|
60
|
+
throw new NonRetryableError(`collection ${collectionId} not on chain`);
|
|
61
|
+
if (!collection.admin) {
|
|
62
|
+
throw new NonRetryableError(`collection ${collectionId} has no admin on chain`);
|
|
63
|
+
}
|
|
64
|
+
const refusal = await options.admit?.({ collectionId, admin: collection.admin, entity: collection.entity }, this.env);
|
|
65
|
+
if (refusal) {
|
|
66
|
+
logger.warn("openhands.hire.refused", { collectionId, reason: refusal });
|
|
67
|
+
throw new NonRetryableError(`collection ${collectionId} is not served: ${refusal}`);
|
|
68
|
+
}
|
|
69
|
+
// Scoped to THIS collection: a meta-grant naming other collections is no invitation here.
|
|
70
|
+
const meta = await metaGrantFor(config, collection.admin, collectionId);
|
|
71
|
+
if (!meta) {
|
|
72
|
+
throw new NonRetryableError(`no EVALUATE meta-grant from ${collection.admin} to ${config.ORACLE_ADDRESS}`);
|
|
73
|
+
}
|
|
74
|
+
const agentQuota = agentQuotaFor(meta);
|
|
75
|
+
if (!Number.isFinite(agentQuota) || agentQuota < 1) {
|
|
76
|
+
throw new NonRetryableError(`meta-grant from ${collection.admin} allows an agent quota of ${agentQuota}`);
|
|
77
|
+
}
|
|
78
|
+
let beforeDate;
|
|
79
|
+
try {
|
|
80
|
+
beforeDate = beforeDateFor(meta, collection.endDate, new Date());
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
throw new NonRetryableError(`collection ${collectionId}: ${asMessage(err)}`);
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
admin: collection.admin,
|
|
87
|
+
agentQuota,
|
|
88
|
+
memberAddress: meta.memberAddress,
|
|
89
|
+
metaCap: meta.maxAmount,
|
|
90
|
+
approvalDenom: approvalDenomOf(collection.payments),
|
|
91
|
+
beforeDateIso: beforeDate.toISOString(),
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
// 2 · MINT. The oracle hands itself the concrete EvaluateClaimAuthorization the register gate
|
|
95
|
+
// and the act lane need, under the poster's meta-grant. Idempotent by a chain read: a retry
|
|
96
|
+
// that lost its answer after broadcasting sees its own grant and stops.
|
|
97
|
+
const minted = await step.do("mint", RETRIES, async () => {
|
|
98
|
+
const held = await readEvaluateGrantsFrom(config, verified.admin);
|
|
99
|
+
const scope = { admin: verified.admin, collectionId };
|
|
100
|
+
const matching = holdsMatchingEvaluateGrant(held, {
|
|
101
|
+
...scope,
|
|
102
|
+
metaCap: verified.metaCap,
|
|
103
|
+
approvalDenom: verified.approvalDenom,
|
|
104
|
+
mintCap: options.maxCustomAmount,
|
|
105
|
+
});
|
|
106
|
+
// ...and enough quota left to keep judging: the chain drops a constraint at 0 and the
|
|
107
|
+
// engine then answers 403, so a nearly spent one is minted beside (the chain APPENDS).
|
|
108
|
+
const remaining = remainingEvaluateQuota(held, scope);
|
|
109
|
+
const topUp = needsTopUp(remaining, verified.agentQuota);
|
|
110
|
+
if (matching && !topUp) {
|
|
111
|
+
await this.recordQuota(ctx, collectionId, remaining, verified.agentQuota);
|
|
112
|
+
return { minted: false, remaining };
|
|
113
|
+
}
|
|
114
|
+
const msg = mintEvaluateAuthzMsg(await getIxoSdk(), {
|
|
115
|
+
oracleAddress: config.ORACLE_ADDRESS,
|
|
116
|
+
oracleDid: config.ORACLE_DID,
|
|
117
|
+
admin: verified.admin,
|
|
118
|
+
collectionId,
|
|
119
|
+
agentQuota: verified.agentQuota,
|
|
120
|
+
beforeDate: new Date(verified.beforeDateIso),
|
|
121
|
+
maxAmount: capForMint(verified.metaCap, verified.approvalDenom, options.maxCustomAmount),
|
|
122
|
+
memberAddress: verified.memberAddress,
|
|
123
|
+
});
|
|
124
|
+
const res = await broadcastAsOracle(this.env, [msg], `openhands hire ${collectionId}`);
|
|
125
|
+
// Retryable: a sequence clash or a full mempool is what a later attempt fixes.
|
|
126
|
+
if (res.code !== 0) {
|
|
127
|
+
throw new Error(`MsgCreateClaimAuthorization failed with code ${res.code}: ${res.rawLog ?? "(no log)"}`);
|
|
128
|
+
}
|
|
129
|
+
await this.recordQuota(ctx, collectionId, remaining + verified.agentQuota, verified.agentQuota);
|
|
130
|
+
return {
|
|
131
|
+
minted: true,
|
|
132
|
+
txHash: res.transactionHash,
|
|
133
|
+
remaining,
|
|
134
|
+
topUp: matching && topUp,
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
// 3 · REGISTER. The four gates, as the oracle's own principal — WHO passes on the grant step 2
|
|
138
|
+
// minted. `cursor: null` scans from claim zero: claims filed before the hire are not skipped.
|
|
139
|
+
// Only for a collection not watched yet: register is an upsert that writes `active`, so a
|
|
140
|
+
// rerun must never revive a collection an operator paused.
|
|
141
|
+
const registered = await step.do("register", RETRIES, async () => {
|
|
142
|
+
const watched = await getDb(this.env).query.watchedCollections.findFirst({
|
|
143
|
+
where: (t, { eq }) => eq(t.collectionId, collectionId),
|
|
144
|
+
columns: { status: true },
|
|
145
|
+
});
|
|
146
|
+
if (watched)
|
|
147
|
+
return { status: watched.status, skipped: true };
|
|
148
|
+
try {
|
|
149
|
+
const row = await registerCollection(this.env, {
|
|
150
|
+
collectionId,
|
|
151
|
+
principal: { did: config.ORACLE_DID, address: config.ORACLE_ADDRESS },
|
|
152
|
+
cursor: null,
|
|
153
|
+
});
|
|
154
|
+
return { status: row.status, skipped: false };
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
if (err instanceof AppError && FINAL_REGISTER_STATUSES.has(err.status)) {
|
|
158
|
+
logger.warn("openhands.hire.unregistrable", {
|
|
159
|
+
collectionId,
|
|
160
|
+
status: err.status,
|
|
161
|
+
detail: err.message,
|
|
162
|
+
});
|
|
163
|
+
throw new NonRetryableError(err.message);
|
|
164
|
+
}
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
// 4 · SCAN. The claims that arrived before the hire, started now rather than at the next tick.
|
|
169
|
+
// A paused collection is left alone (`scanCollection` refuses it with 409).
|
|
170
|
+
const started = await step.do("scan", RETRIES, async () => {
|
|
171
|
+
if (registered.status === "paused")
|
|
172
|
+
return 0;
|
|
173
|
+
return await scanCollection(this.env, collectionId);
|
|
174
|
+
});
|
|
175
|
+
logger.info("openhands.hire.done", {
|
|
176
|
+
collectionId,
|
|
177
|
+
admin: verified.admin,
|
|
178
|
+
minted: minted.minted,
|
|
179
|
+
...(minted.minted ? { txHash: minted.txHash, topUp: minted.topUp } : {}),
|
|
180
|
+
remainingBefore: minted.remaining,
|
|
181
|
+
status: registered.status,
|
|
182
|
+
alreadyWatched: registered.skipped,
|
|
183
|
+
started,
|
|
184
|
+
});
|
|
185
|
+
return { collectionId, ...minted, ...registered, started };
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
logger.error("openhands.hire.failed", { collectionId, detail: asMessage(err) });
|
|
189
|
+
throw err;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** The quota row and the provider's hook. Fail-soft: the chain is the authority, not the row. */
|
|
193
|
+
async recordQuota(ctx, collectionId, remaining, mintQuota) {
|
|
194
|
+
try {
|
|
195
|
+
await refreshHire(ctx.db, { collectionId, remaining, mintQuota, now: new Date() });
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
ctx.logger.warn("openhands.quota.unwritten", { collectionId, detail: asMessage(err) });
|
|
199
|
+
}
|
|
200
|
+
await reportQuota(this.env, ctx.logger, { collectionId, remaining, mintQuota });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
//# sourceMappingURL=openhands-hire.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"openhands-hire.js","sourceRoot":"","sources":["../../src/workflows/openhands-hire.ts"],"names":[],"mappings":"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"}
|
package/docs/api.md
CHANGED
|
@@ -25,6 +25,9 @@ import type {
|
|
|
25
25
|
ActPolicy,
|
|
26
26
|
Config,
|
|
27
27
|
IxoNetwork,
|
|
28
|
+
OpenhandsAsk,
|
|
29
|
+
OpenhandsOptions,
|
|
30
|
+
OpenhandsQuota,
|
|
28
31
|
OracleBindings,
|
|
29
32
|
OracleConfig,
|
|
30
33
|
OracleEnv,
|
|
@@ -37,7 +40,7 @@ import type {
|
|
|
37
40
|
| --- | --- | --- | --- |
|
|
38
41
|
| `defineOracle` | function | The whole runtime surface: validates + registers your config, returns everything the Workers runtime needs | Once, in `src/index.ts` |
|
|
39
42
|
| `OracleConfig` | type | What you pass to `defineOracle` — name, listing, price, policies, `facts`, `archive` | Typing a config you build elsewhere |
|
|
40
|
-
| `OracleWorker` | type | What `defineOracle` returns: `fetch`, `scheduled`, `ClaimIngest`, `ReviewComplete`, `SequenceManagerDO` | Rarely — the three re-exports are structural |
|
|
43
|
+
| `OracleWorker` | type | What `defineOracle` returns: `fetch`, `scheduled`, `ClaimIngest`, `ReviewComplete`, `OpenhandsHire`, `SequenceManagerDO` | Rarely — the three re-exports are structural |
|
|
41
44
|
| `OracleEnv` | type | `OracleBindings & OracleVars` — the runtime contract your `wrangler.jsonc` must satisfy | Typing your own helpers that take `env` |
|
|
42
45
|
| `OracleBindings` | type | Every binding by name (`DB`, `CACHE_KV`, …) | Extending it with your own bindings: `OracleEnv & { MY_KV: KVNamespace }` |
|
|
43
46
|
| `OracleVars` | type | Every var and secret, as wrangler hands them over (strings, `undefined` when unset) | Same |
|
|
@@ -53,6 +56,8 @@ import type {
|
|
|
53
56
|
| `ArchiveAsk` / `ArchiveDoc` | type | What `archive.describe` is handed: the ask's collection and documents | Reading `#done`, `#pro` inside `describe` |
|
|
54
57
|
| `ArchiveAbout` / `ArchiveKind` | type | `about.json` — the view `describe` returns part of | Returning from `describe` |
|
|
55
58
|
| `ArchiveAnswerView` | type | `{ text?, place? }` — what `archive.answer` returns | Returning from `answer` |
|
|
59
|
+
| `OpenhandsOptions` | type | `{ admit?, maxCustomAmount?, onQuota? }` — the `openhands` option's own shape (§2, "The OpenHands add-on") | Typing the hooks you write |
|
|
60
|
+
| `OpenhandsAsk` / `OpenhandsQuota` | type | What `admit` and `onQuota` are handed | Same |
|
|
56
61
|
|
|
57
62
|
### `@ixo/decision-oracle/facts` — node-safe, what a handler imports
|
|
58
63
|
|
|
@@ -221,6 +226,7 @@ import {
|
|
|
221
226
|
getQueryClient,
|
|
222
227
|
loadConfig,
|
|
223
228
|
registerCollection,
|
|
229
|
+
rehireOpenhands,
|
|
224
230
|
scanCollection,
|
|
225
231
|
signEvaluation,
|
|
226
232
|
watchedCollections,
|
|
@@ -250,6 +256,7 @@ and the database handle from it exactly as the cron does (`cron/scan.ts`'s `cron
|
|
|
250
256
|
| `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 |
|
|
251
257
|
| `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 |
|
|
252
258
|
| `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 |
|
|
259
|
+
| `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 |
|
|
253
260
|
| `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 |
|
|
254
261
|
| `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 | Branching on the result, or reading `chainStatus` off `GET /v1/claims/:claimId` |
|
|
255
262
|
| `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` |
|
|
@@ -379,7 +386,8 @@ export const { ClaimIngest, ReviewComplete, SequenceManagerDO } = oracle;
|
|
|
379
386
|
Those **three re-exports are mandatory**: the Workers runtime instantiates Workflow and Durable
|
|
380
387
|
Object classes *by export name*, and the names here must match `class_name` in your
|
|
381
388
|
`wrangler.jsonc` (`workflows[].class_name` → `ClaimIngest` / `ReviewComplete`;
|
|
382
|
-
`durable_objects.bindings[].class_name` → `SequenceManagerDO`).
|
|
389
|
+
`durable_objects.bindings[].class_name` → `SequenceManagerDO`). An OpenHands checker (`openhands`
|
|
390
|
+
set, below) re-exports a fourth, `OpenhandsHire`, bound as `HIRE_WORKFLOW`.
|
|
383
391
|
|
|
384
392
|
### `OracleConfig`
|
|
385
393
|
|
|
@@ -405,6 +413,7 @@ Card is offered for a protocol only when its price is **≥ 50¢ (500 000 µUSD)
|
|
|
405
413
|
| `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) |
|
|
406
414
|
| `facts` | `FactsConfig \| FactsProducer` | carried through untouched | Absent ⇒ the stock lanes for everything |
|
|
407
415
|
| `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](./guide.md#12-operating-it)) |
|
|
416
|
+
| `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` |
|
|
408
417
|
|
|
409
418
|
Validation runs at **module evaluation**, not per request: a bad `minAmount` or an empty `name`
|
|
410
419
|
fails the isolate loudly. `facts` rides through Zod untouched — the TypeScript type is its contract.
|
|
@@ -415,6 +424,51 @@ re-evaluating the same module (bundlers, test pools) is harmless, because the ch
|
|
|
415
424
|
identity of the object you passed. Reading the registry before any `defineOracle` call throws
|
|
416
425
|
`defineOracle() was never called — export it from your worker entry`.
|
|
417
426
|
|
|
427
|
+
### The OpenHands add-on
|
|
428
|
+
|
|
429
|
+
An OpenHands ask is one deed entity plus one claim collection, posted by the OpenHands app for a
|
|
430
|
+
person or an agent. When the poster picks your oracle, their posting transaction grants your
|
|
431
|
+
`ORACLE_ADDRESS` a **meta-grant** (`CreateClaimAuthorizationAuthorization`, `EVALUATE`) — the right
|
|
432
|
+
to hand itself the collection's `EvaluateClaimAuthorization` once the collection exists — and the
|
|
433
|
+
app calls `POST https://<your host>/openhands/hire/<collectionId>`. With `openhands` set, the
|
|
434
|
+
oracle then runs the **hire** (`OpenhandsHire`, one durable instance per collection, the instance
|
|
435
|
+
id = the collection id):
|
|
436
|
+
|
|
437
|
+
1. **verify** — the collection on chain, your `admit` hook, and a live meta-grant **the collection's
|
|
438
|
+
admin signed** naming itself (a grant anyone else signed is never minted under). No grant ⇒ the
|
|
439
|
+
run ends; nothing is signed.
|
|
440
|
+
2. **mint** — `MsgExec(MsgCreateClaimAuthorization)` as this oracle: the poster's quota (an
|
|
441
|
+
uncapped grant mints 10 000 verdicts at a time), the poster's payout ceiling verbatim (none ⇒
|
|
442
|
+
your `maxCustomAmount`, else none, so the collection's own approval payment pays), lasting until
|
|
443
|
+
the ask's end + 90 days (never past the meta-grant). Skipped when the chain already holds a
|
|
444
|
+
matching grant with ≥ 50 verdicts left.
|
|
445
|
+
3. **register** — the same four gates as `POST /v1/collections/register` (protocol, fee and all),
|
|
446
|
+
as the oracle's own principal, scanning from claim zero. A collection already watched is never
|
|
447
|
+
re-registered (an operator's pause stands).
|
|
448
|
+
4. **scan** — the claims filed before the hire, started now.
|
|
449
|
+
|
|
450
|
+
A **backstop** runs every five minutes so a lost webhook never strands an ask: it finds meta-grants
|
|
451
|
+
whose collections are not watched yet and hires them, re-mints a watched collection whose poster
|
|
452
|
+
raised the ceiling or whose quota fell below 50, and lifts an `engine-no-grant` pause once the
|
|
453
|
+
chain shows quota again (at least 5 min after the last read — the engine's grant cache). Each
|
|
454
|
+
collection gets at most three hire reruns a day from the backstop and three from the webhook, on
|
|
455
|
+
separate budgets. `POST /openhands/scan/<collectionId>` scans a watched collection at once (at
|
|
456
|
+
most once a minute; extra calls are scanned on the next minute tick). `GET /v1/info` advertises
|
|
457
|
+
both paths under `openhands`, which is how the OpenHands app tells a checker from any other oracle.
|
|
458
|
+
|
|
459
|
+
`OpenhandsOptions` (all optional):
|
|
460
|
+
|
|
461
|
+
| Field | Type | Meaning / default |
|
|
462
|
+
| --- | --- | --- |
|
|
463
|
+
| `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 |
|
|
464
|
+
| `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 |
|
|
465
|
+
| `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 |
|
|
466
|
+
|
|
467
|
+
Bindings, with `openhands` on: the `HIRE_WORKFLOW` workflow (class `OpenhandsHire`) and, outside
|
|
468
|
+
`ENVIRONMENT=development`, the `OPENHANDS_RATE_LIMITER` ratelimit — unbound there, both webhooks
|
|
469
|
+
answer `503` (fail closed). `decision-oracle init --openhands` writes both. State lives in two
|
|
470
|
+
tables of your `DB` (`openhands_state`, `openhands_hires`, shipped migration `0025_openhands`).
|
|
471
|
+
|
|
418
472
|
### The whole-producer escape hatch
|
|
419
473
|
|
|
420
474
|
`facts` also accepts a single function, for the one case a per-name table cannot express — you
|
|
@@ -1530,6 +1584,8 @@ it("bands a vendor score against the rule's own thresholds", async () => {
|
|
|
1530
1584
|
| `METRICS` | `AnalyticsEngineDataset` (optional) | Workers Analytics Engine: request, upstream, cron and cache metrics you can chart as p50/p95/p99 and rates ([guide §12](./guide.md#12-operating-it)). Absent ⇒ every metric is a `msg = "metric"` log line only |
|
|
1531
1585
|
| `CLAIM_WORKFLOW` | `Workflow` | `ClaimIngest` — one instance per claim |
|
|
1532
1586
|
| `REVIEW_WORKFLOW` | `Workflow` | `ReviewComplete` — the review fold |
|
|
1587
|
+
| `HIRE_WORKFLOW` | `Workflow` (optional) | `OpenhandsHire` — one instance per hired OpenHands collection. Required with `openhands` on |
|
|
1588
|
+
| `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`) |
|
|
1533
1589
|
| `SEQUENCE_MANAGER` | `DurableObjectNamespace` | The SDK's `SequenceManagerDO`: one instance per signer, allocating account sequence numbers so concurrent chain writes never race |
|
|
1534
1590
|
|
|
1535
1591
|
Your own bindings ride alongside — the type is open (`OracleEnv & YourEnv`); only the names above
|
|
@@ -1706,7 +1762,9 @@ check.**
|
|
|
1706
1762
|
| `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 |
|
|
1707
1763
|
| `GET /.well-known/did.json` | public, rate-limit exempt | Needs `SERVICE_ED25519_MNEMONIC` |
|
|
1708
1764
|
| `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 |
|
|
1709
|
-
| `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`, `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`. A portal is built against these exact field names |
|
|
1765
|
+
| `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 |
|
|
1766
|
+
| `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 |
|
|
1767
|
+
| `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 |
|
|
1710
1768
|
| `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`. |
|
|
1711
1769
|
| `GET /v1/collections` | UCAN `collections/read` | The rows the caller owns or enrolled |
|
|
1712
1770
|
| `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 |
|
|
@@ -1766,7 +1824,9 @@ refuses the fold with no engine call:
|
|
|
1766
1824
|
4. **No oracle** — none of `ORACLE_DID`, `did:ixo:<ORACLE_ADDRESS>`, the did:web or
|
|
1767
1825
|
`ORACLE_ENTITY_DID` is in it (`oracle`), nor the oracle's **owner** — the issuer of
|
|
1768
1826
|
`OWNER_DELEGATION_CAR`, read once per isolate; skipped when it is unset (`oracle-owner`). The
|
|
1769
|
-
engine refuses the owner's fold, so it is refused at the answer
|
|
1827
|
+
engine refuses the owner's fold, so it is refused at the answer; the message names the owner's
|
|
1828
|
+
DID and says to answer from another account. Register an oracle with an account nobody uses to
|
|
1829
|
+
review its claims.
|
|
1770
1830
|
5. **Live root** — the issuer is a current controller of the collection's entity (uncached).
|
|
1771
1831
|
6. **No evaluator** — neither the issuer nor the reviewer holds an `EvaluateClaimAuthorization` on
|
|
1772
1832
|
the collection (read uncached): that power already signs `MsgEvaluateClaim` and moves the
|
|
@@ -2336,8 +2396,8 @@ trigger is handled in its own `scheduled` before it delegates to the oracle's.
|
|
|
2336
2396
|
|
|
2337
2397
|
| Trigger | Tier | Jobs |
|
|
2338
2398
|
| --- | --- | --- |
|
|
2339
|
-
| `* * * * *` | minute | 1, 2, 4 (the resume) and 5 below; with `API_LANE` the lane's source resume, webhook deliveries and retries, review-batch emails and backlog gauges |
|
|
2340
|
-
| `*/5 * * * *` | five | `ai-cost-backfill`; with a facilitator, `payments-reconcile` and `balance-watch` |
|
|
2399
|
+
| `* * * * *` | minute | 1, 2, 4 (the resume) and 5 below; 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, 20 a minute) |
|
|
2400
|
+
| `*/5 * * * *` | five | `ai-cost-backfill`; with a facilitator, `payments-reconcile` and `balance-watch`; with `openhands`, `openhands-backstop` ("The OpenHands add-on", §2) |
|
|
2341
2401
|
| `0 * * * *` | hour | 3 below; with `API_LANE` `retention` (housekeeping), `header-seal`, `review-billing-reminders`, `review-reminders`, `review-expiry`, `review-rooms`, `collection-links` |
|
|
2342
2402
|
|
|
2343
2403
|
Sentry cron monitors: `oracle-scan` and (with `API_LANE`) `oracle-api-lane` per minute,
|
|
@@ -2419,12 +2479,17 @@ Sentry cron monitors: `oracle-scan` and (with `API_LANE`) `oracle-api-lane` per
|
|
|
2419
2479
|
second one created. The engine's own `REVIEWER_GRANT_INVALID` / `REVIEWER_NOT_AUTHORIZED` are
|
|
2420
2480
|
non-retryable and land on `foldError` with its `details.reason`.
|
|
2421
2481
|
|
|
2482
|
+
- **`OpenhandsHire`** (`openhands` on) — `verify` → `mint` → `register` → `scan`, one instance per
|
|
2483
|
+
collection (§2, "The OpenHands add-on"). A final "no" (an `admit` refusal, no meta-grant, a
|
|
2484
|
+
register gate's `402`/`403`/`404`/`409`) is a `NonRetryableError` with the reason on the
|
|
2485
|
+
instance; a refused broadcast or an outage retries (5 attempts, exponential from 30 s).
|
|
2486
|
+
|
|
2422
2487
|
### Pause reasons, and who lifts them
|
|
2423
2488
|
|
|
2424
2489
|
| `pausedReason` | Set when | Lifted by |
|
|
2425
2490
|
| --- | --- | --- |
|
|
2426
2491
|
| `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` |
|
|
2427
|
-
| `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 |
|
|
2492
|
+
| `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 |
|
|
2428
2493
|
| `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` |
|
|
2429
2494
|
| `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 |
|
|
2430
2495
|
|
package/docs/guide.md
CHANGED
|
@@ -1013,6 +1013,39 @@ Every refusal carries a stable machine code in `details.reason` (`collection_not
|
|
|
1013
1013
|
`no_review_billing`, `insufficient_credits`, `company_unavailable`, `credits_unavailable`, …), so a
|
|
1014
1014
|
portal never matches a message.
|
|
1015
1015
|
|
|
1016
|
+
### Getting hired on OpenHands
|
|
1017
|
+
|
|
1018
|
+
The OpenHands app lets anyone post an ask (a task, a question, a check) and pick the oracle that
|
|
1019
|
+
judges the answers. To be one of those oracles, set `openhands` in `defineOracle` —
|
|
1020
|
+
`decision-oracle init --openhands` does it for a new project, with the two bindings it needs:
|
|
1021
|
+
|
|
1022
|
+
```ts
|
|
1023
|
+
const oracle = defineOracle({
|
|
1024
|
+
// …name, protocols, pricing, policies, facts as usual…
|
|
1025
|
+
openhands: {},
|
|
1026
|
+
});
|
|
1027
|
+
export const { ClaimIngest, ReviewComplete, OpenhandsHire, SequenceManagerDO } = oracle;
|
|
1028
|
+
```
|
|
1029
|
+
|
|
1030
|
+
Then you register nothing by hand. When a poster picks you, their posting transaction grants your
|
|
1031
|
+
oracle the right to authorize itself on the new collection, and the app calls
|
|
1032
|
+
`POST /openhands/hire/<collectionId>` on your host. The oracle checks that grant on chain, mints its
|
|
1033
|
+
own evaluate grant under it, registers the collection through the same four gates above, and scans
|
|
1034
|
+
it. A backstop every five minutes catches a call that never arrived and keeps every hired ask
|
|
1035
|
+
topped up. `GET /v1/info` lists the two webhooks under `openhands` — that is how the app knows
|
|
1036
|
+
you are an OpenHands checker. (api.md §2, "The OpenHands add-on", has every step and answer.)
|
|
1037
|
+
|
|
1038
|
+
Three things to decide:
|
|
1039
|
+
|
|
1040
|
+
- **Your protocols.** With `supportedProtocols` set, the app offers your oracle for asks on those
|
|
1041
|
+
protocols (the poster's ask uses your protocol as it is). With `[]`, you judge any protocol.
|
|
1042
|
+
- **Who pays for decisions.** The engine bills every decision to the account that registered your
|
|
1043
|
+
oracle. Charge an evaluation fee (`pricing.evaluation`), or bound whose asks you take with
|
|
1044
|
+
`openhands.admit` (an allow-list, a daily budget) — otherwise any poster can spend your credits.
|
|
1045
|
+
- **Who registers it.** The engine never lets the account that registered an oracle review that
|
|
1046
|
+
oracle's claims. Register the oracle with an account you will NOT use to post or review asks
|
|
1047
|
+
(a company or service login), so you can still answer reviews yourself.
|
|
1048
|
+
|
|
1016
1049
|
### Third-party oracle fees
|
|
1017
1050
|
|
|
1018
1051
|
An oracle that IXO does not run is paid on chain claims by the collection's on-chain **evaluation
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
CREATE TABLE `openhands_hires` (
|
|
2
|
+
`collection_id` text PRIMARY KEY NOT NULL,
|
|
3
|
+
`remaining` integer NOT NULL,
|
|
4
|
+
`mint_quota` integer NOT NULL,
|
|
5
|
+
`checked_at` text NOT NULL,
|
|
6
|
+
`ends_at` text
|
|
7
|
+
);
|
|
8
|
+
--> statement-breakpoint
|
|
9
|
+
CREATE TABLE `openhands_state` (
|
|
10
|
+
`name` text PRIMARY KEY NOT NULL,
|
|
11
|
+
`value` text NOT NULL
|
|
12
|
+
);
|