@ixo/decision-oracle 0.2.4 → 0.2.6

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.
Files changed (83) hide show
  1. package/README.md +6 -3
  2. package/dist/cli/commands/init.d.ts +3 -1
  3. package/dist/cli/commands/init.d.ts.map +1 -1
  4. package/dist/cli/commands/init.js +42 -2
  5. package/dist/cli/commands/init.js.map +1 -1
  6. package/dist/cli/commands/oracle-init.d.ts.map +1 -1
  7. package/dist/cli/commands/oracle-init.js +13 -5
  8. package/dist/cli/commands/oracle-init.js.map +1 -1
  9. package/dist/cli/index.js +6 -3
  10. package/dist/cli/index.js.map +1 -1
  11. package/dist/cron/openhands.d.ts +9 -0
  12. package/dist/cron/openhands.d.ts.map +1 -0
  13. package/dist/cron/openhands.js +30 -0
  14. package/dist/cron/openhands.js.map +1 -0
  15. package/dist/cron/scan.d.ts.map +1 -1
  16. package/dist/cron/scan.js +3 -0
  17. package/dist/cron/scan.js.map +1 -1
  18. package/dist/db/schema.d.ts +158 -0
  19. package/dist/db/schema.d.ts.map +1 -1
  20. package/dist/db/schema.js +26 -0
  21. package/dist/db/schema.js.map +1 -1
  22. package/dist/define-oracle.d.ts +3 -0
  23. package/dist/define-oracle.d.ts.map +1 -1
  24. package/dist/define-oracle.js +13 -1
  25. package/dist/define-oracle.js.map +1 -1
  26. package/dist/env.d.ts +12 -0
  27. package/dist/env.d.ts.map +1 -1
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/lib/ixo/blocksync/index.d.ts +4 -0
  31. package/dist/lib/ixo/blocksync/index.d.ts.map +1 -1
  32. package/dist/lib/ixo/blocksync/index.js +47 -1
  33. package/dist/lib/ixo/blocksync/index.js.map +1 -1
  34. package/dist/lib/ixo/blocksync/queries.d.ts +2 -0
  35. package/dist/lib/ixo/blocksync/queries.d.ts.map +1 -1
  36. package/dist/lib/ixo/blocksync/queries.js +17 -0
  37. package/dist/lib/ixo/blocksync/queries.js.map +1 -1
  38. package/dist/lib/ixo/chain.d.ts +1 -1
  39. package/dist/lib/ixo/chain.d.ts.map +1 -1
  40. package/dist/lib/openhands/backstop.d.ts +131 -0
  41. package/dist/lib/openhands/backstop.d.ts.map +1 -0
  42. package/dist/lib/openhands/backstop.js +513 -0
  43. package/dist/lib/openhands/backstop.js.map +1 -0
  44. package/dist/lib/openhands/chain.d.ts +218 -0
  45. package/dist/lib/openhands/chain.d.ts.map +1 -0
  46. package/dist/lib/openhands/chain.js +392 -0
  47. package/dist/lib/openhands/chain.js.map +1 -0
  48. package/dist/lib/openhands/options.d.ts +13 -0
  49. package/dist/lib/openhands/options.d.ts.map +1 -0
  50. package/dist/lib/openhands/options.js +28 -0
  51. package/dist/lib/openhands/options.js.map +1 -0
  52. package/dist/lib/openhands/routes.d.ts +73 -0
  53. package/dist/lib/openhands/routes.d.ts.map +1 -0
  54. package/dist/lib/openhands/routes.js +355 -0
  55. package/dist/lib/openhands/routes.js.map +1 -0
  56. package/dist/lib/openhands/store.d.ts +74 -0
  57. package/dist/lib/openhands/store.d.ts.map +1 -0
  58. package/dist/lib/openhands/store.js +148 -0
  59. package/dist/lib/openhands/store.js.map +1 -0
  60. package/dist/oracle.d.ts +52 -1
  61. package/dist/oracle.d.ts.map +1 -1
  62. package/dist/oracle.js +6 -0
  63. package/dist/oracle.js.map +1 -1
  64. package/dist/routes/v1/info.d.ts.map +1 -1
  65. package/dist/routes/v1/info.js +6 -0
  66. package/dist/routes/v1/info.js.map +1 -1
  67. package/dist/runtime.d.ts +6 -0
  68. package/dist/runtime.d.ts.map +1 -1
  69. package/dist/runtime.js +10 -0
  70. package/dist/runtime.js.map +1 -1
  71. package/dist/workflows/openhands-hire.d.ts +65 -0
  72. package/dist/workflows/openhands-hire.d.ts.map +1 -0
  73. package/dist/workflows/openhands-hire.js +203 -0
  74. package/dist/workflows/openhands-hire.js.map +1 -0
  75. package/docs/api.md +70 -7
  76. package/docs/guide.md +33 -0
  77. package/migrations/0025_openhands.sql +12 -0
  78. package/migrations/meta/0025_snapshot.json +2976 -0
  79. package/migrations/meta/_journal.json +7 -0
  80. package/package.json +1 -1
  81. package/skills/README.md +1 -1
  82. package/skills/add-openhands-support/SKILL.md +96 -86
  83. 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":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAAE,kBAAkB,EAAyC,MAAM,oBAAoB,CAAC;AAC/F,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AACzD,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AACvC,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAC3C,OAAO,EAAE,KAAK,EAAE,MAAM,cAAc,CAAC;AAErC,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AACzC,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AACrD,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC7C,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AACzD,OAAO,EACL,aAAa,EACb,eAAe,EACf,aAAa,EACb,UAAU,EACV,0BAA0B,EAC1B,YAAY,EACZ,oBAAoB,EACpB,UAAU,EACV,sBAAsB,EACtB,sBAAsB,GACvB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AACzE,OAAO,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;AACrD,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAMnF,0FAA0F;AAC1F,MAAM,OAAO,GAAG;IACd,OAAO,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,YAAY,EAAE,OAAO,EAAE,aAAa,EAAE;CAC1D,CAAC;AAEX,MAAM,gBAAgB,GAAG,UAAU,CAAC;AAEpC,qGAAqG;AACrG,MAAM,uBAAuB,GAAwB,IAAI,GAAG,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC;AAEnF,MAAM,SAAS,GAAG,CAAC,GAAY,EAAU,EAAE,CAAC,CAAC,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;AAE/F,MAAM,OAAO,aAAc,SAAQ,kBAAkD;IAC1E,KAAK,CAAC,GAAG,CAAC,KAAyC,EAAE,IAAkB;QAC9E,6FAA6F;QAC7F,sEAAsE;QACtE,MAAM,YAAY,GAAG,KAAK,CAAC,OAAO,EAAE,YAAY,IAAI,KAAK,CAAC,UAAU,CAAC;QACrE,IAAI,CAAC,gBAAgB,CAAC,IAAI,CAAC,YAAY,CAAC,EAAE,CAAC;YACzC,MAAM,IAAI,iBAAiB,CAAC,wBAAwB,IAAI,CAAC,SAAS,CAAC,YAAY,CAAC,EAAE,CAAC,CAAC;QACtF,CAAC;QACD,MAAM,GAAG,GAAG,WAAW,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAClC,MAAM,KAAK,GAAG,EAAE,OAAO,EAAE,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,CAAC;QAC3D,OAAO,WAAW,CAAC,KAAK,EAAE,GAAG,EAAE,CAC7B,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,IAAI,EAAE,KAAK,EAAE,gBAAgB,CAAC,EAAE,GAAG,EAAE,YAAY,CAAC,CACxE,CAAC;IACJ,CAAC;IAEO,KAAK,CAAC,IAAI,CAChB,IAAkB,EAClB,GAAmC,EACnC,YAAoB;QAEpB,MAAM,EAAE,MAAM,EAAE,GAAG,GAAG,CAAC;QACvB,MAAM,MAAM,GAAG,UAAU,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACpC,MAAM,OAAO,GAAG,gBAAgB,EAAE,CAAC;QACnC,IAAI,CAAC;YACH,8FAA8F;YAC9F,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,EAAE,CAAC,QAAQ,EAAE,OAAO,EAAE,KAAK,IAAI,EAAE;gBAC3D,MAAM,UAAU,GAAG,MAAM,aAAa,CAAC,GAAG,CAAC,IAAI,EAAE,YAAY,CAAC,CAAC;gBAC/D,IAAI,CAAC,UAAU;oBAAE,MAAM,IAAI,iBAAiB,CAAC,cAAc,YAAY,eAAe,CAAC,CAAC;gBACxF,IAAI,CAAC,UAAU,CAAC,KAAK,EAAE,CAAC;oBACtB,MAAM,IAAI,iBAAiB,CAAC,cAAc,YAAY,wBAAwB,CAAC,CAAC;gBAClF,CAAC;gBACD,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,KAAK,EAAE,CACnC,EAAE,YAAY,EAAE,KAAK,EAAE,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,EAAE,EACpE,IAAI,CAAC,GAAG,CACT,CAAC;gBACF,IAAI,OAAO,EAAE,CAAC;oBACZ,MAAM,CAAC,IAAI,CAAC,wBAAwB,EAAE,EAAE,YAAY,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC;oBACzE,MAAM,IAAI,iBAAiB,CAAC,cAAc,YAAY,mBAAmB,OAAO,EAAE,CAAC,CAAC;gBACtF,CAAC;gBACD,0FAA0F;gBAC1F,MAAM,IAAI,GAAG,MAAM,YAAY,CAAC,MAAM,EAAE,UAAU,CAAC,KAAK,EAAE,YAAY,CAAC,CAAC;gBACxE,IAAI,CAAC,IAAI,EAAE,CAAC;oBACV,MAAM,IAAI,iBAAiB,CACzB,+BAA+B,UAAU,CAAC,KAAK,OAAO,MAAM,CAAC,cAAc,EAAE,CAC9E,CAAC;gBACJ,CAAC;gBACD,MAAM,UAAU,GAAG,aAAa,CAAC,IAAI,CAAC,CAAC;gBACvC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,UAAU,CAAC,IAAI,UAAU,GAAG,CAAC,EAAE,CAAC;oBACnD,MAAM,IAAI,iBAAiB,CACzB,mBAAmB,UAAU,CAAC,KAAK,6BAA6B,UAAU,EAAE,CAC7E,CAAC;gBACJ,CAAC;gBACD,IAAI,UAAgB,CAAC;gBACrB,IAAI,CAAC;oBACH,UAAU,GAAG,aAAa,CAAC,IAAI,EAAE,UAAU,CAAC,OAAO,EAAE,IAAI,IAAI,EAAE,CAAC,CAAC;gBACnE,CAAC;gBAAC,OAAO,GAAG,EAAE,CAAC;oBACb,MAAM,IAAI,iBAAiB,CAAC,cAAc,YAAY,KAAK,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;gBAC/E,CAAC;gBACD,OAAO;oBACL,KAAK,EAAE,UAAU,CAAC,KAAK;oBACvB,UAAU;oBACV,aAAa,EAAE,IAAI,CAAC,aAAa;oBACjC,OAAO,EAAE,IAAI,CAAC,SAAS;oBACvB,aAAa,EAAE,eAAe,CAAC,UAAU,CAAC,QAAQ,CAAC;oBACnD,aAAa,EAAE,UAAU,CAAC,WAAW,EAAE;iBACxC,CAAC;YACJ,CAAC,CAAC,CAAC;YAEH,8FAA8F;YAC9F,4FAA4F;YAC5F,wEAAwE;YACxE,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,EAAE,KAAK,IAAI,EAAE;gBACvD,MAAM,IAAI,GAAG,MAAM,sBAAsB,CAAC,MAAM,EAAE,QAAQ,CAAC,KAAK,CAAC,CAAC;gBAClE,MAAM,KAAK,GAAG,EAAE,KAAK,EAAE,QAAQ,CAAC,KAAK,EAAE,YAAY,EAAE,CAAC;gBACtD,MAAM,QAAQ,GAAG,0BAA0B,CAAC,IAAI,EAAE;oBAChD,GAAG,KAAK;oBACR,OAAO,EAAE,QAAQ,CAAC,OAAO;oBACzB,aAAa,EAAE,QAAQ,CAAC,aAAa;oBACrC,OAAO,EAAE,OAAO,CAAC,eAAe;iBACjC,CAAC,CAAC;gBACH,sFAAsF;gBACtF,uFAAuF;gBACvF,MAAM,SAAS,GAAG,sBAAsB,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;gBACtD,MAAM,KAAK,GAAG,UAAU,CAAC,SAAS,EAAE,QAAQ,CAAC,UAAU,CAAC,CAAC;gBACzD,IAAI,QAAQ,IAAI,CAAC,KAAK,EAAE,CAAC;oBACvB,MAAM,IAAI,CAAC,WAAW,CAAC,GAAG,EAAE,YAAY,EAAE,SAAS,EAAE,QAAQ,CAAC,UAAU,CAAC,CAAC;oBAC1E,OAAO,EAAE,MAAM,EAAE,KAAc,EAAE,SAAS,EAAE,CAAC;gBAC/C,CAAC;gBACD,MAAM,GAAG,GAAG,oBAAoB,CAAC,MAAM,SAAS,EAAE,EAAE;oBAClD,aAAa,EAAE,MAAM,CAAC,cAAc;oBACpC,SAAS,EAAE,MAAM,CAAC,UAAU;oBAC5B,KAAK,EAAE,QAAQ,CAAC,KAAK;oBACrB,YAAY;oBACZ,UAAU,EAAE,QAAQ,CAAC,UAAU;oBAC/B,UAAU,EAAE,IAAI,IAAI,CAAC,QAAQ,CAAC,aAAa,CAAC;oBAC5C,SAAS,EAAE,UAAU,CAAC,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC,eAAe,CAAC;oBACxF,aAAa,EAAE,QAAQ,CAAC,aAAa;iBACtC,CAAC,CAAC;gBACH,MAAM,GAAG,GAAG,MAAM,iBAAiB,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,EAAE,kBAAkB,YAAY,EAAE,CAAC,CAAC;gBACvF,+EAA+E;gBAC/E,IAAI,GAAG,CAAC,IAAI,KAAK,CAAC,EAAE,CAAC;oBACnB,MAAM,IAAI,KAAK,CACb,gDAAgD,GAAG,CAAC,IAAI,KAAK,GAAG,CAAC,MAAM,IAAI,UAAU,EAAE,CACxF,CAAC;gBACJ,CAAC;gBACD,MAAM,IAAI,CAAC,WAAW,CACpB,GAAG,EACH,YAAY,EACZ,SAAS,GAAG,QAAQ,CAAC,UAAU,EAC/B,QAAQ,CAAC,UAAU,CACpB,CAAC;gBACF,OAAO;oBACL,MAAM,EAAE,IAAa;oBACrB,MAAM,EAAE,GAAG,CAAC,eAAe;oBAC3B,SAAS;oBACT,KAAK,EAAE,QAAQ,IAAI,KAAK;iBACzB,CAAC;YACJ,CAAC,CAAC,CAAC;YAEH,+FAA+F;YAC/F,8FAA8F;YAC9F,0FAA0F;YAC1F,2DAA2D;YAC3D,MAAM,UAAU,GAAG,MAAM,IAAI,CAAC,EAAE,CAAC,UAAU,EAAE,OAAO,EAAE,KAAK,IAAI,EAAE;gBAC/D,MAAM,OAAO,GAAG,MAAM,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,kBAAkB,CAAC,SAAS,CAAC;oBACvE,KAAK,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,YAAY,EAAE,YAAY,CAAC;oBACtD,OAAO,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE;iBAC1B,CAAC,CAAC;gBACH,IAAI,OAAO;oBAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,OAAO,EAAE,IAAa,EAAE,CAAC;gBACvE,IAAI,CAAC;oBACH,MAAM,GAAG,GAAG,MAAM,kBAAkB,CAAC,IAAI,CAAC,GAAG,EAAE;wBAC7C,YAAY;wBACZ,SAAS,EAAE,EAAE,GAAG,EAAE,MAAM,CAAC,UAAU,EAAE,OAAO,EAAE,MAAM,CAAC,cAAc,EAAE;wBACrE,MAAM,EAAE,IAAI;qBACb,CAAC,CAAC;oBACH,OAAO,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,EAAE,KAAc,EAAE,CAAC;gBACzD,CAAC;gBAAC,OAAO,GAAG,EAAE,CAAC;oBACb,IAAI,GAAG,YAAY,QAAQ,IAAI,uBAAuB,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;wBACvE,MAAM,CAAC,IAAI,CAAC,8BAA8B,EAAE;4BAC1C,YAAY;4BACZ,MAAM,EAAE,GAAG,CAAC,MAAM;4BAClB,MAAM,EAAE,GAAG,CAAC,OAAO;yBACpB,CAAC,CAAC;wBACH,MAAM,IAAI,iBAAiB,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;oBAC3C,CAAC;oBACD,MAAM,GAAG,CAAC;gBACZ,CAAC;YACH,CAAC,CAAC,CAAC;YAEH,+FAA+F;YAC/F,4EAA4E;YAC5E,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,EAAE,KAAK,IAAI,EAAE;gBACxD,IAAI,UAAU,CAAC,MAAM,KAAK,QAAQ;oBAAE,OAAO,CAAC,CAAC;gBAC7C,OAAO,MAAM,cAAc,CAAC,IAAI,CAAC,GAAG,EAAE,YAAY,CAAC,CAAC;YACtD,CAAC,CAAC,CAAC;YAEH,MAAM,CAAC,IAAI,CAAC,qBAAqB,EAAE;gBACjC,YAAY;gBACZ,KAAK,EAAE,QAAQ,CAAC,KAAK;gBACrB,MAAM,EAAE,MAAM,CAAC,MAAM;gBACrB,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;gBACxE,eAAe,EAAE,MAAM,CAAC,SAAS;gBACjC,MAAM,EAAE,UAAU,CAAC,MAAM;gBACzB,cAAc,EAAE,UAAU,CAAC,OAAO;gBAClC,OAAO;aACR,CAAC,CAAC;YACH,OAAO,EAAE,YAAY,EAAE,GAAG,MAAM,EAAE,GAAG,UAAU,EAAE,OAAO,EAAE,CAAC;QAC7D,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,MAAM,CAAC,KAAK,CAAC,uBAAuB,EAAE,EAAE,YAAY,EAAE,MAAM,EAAE,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;YAChF,MAAM,GAAG,CAAC;QACZ,CAAC;IACH,CAAC;IAED,iGAAiG;IACzF,KAAK,CAAC,WAAW,CACvB,GAAmC,EACnC,YAAoB,EACpB,SAAiB,EACjB,SAAiB;QAEjB,IAAI,CAAC;YACH,MAAM,WAAW,CAAC,GAAG,CAAC,EAAE,EAAE,EAAE,YAAY,EAAE,SAAS,EAAE,SAAS,EAAE,GAAG,EAAE,IAAI,IAAI,EAAE,EAAE,CAAC,CAAC;QACrF,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,2BAA2B,EAAE,EAAE,YAAY,EAAE,MAAM,EAAE,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QACzF,CAAC;QACD,MAAM,WAAW,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,CAAC,MAAM,EAAE,EAAE,YAAY,EAAE,SAAS,EAAE,SAAS,EAAE,CAAC,CAAC;IAClF,CAAC;CACF"}
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`, `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 |
@@ -1758,7 +1816,7 @@ refuses the fold with no engine call:
1758
1816
  included); exactly one delegation, no proofs, no extra block; its signature verifies against
1759
1817
  a key the issuer's `did:ixo` publishes on chain; its capabilities are exactly
1760
1818
  `[{ with: "ixo:oracle/<this oracle's did:web>/collection/<collectionId>", can: "claims/review" }]`
1761
- (no wildcard); issuer and audience are two different `did:ixo:ixo1…` accounts; `nbf` and `exp`
1819
+ (no wildcard); issuer and audience are `did:ixo:ixo1…` accounts (the same one is allowed: a controller may grant themselves); `nbf` and `exp`
1762
1820
  are both set, `exp − nbf` ≤ 30 days (UCANs carry no issued-at), `nbf ≤ now + 60 s` (the
1763
1821
  engine's clock skew) and `now < exp`.
1764
1822
  2. **The reviewer** — its audience is the invoking principal (= the attestation's `issuer`).
@@ -2336,8 +2394,8 @@ trigger is handled in its own `scheduled` before it delegates to the oracle's.
2336
2394
 
2337
2395
  | Trigger | Tier | Jobs |
2338
2396
  | --- | --- | --- |
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` |
2397
+ | `* * * * *` | 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) |
2398
+ | `*/5 * * * *` | five | `ai-cost-backfill`; with a facilitator, `payments-reconcile` and `balance-watch`; with `openhands`, `openhands-backstop` ("The OpenHands add-on", §2) |
2341
2399
  | `0 * * * *` | hour | 3 below; with `API_LANE` `retention` (housekeeping), `header-seal`, `review-billing-reminders`, `review-reminders`, `review-expiry`, `review-rooms`, `collection-links` |
2342
2400
 
2343
2401
  Sentry cron monitors: `oracle-scan` and (with `API_LANE`) `oracle-api-lane` per minute,
@@ -2419,12 +2477,17 @@ Sentry cron monitors: `oracle-scan` and (with `API_LANE`) `oracle-api-lane` per
2419
2477
  second one created. The engine's own `REVIEWER_GRANT_INVALID` / `REVIEWER_NOT_AUTHORIZED` are
2420
2478
  non-retryable and land on `foldError` with its `details.reason`.
2421
2479
 
2480
+ - **`OpenhandsHire`** (`openhands` on) — `verify` → `mint` → `register` → `scan`, one instance per
2481
+ collection (§2, "The OpenHands add-on"). A final "no" (an `admit` refusal, no meta-grant, a
2482
+ register gate's `402`/`403`/`404`/`409`) is a `NonRetryableError` with the reason on the
2483
+ instance; a refused broadcast or an outage retries (5 attempts, exponential from 30 s).
2484
+
2422
2485
  ### Pause reasons, and who lifts them
2423
2486
 
2424
2487
  | `pausedReason` | Set when | Lifted by |
2425
2488
  | --- | --- | --- |
2426
2489
  | `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 |
2490
+ | `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
2491
  | `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
2492
  | `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
2493
 
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
+ );