@ixo/decision-oracle 0.2.28 → 0.2.30

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 (58) hide show
  1. package/README.md +1 -1
  2. package/dist/db/schema.d.ts +17 -0
  3. package/dist/db/schema.d.ts.map +1 -1
  4. package/dist/db/schema.js +10 -0
  5. package/dist/db/schema.js.map +1 -1
  6. package/dist/lib/api-lane/accept.d.ts.map +1 -1
  7. package/dist/lib/api-lane/accept.js +27 -10
  8. package/dist/lib/api-lane/accept.js.map +1 -1
  9. package/dist/lib/api-lane/notify.d.ts +9 -0
  10. package/dist/lib/api-lane/notify.d.ts.map +1 -1
  11. package/dist/lib/api-lane/notify.js +10 -3
  12. package/dist/lib/api-lane/notify.js.map +1 -1
  13. package/dist/lib/api-lane/record.d.ts.map +1 -1
  14. package/dist/lib/api-lane/record.js +6 -3
  15. package/dist/lib/api-lane/record.js.map +1 -1
  16. package/dist/lib/engine/client.d.ts +14 -2
  17. package/dist/lib/engine/client.d.ts.map +1 -1
  18. package/dist/lib/engine/client.js +90 -11
  19. package/dist/lib/engine/client.js.map +1 -1
  20. package/dist/lib/ixo/grant-chain.js +6 -6
  21. package/dist/lib/ixo/grant-chain.js.map +1 -1
  22. package/dist/lib/openhands/why.d.ts.map +1 -1
  23. package/dist/lib/openhands/why.js +7 -2
  24. package/dist/lib/openhands/why.js.map +1 -1
  25. package/dist/lib/work-order/index.d.ts +4 -2
  26. package/dist/lib/work-order/index.d.ts.map +1 -1
  27. package/dist/lib/work-order/index.js +19 -4
  28. package/dist/lib/work-order/index.js.map +1 -1
  29. package/dist/middleware/api/keys.d.ts.map +1 -1
  30. package/dist/middleware/api/keys.js +10 -2
  31. package/dist/middleware/api/keys.js.map +1 -1
  32. package/dist/middleware/api/rate-limit.js +3 -4
  33. package/dist/middleware/api/rate-limit.js.map +1 -1
  34. package/dist/middleware/api/wait.d.ts +17 -2
  35. package/dist/middleware/api/wait.d.ts.map +1 -1
  36. package/dist/middleware/api/wait.js +80 -19
  37. package/dist/middleware/api/wait.js.map +1 -1
  38. package/dist/middleware/ucan-auth.d.ts.map +1 -1
  39. package/dist/middleware/ucan-auth.js +7 -2
  40. package/dist/middleware/ucan-auth.js.map +1 -1
  41. package/dist/routes/v1/api/claims.d.ts +15 -3
  42. package/dist/routes/v1/api/claims.d.ts.map +1 -1
  43. package/dist/routes/v1/api/claims.js +85 -14
  44. package/dist/routes/v1/api/claims.js.map +1 -1
  45. package/dist/workflows/claim-ingest.d.ts +19 -125
  46. package/dist/workflows/claim-ingest.d.ts.map +1 -1
  47. package/dist/workflows/claim-ingest.js +49 -765
  48. package/dist/workflows/claim-ingest.js.map +1 -1
  49. package/dist/workflows/evaluate.d.ts +193 -0
  50. package/dist/workflows/evaluate.d.ts.map +1 -0
  51. package/dist/workflows/evaluate.js +980 -0
  52. package/dist/workflows/evaluate.js.map +1 -0
  53. package/docs/api.md +24 -4
  54. package/docs/guide.md +19 -1
  55. package/migrations/0029_inline_lease.sql +1 -0
  56. package/migrations/meta/0029_snapshot.json +3021 -0
  57. package/migrations/meta/_journal.json +7 -0
  58. package/package.json +4 -4
@@ -0,0 +1,980 @@
1
+ /**
2
+ * The `evaluate` step's body (workflows/claim-ingest.ts header), as ONE function both front doors
3
+ * run: the `ClaimIngest` workflow's step, and — for an API claim submitted with `Prefer: wait=N` —
4
+ * the accepting request itself (`evaluateInline` below), so a waiting caller's decision lands in
5
+ * the request instead of after the Workflow's queue time.
6
+ *
7
+ * Pull → facts → decide → the `decisions` row, all inside one call: the claim body and the FactSet
8
+ * never leave it. What makes two callers safe is the ledger row, never memory:
9
+ *
10
+ * - the `decisions` row is written once (`onConflictDoNothing`) and every caller that finds it
11
+ * returns it — no second pull, no second handler run, no second ask;
12
+ * - `claim_ingests.status` moves `queued` → `processing` in ONE conditional statement, so exactly
13
+ * one caller takes a fresh claim. The workflow takes it from anything but `processing`; the
14
+ * inline run only from `queued` — it never resumes another caller's attempt;
15
+ * - the inline run's lease (`claim_ingests.inlineUntil`) is written in the statement that takes
16
+ * the row, so it exists iff that run holds it. A workflow that finds the row `processing` under
17
+ * a live lease waits for that run's decision row instead of deciding beside it; with no lease
18
+ * (its own earlier attempt) it never waits. The lease outlives anything the request can still be
19
+ * doing (`wait` + the runtime's 30 s `waitUntil` window), so a run that died just lets it lapse;
20
+ * - a run that FAILS keeps the row `processing` and ends its lease, so the workflow resumes it
21
+ * like its own crashed attempt: the recovery read (`engine.getDecision`) first — the run may
22
+ * have been decided on the engine before it failed — never a blind second decide.
23
+ */
24
+ import { NonRetryableError } from "cloudflare:workflows";
25
+ import { and, eq, isNull, ne, notExists, notInArray, sql } from "drizzle-orm";
26
+ import { getDb } from "../db/client.js";
27
+ import { apiCharges, apiClaims, claimIngests, decisions, watchedCollections, } from "../db/schema.js";
28
+ import { announceApiClaim } from "../lib/api-lane/notify.js";
29
+ import { decisionFailure, FAILURE } from "../lib/api-lane/record.js";
30
+ import { archiveAnswer } from "../lib/archive.js";
31
+ import { createEngineClient } from "../lib/engine/client.js";
32
+ import { EngineError, isManifestUnusable, } from "../lib/engine/types.js";
33
+ import { AppError } from "../lib/errors.js";
34
+ import { buildFacts, factDeps } from "../lib/facts/builder.js";
35
+ import { factCacheDelete } from "../lib/facts/fact-cache.js";
36
+ import { factsDigest } from "../lib/facts/factset.js";
37
+ import { finalVerdictUpdate, registryVerdictToken } from "../lib/facts/stock/unique-store.js";
38
+ import { instanceIdFor } from "../lib/ids.js";
39
+ import { depsFromEnv } from "../lib/ixo/index.js";
40
+ import { currentCorrelation, peerCode, withMetrics } from "../lib/metrics.js";
41
+ import { extendForReview } from "../lib/payments/extend.js";
42
+ import { BILLING_PAUSES, CHAIN_CLAIM_ID, companyOf, planAllowsReviewBilling, releaseChainClaim, reserveChainClaim, resolvePayer, } from "../lib/payments/review-billing.js";
43
+ import { settleClaim } from "../lib/payments/settle.js";
44
+ import { payoutRequestFor } from "../lib/payouts.js";
45
+ import { reviewerNoteEnabled, writeReviewerNote } from "../lib/review/reviewer-note.js";
46
+ import { reportAlert, reportError } from "../lib/sentry.js";
47
+ import { getOracle } from "../oracle.js";
48
+ import { isChain, sourceFor } from "./claim-source.js";
49
+ import { erroredFactsOf, pauseCollection, pauseForEngineCredits, pauseUnusable, reasonOf, requireReceipt, stepError, } from "./steps.js";
50
+ /**
51
+ * How a review-billing trouble is told apart in the ledger's `error` (a run that gave up while its
52
+ * collection was paused for payment): the resume cron restarts exactly those.
53
+ */
54
+ export const PAYER_BILLING = "payer-billing:";
55
+ /** `evaluate`'s result for a claim a per-review collection cannot bill (see the header). */
56
+ export const UNBILLABLE = "unbillable";
57
+ const isBillingPause = (reason) => BILLING_PAUSES.includes(reason ?? "");
58
+ /** The facts lane's deterministic negatives → the `decisions.status` they are recorded as (schema.ts). */
59
+ const BUILD_FAILURE_STATUS = {
60
+ "rubric-unusable": "unusable",
61
+ "invalid-facts": "invalid_facts",
62
+ };
63
+ /** The engine's refusals of the claim's content → the `decisions.status` they are recorded as. */
64
+ const CONTENT_REFUSAL_STATUS = {
65
+ CONTENT_MISMATCH: "content_mismatch",
66
+ PAYLOAD_TOO_LARGE: "payload_too_large",
67
+ };
68
+ /**
69
+ * `403 NO_EVALUATE_GRANT` is retry-ONCE (docs/ENGINE-CONTRACT.md): the grant may have landed on
70
+ * chain seconds ago — the portal grants and registers in one flow — so wait about a block and ask
71
+ * again before calling it a real "not hired". In-step on purpose: the FactSet may not leave the
72
+ * step, so a step-level sleep would cost a full rebuild (connectors, AI charges). An object so a
73
+ * test can shorten it.
74
+ */
75
+ export const GRANT_RETRY_DELAY = { ms: 30_000 };
76
+ const resultOf = (row) => ({
77
+ status: row.status,
78
+ verdict: row.verdict,
79
+ resultDigest: row.resultDigest,
80
+ receiptCid: row.receiptCid,
81
+ });
82
+ /**
83
+ * A recorded decision → the step's result. A row that records a FAILURE instead (`invalid_facts`,
84
+ * the engine refusing the content: `decisionFailure`) ends the ingest as the attempt that wrote it
85
+ * did — failed, never `processed` — whoever finds it: the workflow after an inline run that wrote
86
+ * it, or an operator's rerun.
87
+ */
88
+ function landedResult(row) {
89
+ if (decisionFailure(row.status)) {
90
+ throw new NonRetryableError(row.status === "invalid_facts"
91
+ ? `Facts do not match the rubric's work order: ${row.explanation ?? ""}`
92
+ : (row.explanation ?? row.status));
93
+ }
94
+ return resultOf(row);
95
+ }
96
+ const ledgerOf = (db, claimId) => db.query.claimIngests.findFirst({ where: (t, { eq: eqOp }) => eqOp(t.claimId, claimId) });
97
+ const decisionOf = (db, claimId) => db.query.decisions.findFirst({ where: (t, { eq: eqOp }) => eqOp(t.claimId, claimId) });
98
+ /** A scope's protocol, then its manifest (the work order) — what a build reads first. */
99
+ async function workOrderOf(source, engine, collectionId) {
100
+ const protocolDid = await source.protocolDid(collectionId);
101
+ return { protocolDid, manifest: await engine.getManifest(protocolDid) };
102
+ }
103
+ /** A lease on the row that has not run out (`claim_ingests.inlineUntil`). */
104
+ const leaseLive = (until) => !!until && until.getTime() > Date.now();
105
+ /** How often a workflow waiting on the inline run re-reads the ledger (`awaitInline`). */
106
+ const INLINE_POLL_MS = 500;
107
+ export class ClaimEvaluator {
108
+ env;
109
+ constructor(env) {
110
+ this.env = env;
111
+ }
112
+ /**
113
+ * The evaluate step's body: pull → facts → decide → the `decisions` row. Reads the DB, not step
114
+ * results, so an operator rerun converges — and a retry that finds the row is done.
115
+ */
116
+ async evaluate(deps, source, claimId, opts = {}) {
117
+ const db = getDb(this.env);
118
+ // What the ledger says about this step's history, and the decision, read together — a caller
119
+ // that finds the decision returns before it writes anything, so a decided claim's ledger never
120
+ // moves back. (Two reads, not one `db.batch`: Drizzle's D1 batch throws on a relational read
121
+ // that finds no row.)
122
+ const [ingest, existing] = await Promise.all([ledgerOf(db, claimId), decisionOf(db, claimId)]);
123
+ if (existing)
124
+ return landedResult(existing);
125
+ const scope = ingest?.collectionId;
126
+ // Read beside the `processing` mark (neither pulls nor spends) and looked at only AFTER the
127
+ // recovery read below, so a recovered claim returns regardless of a pause.
128
+ const pausedOf = () => {
129
+ const read = scope ? source.pausedReason(scope) : Promise.resolve(null);
130
+ read.catch(() => { }); // awaited below; a recovery that returns first must not leave it unhandled
131
+ return read;
132
+ };
133
+ let paused = pausedOf();
134
+ // The `processing` mark for the NEXT attempt — written before anything is pulled or spent, so a
135
+ // crash anywhere below leaves it behind. One conditional statement, so it also says whether the
136
+ // row was ALREADY `processing`: a previous attempt ran (and may have died after the engine
137
+ // decided), or the accepting request's inline run holds it. The inline run took it from
138
+ // `queued` itself and never resumes anything.
139
+ const resumed = opts.inline ? false : await this.take(db, claimId, ingest !== undefined);
140
+ if (resumed && leaseLive(ingest?.inlineUntil)) {
141
+ const landed = await this.awaitInline(deps, db, claimId);
142
+ if (landed)
143
+ return landedResult(landed);
144
+ // The inline run failed (it ended its lease) or the lease ran out: this attempt holds the row
145
+ // now — the lease cleared, so a retry of it never waits — and still pays the recovery read,
146
+ // since that run may have been decided on the engine. The pause read is taken again: the wait
147
+ // may have outlived it.
148
+ await db
149
+ .update(claimIngests)
150
+ .set({ status: "processing", inlineUntil: null })
151
+ .where(eq(claimIngests.claimId, claimId));
152
+ paused = pausedOf();
153
+ }
154
+ const engine = createEngineClient({
155
+ config: deps.config,
156
+ logger: deps.logger,
157
+ trace: deps.trace,
158
+ cache: deps.cache,
159
+ });
160
+ // `processing` already set ⇒ a previous attempt of this step ran and died before its row
161
+ // landed — possibly AFTER the engine decided. Decided-once on the engine means a resend would
162
+ // only replay that stored row, so one read recovers it and the claim is never stranded: the
163
+ // engine is NOT asked to decide again below. A 404 means the engine never got that far: the
164
+ // normal path. A first attempt (`queued`) never pays the read.
165
+ if (resumed && ingest) {
166
+ const recovered = await engine.getDecision(claimId);
167
+ if (recovered) {
168
+ deps.logger.info("evaluate.recovered", {
169
+ claimId,
170
+ collectionId: ingest.collectionId,
171
+ status: recovered.decision.status,
172
+ resultDigest: recovered.resultDigest,
173
+ receiptCid: recovered.receipt?.cid ?? null,
174
+ });
175
+ if (!recovered.receipt && recovered.decision.status !== "pending_review") {
176
+ // Since CA10 the GET mints a missing receipt or answers 503, so this is a contract
177
+ // breach. Never recorded receipt-less (the receipt is the one credential act and the
178
+ // payout spend), and never re-decided either: that would re-run the handlers' AI spend.
179
+ // `requireReceipt` retries the step, whose next GET recovers it with its receipt.
180
+ deps.logger.warn("evaluate.recovered-without-receipt", {
181
+ claimId,
182
+ collectionId: ingest.collectionId,
183
+ status: recovered.decision.status,
184
+ });
185
+ }
186
+ requireReceipt(recovered, "decision");
187
+ return this.recordRecovered(deps, source, db, engine, claimId, ingest.collectionId, recovered);
188
+ }
189
+ }
190
+ // Out of engine credits, every decide call 402s until the credits cron resumes the scope, so an
191
+ // attempt here would pull, build and PAY for its AI calls up to eight times only to be refused.
192
+ // One projected read instead (above), looked at AFTER the ledger reads (a decided or recovered
193
+ // claim returns regardless): the step retries on its own backoff and spends nothing meanwhile.
194
+ // The chain lane off (config.ts `CHAIN_LANE`): a chain run started before the switch, still
195
+ // retrying, or restarted by hand must not pull, spend AI or ask the engine to decide on the
196
+ // owner's credits. Retried like the pause below, never recorded `skipped` — the scan would then
197
+ // count the claim as seen for good — so the run carries on once the lane is back on (within the
198
+ // step's ~4 h of retries; past them it ends `failed`, rerun like any failed claim).
199
+ if (isChain(source) && !deps.config.CHAIN_LANE) {
200
+ throw AppError.unavailable(`chain lane off (CHAIN_LANE=false) — not pulling ${claimId}`);
201
+ }
202
+ const pausedReason = await paused;
203
+ if (pausedReason === "engine-credits") {
204
+ throw AppError.unavailable(`${source.lane === "chain" ? "collection" : "source"} paused for engine credits — not pulling ${claimId}`);
205
+ }
206
+ // The same for a review nobody can pay for yet: the payer's fix, resumed by the payer cron.
207
+ if (isBillingPause(pausedReason)) {
208
+ throw AppError.unavailable(`${PAYER_BILLING} collection paused ${pausedReason} — not pulling ${claimId}`);
209
+ }
210
+ const billing = isChain(source) && ingest?.collectionId
211
+ ? await this.billingFor(deps, source, db, claimId, ingest.collectionId)
212
+ : null;
213
+ if (billing === UNBILLABLE) {
214
+ await db
215
+ .update(claimIngests)
216
+ .set({ status: "skipped", error: "unbillable-claim-id" })
217
+ .where(eq(claimIngests.claimId, claimId));
218
+ deps.logger.warn("reserve.unbillable-claim-id", {
219
+ claimId,
220
+ collectionId: ingest?.collectionId,
221
+ });
222
+ return { status: UNBILLABLE, verdict: null, resultDigest: null, receiptCid: null };
223
+ }
224
+ return this.decideFresh(deps, source, db, engine, claimId, {
225
+ billing,
226
+ finalAttempt: opts.finalAttempt ?? false,
227
+ scope,
228
+ });
229
+ }
230
+ /** The `processing` mark (see `evaluate`): true when the row was already `processing`. Taking it
231
+ * clears any lease: from here on the row is this workflow's. */
232
+ async take(db, claimId, exists) {
233
+ const taken = await db
234
+ .update(claimIngests)
235
+ .set({ status: "processing", inlineUntil: null })
236
+ // Never over `processing` (someone holds it) nor `processed` (it has its decision, the early
237
+ // return above; an orphaned attempt must not walk it back — see `recordStatus`).
238
+ .where(and(eq(claimIngests.claimId, claimId), notInArray(claimIngests.status, ["processing", "processed"])))
239
+ .returning({ claimId: claimIngests.claimId });
240
+ return exists && taken.length === 0;
241
+ }
242
+ /**
243
+ * The accepting request's inline run holds the row (module header): wait for its decision row
244
+ * rather than decide beside it — two runs would pay the handlers (AI, external sources) twice.
245
+ * Null once that run ended its lease (it failed) or the lease ran out — read off the row on every
246
+ * poll, so bounded by it; the request set it to outlast itself.
247
+ */
248
+ async awaitInline(deps, db, claimId) {
249
+ deps.logger.info("evaluate.awaiting-inline", { claimId });
250
+ for (;;) {
251
+ const [ingest, decision] = await Promise.all([
252
+ ledgerOf(db, claimId),
253
+ decisionOf(db, claimId),
254
+ ]);
255
+ if (decision)
256
+ return decision;
257
+ const until = ingest?.status === "processing" ? ingest.inlineUntil?.getTime() : undefined;
258
+ const left = until === undefined ? 0 : until - Date.now();
259
+ if (left <= 0) {
260
+ deps.logger.info("evaluate.inline-gone", { claimId, status: ingest?.status ?? null });
261
+ return null;
262
+ }
263
+ await new Promise((r) => setTimeout(r, Math.min(INLINE_POLL_MS, left)));
264
+ }
265
+ }
266
+ /**
267
+ * Who pays for this chain claim's review (header): null when its collection pays by its
268
+ * evaluation leg (or the oracle no longer bills per review); `reserved` when a charge already
269
+ * holds it (a retry); else the payer, resolved fresh — never a guess: the engine's or the chain's
270
+ * trouble retries the step, and no usable delegation pauses the collection `payer-delegation`.
271
+ */
272
+ async billingFor(deps, source, db, claimId, collectionId) {
273
+ const watched = await deps.getWatched(collectionId);
274
+ if (!watched?.billingSourceId)
275
+ return null;
276
+ // Registered to be paid per review, it is never decided unpaid: a plan IXO no longer grants
277
+ // pauses it (re-register on the escrow gate), and a plan the engine cannot tell retries.
278
+ if (!(await planAllowsReviewBilling(deps))) {
279
+ await this.pausePayer(deps, db, collectionId, "oracle-plan", "plan does not allow it");
280
+ throw AppError.unavailable(`${PAYER_BILLING} the plan does not allow per-review billing`);
281
+ }
282
+ if (!CHAIN_CLAIM_ID.test(claimId))
283
+ return UNBILLABLE;
284
+ const charge = await db.query.apiCharges.findFirst({
285
+ columns: { status: true },
286
+ where: eq(apiCharges.claimId, claimId),
287
+ });
288
+ if (charge && ["reserved", "settled", "paid"].includes(charge.status))
289
+ return "reserved";
290
+ const billingDeps = { ...deps, db };
291
+ const payer = await resolvePayer(billingDeps, await companyOf(billingDeps, collectionId), await source.protocolDid(collectionId));
292
+ if (!payer.ok) {
293
+ await this.pausePayer(deps, db, collectionId, "payer-delegation", payer.detail);
294
+ if (payer.detail.startsWith("no published price")) {
295
+ // The operator's config, not the payer's: every claim of the protocol stops here.
296
+ await reportAlert(deps, "payments.review.no-price", { collectionId });
297
+ }
298
+ throw AppError.unavailable(`${PAYER_BILLING} no one pays for collection ${collectionId}`);
299
+ }
300
+ // The organisation now pays from another source (one deleted, one added): follow it.
301
+ if (payer.source.sourceId !== watched.billingSourceId) {
302
+ await db
303
+ .update(watchedCollections)
304
+ .set({ billingSourceId: payer.source.sourceId })
305
+ .where(eq(watchedCollections.collectionId, collectionId));
306
+ }
307
+ return payer;
308
+ }
309
+ /**
310
+ * A billing pause. The payer's two: the owner's notice, never a page (a customer's condition).
311
+ * `oracle-plan`: IXO's to fix — paged, no notice to the owner or the payer.
312
+ */
313
+ async pausePayer(deps, db, collectionId, reason, detail) {
314
+ if (reason === "oracle-plan") {
315
+ await db
316
+ .update(watchedCollections)
317
+ .set({ status: "paused", pausedReason: reason })
318
+ .where(eq(watchedCollections.collectionId, collectionId));
319
+ await reportAlert(deps, "payments.review.oracle-plan", { collectionId, code: peerCode(detail) }, "payments.review.oracle-plan");
320
+ }
321
+ else {
322
+ await pauseCollection(db, deps, collectionId, reason, undefined, false);
323
+ }
324
+ deps.logger.warn(`reserve.${reason}`, { collectionId, detail });
325
+ deps.metrics.count("review_billing.paused", 1, { outcome: reason });
326
+ }
327
+ /** The normal path: pull → build → (reserve) → decide → the `decisions` row. */
328
+ async decideFresh(deps, source, db, engine, claimId, opts) {
329
+ const { billing, finalAttempt, scope } = opts;
330
+ // `subject.protocolDid` — where the engine reads `#rub`/`#vct` from, and what its manifest (the
331
+ // build's first read) is keyed by. The ledger already names the claim's scope, so both are read
332
+ // BESIDE the pull rather than after it; the pull's own scope is the authority, and one that
333
+ // differs reads them again.
334
+ const early = scope ? workOrderOf(source, engine, scope) : null;
335
+ early?.catch(() => { }); // awaited below unless the pull throws first
336
+ // The one pull per claim: metadata + body + attachment descriptors (the source's store), and
337
+ // the content the engine binds the decision to — the engine's alone, so it is taken off here
338
+ // and no handler ever sees it. Like the body, it never leaves this step.
339
+ const { content, ...pulled } = await source.pull(claimId);
340
+ const { collectionId } = pulled;
341
+ const fdeps = { ...factDeps(deps, pulled.media, engine, db, this.env, source), finalAttempt };
342
+ const { protocolDid, manifest } = early && scope === collectionId
343
+ ? await early
344
+ : await workOrderOf(source, engine, collectionId);
345
+ // The engine names the chain lane's subject `claim` (§8.1); the seam names lanes.
346
+ const kind = (await source.subject(claimId)).kind === "api" ? "api" : "claim";
347
+ // S16 — the payout block, beside the facts (never in them).
348
+ const payout = payoutRequestFor(protocolDid);
349
+ // RESOLVE + FREEZE (lib/facts/builder.ts). Nothing here is decided: a build that cannot
350
+ // complete is recorded as what it is, and the engine is never asked.
351
+ const built = await buildFacts(fdeps, { claim: pulled, protocolDid, manifest });
352
+ if (!built.ok) {
353
+ return this.buildFailure(deps, source, db, pulled, built);
354
+ }
355
+ // The claim WILL be decided: only now is a review reserved (never decide without one).
356
+ if (billing && billing !== "reserved") {
357
+ await this.reserve(deps, db, billing, { claimId, collectionId, protocolDid });
358
+ }
359
+ const request = (b) => ({
360
+ subject: { kind, claimId, collectionId, protocolDid },
361
+ rubric: { id: b.rubricId },
362
+ facts: b.facts,
363
+ ...(payout ? { payout } : {}),
364
+ claimContent: content,
365
+ });
366
+ let decided;
367
+ try {
368
+ decided = await this.decide(deps, engine, fdeps, pulled, protocolDid, built, request);
369
+ }
370
+ catch (err) {
371
+ // The re-anchored rubric is the one that cannot be built for — recorded exactly as a first
372
+ // build's failure would be (see `decide`), never decided.
373
+ if (err instanceof BuildFailure) {
374
+ return this.buildFailure(deps, source, db, pulled, err.result);
375
+ }
376
+ throw await this.engineFailure(deps, source, db, claimId, collectionId, err);
377
+ }
378
+ const { response } = decided;
379
+ requireReceipt(response, "decision");
380
+ // Both sides hash the same canonical JSON (factset.ts); a drift here means the engine is
381
+ // binding its decision to facts other than the ones we froze — worth knowing, never fatal.
382
+ const ours = await factsDigest(decided.built.facts);
383
+ if (ours !== response.factsDigest) {
384
+ deps.logger.warn("evaluate.facts-digest-drift", {
385
+ claimId,
386
+ ours,
387
+ theirs: response.factsDigest,
388
+ });
389
+ }
390
+ // Null on the engine only when the protocol has no `#rub` — impossible past our own resolve,
391
+ // but never store a null pin for a claim we KNOW the rubric of.
392
+ const rubricId = response.rubricId ?? decided.built.rubricId;
393
+ const result = await this.record(deps, source, db, pulled, response, rubricId, decided.built.facts);
394
+ // The answer archive (lib/archive.ts), here because the body is in hand only inside this step.
395
+ // Fail-soft and returns nothing: the body goes to R2, never into the step result.
396
+ if (this.env.ANSWERS_ARCHIVE) {
397
+ await archiveAnswer(this.env, deps, {
398
+ lane: source.lane,
399
+ claim: pulled,
400
+ content,
401
+ submitter: await source.submitterKey(pulled).catch(() => null),
402
+ response,
403
+ facts: { ext: decided.built.facts.ext, ai: decided.built.facts.ai },
404
+ hooks: getOracle().config.archive,
405
+ });
406
+ }
407
+ // The reviewer note (lib/review/reviewer-note.ts): only for a claim held for a person, and
408
+ // here for the same reason — the answers are in hand only inside this step. Fail-soft and
409
+ // returns nothing; outside the decision, so it can never move a verdict.
410
+ if (response.decision.status === "pending_review" && reviewerNoteEnabled()) {
411
+ const facts = decided.built.facts;
412
+ await writeReviewerNote({
413
+ config: deps.config,
414
+ logger: deps.logger,
415
+ trace: deps.trace,
416
+ env: this.env,
417
+ cache: deps.cache,
418
+ db,
419
+ engine,
420
+ }, {
421
+ claimId,
422
+ protocolDid,
423
+ pendingTasks: response.decision.pendingTasks,
424
+ answers: facts.answers,
425
+ attachments: facts.attachments,
426
+ facts: { ext: facts.ext, ai: facts.ai },
427
+ });
428
+ }
429
+ return result;
430
+ }
431
+ /** Reserve the review (payments/review-billing.ts), or say why not — a pause, a retry, a stop. */
432
+ async reserve(deps, db, payer, claim) {
433
+ const out = await reserveChainClaim({ ...deps, db }, payer, claim);
434
+ if (out.ok)
435
+ return;
436
+ if ("pause" in out) {
437
+ await this.pausePayer(deps, db, claim.collectionId, out.pause, out.detail);
438
+ throw AppError.unavailable(`${PAYER_BILLING} collection paused ${out.pause} (${out.detail})`);
439
+ }
440
+ if ("fail" in out)
441
+ throw new NonRetryableError(`${PAYER_BILLING} ${out.detail}`);
442
+ throw AppError.unavailable(`${PAYER_BILLING} reservation refused (${out.detail})`);
443
+ }
444
+ /**
445
+ * A decision recovered from the engine's store → its `decisions` row.
446
+ *
447
+ * It is FINAL for this claim — the engine will not judge it twice — so it outranks anything this
448
+ * attempt would have concluded, including a build that failed. What it lacks is the facts it was
449
+ * decided on: the attempt that produced them died before writing the row, and the engine keeps a
450
+ * trace, not a FactSet. So they are rebuilt here — and stand in for the originals only if the
451
+ * rebuild is PROVABLY the same one, which is exactly what the engine's `factsDigest` proves.
452
+ * Anything else (a non-deterministic fact, a re-anchored rubric, a failed rebuild) is written as
453
+ * a park with its reason, at write time: better an explicit blocked row naming the drift than a
454
+ * decided row the act lane refuses later without knowing why.
455
+ *
456
+ * The rebuild may not even be ATTEMPTABLE: the owner's delegation can expire while the claim
457
+ * sits between attempts, and then the pull fail-closes 403. A deterministic "no" there is NOT a
458
+ * reason to lose the decision — the ingest would be marked `failed` with no row at all, while
459
+ * the engine holds a final answer it will never issue again. So it is recorded parked, facts
460
+ * null, and the act lane refuses to sign it (src/act.ts reads those facts). Only a transient
461
+ * failure is rethrown, so the step still retries and a later attempt can record the real facts.
462
+ */
463
+ async recordRecovered(deps, source, db, engine, claimId, collectionId, recovered) {
464
+ let claim;
465
+ let built;
466
+ try {
467
+ // Nothing is sent here, so the content is dropped with the pull.
468
+ const { content: _content, ...rest } = await source.pull(claimId);
469
+ claim = rest;
470
+ const protocolDid = await source.protocolDid(claim.collectionId);
471
+ built = await buildFacts(factDeps(deps, claim.media, engine, db, this.env, source), {
472
+ claim,
473
+ protocolDid,
474
+ });
475
+ }
476
+ catch (err) {
477
+ // `stepError` is the retry/stop line the step itself is judged by (steps.ts) — reuse it so
478
+ // "deterministic" means the same thing here as it does there. Transient: rethrow untouched
479
+ // and let the step wrapper classify it, exactly as before.
480
+ if (!(stepError(err) instanceof NonRetryableError))
481
+ throw err;
482
+ const message = err instanceof Error ? err.message : String(err);
483
+ deps.logger.warn("evaluate.recovered-facts-unbuildable", {
484
+ claimId,
485
+ collectionId,
486
+ status: recovered.decision.status,
487
+ error: message,
488
+ });
489
+ return this.record(deps, source, db, { claimId, collectionId }, recovered, recovered.rubricId, null, `recovered decision; facts could not be rebuilt: ${message}`);
490
+ }
491
+ const rebuilt = built.ok && (await factsDigest(built.facts)) === recovered.factsDigest ? built.facts : null;
492
+ return this.record(deps, source, db, claim, recovered, recovered.rubricId, rebuilt, rebuilt
493
+ ? undefined
494
+ : "recovered decision's factsDigest does not match the facts rebuilt in this attempt");
495
+ }
496
+ /**
497
+ * The engine's answer → the `decisions` row, the collection's rubric pin and the `unusable`
498
+ * pause: ONE path whether the answer came from `POST /v1/decisions` just now or was recovered
499
+ * from the engine's store on a retry (see `evaluate`), so the two can never drift apart.
500
+ *
501
+ * `sent` is the FactSet this decision was made on — its `ext`/`ai` are frozen onto the row for
502
+ * the act policy to read at signing time (schema.ts `decisions.facts`), and nothing else of it
503
+ * is: `answers`/`attachments` are the claim's erasable surface and never persisted here. Null
504
+ * only on the recovery path when the rebuild could not be proven to be the same facts, which is
505
+ * what `park` says: the row is written `blocked` with that reason instead of `pending`, so the
506
+ * claim read explains itself and the act lane never has to guess.
507
+ */
508
+ async record(deps, source, db, claim, response, rubricId, sent, park) {
509
+ const { claimId, collectionId } = claim;
510
+ // What the act lane could ever sign (chain-write.ts step 1): a `decided` 1/2 now, or a
511
+ // `pending_review` once its fold makes it final (the fold writes `pending` itself). Anything
512
+ // else — an engine `unusable`, a `decided` review (5) — is a recorded non-action, so it is
513
+ // `skipped` from the start rather than `pending` for ever.
514
+ const { status, evaluationStatus } = response.decision;
515
+ const actable = !!source.after.act &&
516
+ (status === "pending_review" ||
517
+ (status === "decided" && (evaluationStatus === 1 || evaluationStatus === 2)));
518
+ const row = {
519
+ claimId,
520
+ collectionId,
521
+ rubricId,
522
+ factsDigest: response.factsDigest,
523
+ verdict: response.decision.verdict,
524
+ status: response.decision.status,
525
+ resultDigest: response.resultDigest,
526
+ receiptCid: response.receipt?.cid ?? null,
527
+ receiptJws: response.receipt?.jws ?? null,
528
+ decision: response.decision,
529
+ explanation: response.explanation,
530
+ facts: sent ? { ext: sent.ext, ai: sent.ai } : null,
531
+ erroredFacts: erroredFactsOf(response),
532
+ // The review view's `duplicates` source (`{}` stores nothing, like an older engine's none).
533
+ pendingTaskDetails: Object.keys(response.pendingTaskDetails ?? {}).length > 0
534
+ ? (response.pendingTaskDetails ?? null)
535
+ : null,
536
+ // Nothing is ever acted on for a source without an act step (the API lane, §7.1).
537
+ chainStatus: !actable
538
+ ? "skipped"
539
+ : park
540
+ ? "blocked"
541
+ : "pending",
542
+ chainError: actable ? (park ?? null) : null,
543
+ };
544
+ // The decision and, when it is FINAL, its verdict denormalized onto the claim's duplicate
545
+ // registry rows (stock/unique-store.ts; a no-op for a rubric without `unique`) land in ONE
546
+ // batch: a retry that finds the row is done (the early return above), so the token can never
547
+ // be owed separately. `pending_review` stays NULL (= pending, blockable) until the fold.
548
+ // The claim's connector-result cache rows die in the same batch (fact-cache.ts) — decided,
549
+ // so nothing is left for a retry to reuse. So does the collection's rubric pin, first sight
550
+ // only (NULL only): the chain is the authority and the per-claim pin is the row's own
551
+ // `rubricId`; this one is the dashboard's "which rules govern this collection", never
552
+ // re-written by a later re-anchor. Inside the batch, or a crash after it would lose the pin for
553
+ // good (the retry returns early on the row). Chain lane only — an API scope has no
554
+ // `watched_collections` row.
555
+ const token = registryVerdictToken(row.status, row.verdict);
556
+ await db.batch([
557
+ db.insert(decisions).values(row).onConflictDoNothing(),
558
+ ...(token ? [finalVerdictUpdate(db, { collectionId, claimId, token })] : []),
559
+ factCacheDelete(db, claimId),
560
+ ...(isChain(source)
561
+ ? [
562
+ db
563
+ .update(watchedCollections)
564
+ .set({ rubricId: row.rubricId })
565
+ .where(and(eq(watchedCollections.collectionId, collectionId), isNull(watchedCollections.rubricId))),
566
+ ]
567
+ : []),
568
+ ]);
569
+ // The latency metric's one read runs beside the settle (both after the row, neither depends on
570
+ // the other; the metric never fails the step).
571
+ const latency = this.decisionLatency(deps, source, db, claimId, response, sent);
572
+ // A payout scope whose claim names no payee: decided, its receipt without a payment binding
573
+ // (ENGINE-CONTRACT "Payee").
574
+ if (response.decisionToPayRefused) {
575
+ deps.logger.warn("payout.refused", {
576
+ claimId,
577
+ collectionId,
578
+ code: response.decisionToPayRefused,
579
+ });
580
+ }
581
+ // Settle on the receipt (payments spec §4 step 6): fail-soft, the reconcile re-settles a lost
582
+ // one; a claim with no charge (billing mode `none`, a chain claim its evaluation leg pays) is a
583
+ // no-op. An engine `unusable` is a decision with a receipt: settled, as on the API lane. A
584
+ // `pending_review` has no receipt until the fold: hold its reservation through the review
585
+ // instead (extend.ts).
586
+ if (deps.config.X402_FACILITATOR_URL) {
587
+ if (row.receiptJws)
588
+ await settleClaim({ ...deps, db }, claimId, row.receiptJws);
589
+ else if (row.status === "pending_review")
590
+ await extendForReview({ ...deps, db }, claimId);
591
+ }
592
+ await latency;
593
+ // The engine could not bind the rubric to these facts (header): a real, receipted decision
594
+ // for THIS claim — and the same answer for every other claim of the collection.
595
+ if (response.decision.status === "unusable") {
596
+ await pauseUnusable(source, deps.logger, "evaluate", {
597
+ claimId,
598
+ collectionId,
599
+ rubricId: row.rubricId,
600
+ issues: response.decision.reasons,
601
+ receiptCid: row.receiptCid,
602
+ });
603
+ }
604
+ deps.logger.info("evaluate.done", {
605
+ claimId,
606
+ collectionId,
607
+ rubricId: row.rubricId,
608
+ status: response.decision.status,
609
+ verdict: response.decision.verdict,
610
+ pendingTasks: response.decision.pendingTasks,
611
+ resultDigest: response.resultDigest,
612
+ receiptCid: row.receiptCid,
613
+ traceId: response.traceId ?? null,
614
+ });
615
+ return resultOf(row);
616
+ }
617
+ /**
618
+ * `api.decision.latency` — accept → decision row, the SLO metric. Accepted is the API claim's
619
+ * `receivedAt`, or the chain claim's ledger `createdAt` (its first `record-queued`). `status` says
620
+ * whether the protocol's rubric runs AI checks: the frozen FactSet's `ai` keys are exactly the
621
+ * work order's (validate.ts holds them to the manifest), so no extra read; `-` only when a
622
+ * recovered decision's facts could not be rebuilt. Best-effort: a metric never fails the step.
623
+ */
624
+ async decisionLatency(deps, source, db, claimId, response, sent) {
625
+ try {
626
+ const acceptedAt = isChain(source)
627
+ ? (await db.query.claimIngests.findFirst({
628
+ columns: { createdAt: true },
629
+ where: eq(claimIngests.claimId, claimId),
630
+ }))?.createdAt
631
+ : (await db.query.apiClaims.findFirst({
632
+ columns: { receivedAt: true },
633
+ where: eq(apiClaims.claimId, claimId),
634
+ }))?.receivedAt;
635
+ if (!acceptedAt)
636
+ return;
637
+ const { protocolDid } = response.subject;
638
+ deps.metrics.timing("api.decision.latency", Date.now() - acceptedAt.getTime(), {
639
+ op: isChain(source) ? "chain" : "api",
640
+ status: sent ? (Object.keys(sent.ai).length > 0 ? "ai" : "no_ai") : null,
641
+ outcome: response.decision.verdict,
642
+ id: typeof protocolDid === "string" ? protocolDid : null,
643
+ });
644
+ }
645
+ catch (err) {
646
+ deps.logger.warn("metrics.decision-latency-failed", { claimId, detail: String(err) });
647
+ }
648
+ }
649
+ /**
650
+ * `POST /v1/decisions`, with the one recoverable refusal handled here: `RUBRIC_PIN_MISMATCH` —
651
+ * our pin ≠ the chain's `#rub` proof as the engine read it, i.e. `#rub` was re-anchored between
652
+ * our (KV-cached) manifest read and theirs. Invalidate and re-read the manifest ONCE: a changed
653
+ * pin is a new work order (new facts, so a rebuild — connectors run again; the fact cache is
654
+ * keyed by the pin, fact-cache.ts), the same pin means the engine and the chain disagree, which
655
+ * no retry of ours fixes. The manifest may also have turned `unusable` under the new anchor —
656
+ * then the rebuild records that, and never decides.
657
+ */
658
+ async decide(deps, engine, fdeps, pulled, protocolDid, built, request) {
659
+ try {
660
+ return { response: await this.ask(deps, engine, request(built)), built };
661
+ }
662
+ catch (err) {
663
+ if (!(err instanceof EngineError) || err.code !== "RUBRIC_PIN_MISMATCH")
664
+ throw err;
665
+ await engine.invalidateManifest(protocolDid);
666
+ const fresh = await engine.getManifest(protocolDid);
667
+ const repinned = isManifestUnusable(fresh) ? null : fresh.rubricId.toLowerCase();
668
+ if (repinned === built.rubricId) {
669
+ throw new NonRetryableError(`Rubric pin mismatch that re-reading the manifest did not fix: ${err.message}`);
670
+ }
671
+ deps.logger.warn("evaluate.rubric-repinned", {
672
+ claimId: pulled.claimId,
673
+ collectionId: pulled.collectionId,
674
+ from: built.rubricId,
675
+ to: repinned,
676
+ });
677
+ const rebuilt = await buildFacts(fdeps, { claim: pulled, protocolDid, manifest: fresh });
678
+ if (!rebuilt.ok) {
679
+ // The re-anchored rubric is the one that cannot be built for — record THAT, no engine call.
680
+ throw new BuildFailure(rebuilt);
681
+ }
682
+ return { response: await this.ask(deps, engine, request(rebuilt)), built: rebuilt };
683
+ }
684
+ }
685
+ /** One decide call, with the contract's retry-once on `NO_EVALUATE_GRANT` (see `GRANT_RETRY_DELAY`). */
686
+ async ask(deps, engine, req) {
687
+ try {
688
+ return await engine.decide(req);
689
+ }
690
+ catch (err) {
691
+ if (!(err instanceof EngineError) || err.code !== "NO_EVALUATE_GRANT")
692
+ throw err;
693
+ deps.logger.warn("evaluate.no-grant-yet", {
694
+ claimId: req.subject.claimId,
695
+ collectionId: req.subject.collectionId,
696
+ retryInMs: GRANT_RETRY_DELAY.ms,
697
+ });
698
+ await new Promise((resolve) => setTimeout(resolve, GRANT_RETRY_DELAY.ms));
699
+ return engine.decide(req);
700
+ }
701
+ }
702
+ /**
703
+ * The engine said no (docs/ENGINE-CONTRACT.md "Errors"): do what the table says, then hand back
704
+ * the error to throw — NonRetryable for a deterministic refusal, the original for a retryable
705
+ * one. Non-engine errors pass through for `stepError` to classify.
706
+ */
707
+ async engineFailure(deps, source, db, claimId, collectionId, err) {
708
+ if (!(err instanceof EngineError))
709
+ return err;
710
+ const scope = { claimId, collectionId, code: err.code, requestId: err.requestId ?? null };
711
+ switch (err.code) {
712
+ case "NO_EVALUATE_GRANT":
713
+ // Twice (see `ask`): not hired (any more) for this collection — no claim of it can be
714
+ // decided until the owner re-grants, so stop enqueuing and say why. The watched row stands.
715
+ // Only the chain lane's subject is grant-checked (§8.1); an API source never gets here.
716
+ if (isChain(source))
717
+ await source.pause(collectionId, "engine-no-grant");
718
+ deps.logger.error("evaluate.not-hired", scope);
719
+ reportError(err, { lane: "evaluate", step: "evaluate", claimId, collectionId });
720
+ return new NonRetryableError(`Not hired for collection ${collectionId}: ${err.message}`);
721
+ case "INSUFFICIENT_CREDITS": {
722
+ // The oracle OWNER's credits are exhausted at the engine (an invoice bounced): EVERY
723
+ // collection pauses (the credits are the owner's, shared across every collection we watch
724
+ // for them, never this kit's own wallet), the operator is paged, and this claim keeps
725
+ // retrying. While the pause stands, the cron enqueues nothing new and every in-flight
726
+ // claim's retry stops at the pause check before its pull (`evaluate`), spending nothing;
727
+ // once the billing cron lifts it, the next retry pulls, builds and the engine answers.
728
+ await pauseForEngineCredits(db, deps);
729
+ deps.logger.error("evaluate.credits-exhausted", { ...scope, topUpUrl: err.topUpUrl });
730
+ reportError(err, { lane: "evaluate", step: "evaluate", claimId, collectionId });
731
+ return err;
732
+ }
733
+ case "VALIDATION":
734
+ // The engine refused the facts' SHAPE — our connectors' bug, never a claim outcome. The
735
+ // details are Zod paths and messages (identifiers), logged so the fix is one grep away.
736
+ deps.logger.error("evaluate.facts-rejected", { ...scope, details: err.details ?? null });
737
+ reportError(err, { lane: "evaluate", step: "evaluate", claimId, collectionId });
738
+ return new NonRetryableError(`${FAILURE.engineRejected}: ${err.message}`);
739
+ case "CONTENT_MISMATCH":
740
+ case "PAYLOAD_TOO_LARGE": {
741
+ // The engine would not bind the decision to the content we sent (company authority): the
742
+ // bytes, the answers, the attachments, the agent — or a body over its cap, which the client
743
+ // also says before sending. No resend changes the claim, so it is recorded like a build
744
+ // failure (`skipped`: nothing to act on), the ingest fails, and the operator is paged —
745
+ // `details.reason` names the check, never an answer value.
746
+ const status = CONTENT_REFUSAL_STATUS[err.code];
747
+ const reason = reasonOf(err.details);
748
+ await db.batch([
749
+ db
750
+ .insert(decisions)
751
+ .values({
752
+ claimId,
753
+ collectionId,
754
+ status,
755
+ explanation: `${err.code}${reason ? ` (${reason})` : ""}: ${err.message}`,
756
+ chainStatus: "skipped",
757
+ })
758
+ .onConflictDoNothing(),
759
+ factCacheDelete(db, claimId),
760
+ ]);
761
+ deps.logger.error("evaluate.content-refused", { ...scope, reason });
762
+ await reportAlert(deps, `engine.${status.replaceAll("_", "-")}`, { claimId, collectionId, code: err.code, reason }, `engine.${status}:${claimId}`);
763
+ return new NonRetryableError(`${err.code}: ${reason ?? err.message}`);
764
+ }
765
+ case "CONFLICT":
766
+ // The engine holds this claim's decision under a different `kind` or `collectionId` than
767
+ // we just sent — the same claimId reached it through two lanes or scopes. Our bug, never a
768
+ // claim outcome, and no retry changes the stored row: stop, and page it.
769
+ deps.logger.error("evaluate.subject-conflict", scope);
770
+ reportError(err, { lane: "evaluate", step: "evaluate", claimId, collectionId });
771
+ return new NonRetryableError(`${FAILURE.engineConflict}: the engine holds this claim under another subject: ${err.message}`);
772
+ case "RATE_LIMITED":
773
+ // The engine's per-IP limiter said "not now" (a backlog burst): the step's own backoff
774
+ // (1 min doubling) already outlasts its 60 s window, so retry as-is — logged with the
775
+ // engine's `Retry-After` so a throttled backlog is visible, never a permanent failure.
776
+ deps.logger.warn("evaluate.throttled", {
777
+ ...scope,
778
+ retryAfterMs: err.retryAfterMs ?? null,
779
+ });
780
+ return err;
781
+ // The three refusals below all come out of the engine's OWN `POST /v1/oracles/register`
782
+ // (decision-engine spec §5/§9), surfacing HERE because `client.ts`'s `registered()` wrapper
783
+ // re-registers whenever its KV "registered" marker (REGISTERED_TTL_SEC, 30d) has expired —
784
+ // i.e. any `decide()` can hit one of these, not just a fresh boot. None pause a collection:
785
+ // unlike `engine-credits`, the engine's `/me` keeps answering `ok` here (billingStatus is a
786
+ // different gate), so `cron/billing.ts`'s auto-resume would never see the fix and un-pause —
787
+ // this is the owner's/operator's fix to make, not a chain-write concern.
788
+ case "NO_SUBSCRIPTION": {
789
+ // The owner has no IXO subscription, or it lapsed — the engine can't find a credit wallet
790
+ // to bill without one. `topUpUrl` is null for a bare "no subscription at all" (nothing to
791
+ // manage a URL for yet) — the kit's own PORTAL_BASE_URL still names somewhere to send them.
792
+ const topUpUrl = err.topUpUrl ?? deps.config.PORTAL_BASE_URL;
793
+ deps.logger.error("evaluate.owner-billing-refused", { ...scope, topUpUrl });
794
+ reportError(err, { lane: "evaluate", step: "evaluate", claimId, collectionId });
795
+ return new NonRetryableError(`Owner has no active IXO subscription: ${err.message}. Subscribe in the portal (${topUpUrl}), then register again.`);
796
+ }
797
+ case "DELEGATION_NOT_FOR_ORACLE":
798
+ // `OWNER_DELEGATION_CAR` is a real, unexpired CAR — just bound (`billing/pay with`) to a
799
+ // DIFFERENT oracle's did:web. Re-registering with the same file only fails the same way.
800
+ deps.logger.error("evaluate.owner-billing-refused", scope);
801
+ reportError(err, { lane: "evaluate", step: "evaluate", claimId, collectionId });
802
+ return new NonRetryableError(`Owner delegation is bound to a different oracle: ${err.message}. Re-mint the CAR for ` +
803
+ "THIS oracle (decision-oracle delegation --for engine --oracle <this did:web>) and " +
804
+ "set it as OWNER_DELEGATION_CAR.");
805
+ default: {
806
+ // A bad/expired CAR is a plain `400 BAD_REQUEST` (owner-delegation.ts's own checks — not a
807
+ // dedicated code), told apart from any other BAD_REQUEST only by its message naming the
808
+ // field this kit actually sends.
809
+ if (err.code === "BAD_REQUEST" && err.message.includes("ownerDelegation")) {
810
+ deps.logger.error("evaluate.owner-billing-refused", scope);
811
+ reportError(err, { lane: "evaluate", step: "evaluate", claimId, collectionId });
812
+ return new NonRetryableError(`Owner delegation rejected: ${err.message}. Re-mint the CAR ` +
813
+ "(decision-oracle delegation --for engine) and register again.");
814
+ }
815
+ return err.retryable ? err : new NonRetryableError(`${err.code}: ${err.message}`);
816
+ }
817
+ }
818
+ }
819
+ /**
820
+ * Facts that don't match the rubric's work order (lib/facts/validate.ts) — the producer's bug,
821
+ * never a claim outcome: the issue list is codes and paths (identifiers), logged and paged so the
822
+ * fix is one grep away, and the error to throw is NonRetryable (a retry would re-run the same
823
+ * producer). The row is already recorded by the caller.
824
+ */
825
+ invalidFacts(deps, claimId, collectionId, built) {
826
+ deps.logger.error("evaluate.facts-invalid", {
827
+ claimId,
828
+ collectionId,
829
+ rubricId: built.rubricId ?? null,
830
+ issues: built.issues,
831
+ });
832
+ const err = new NonRetryableError(`Facts do not match the rubric's work order: ${built.issues.map((i) => `${i.code} ${i.path}`).join(", ")}`);
833
+ reportError(err, { lane: "evaluate", step: "evaluate", claimId, collectionId });
834
+ return err;
835
+ }
836
+ /**
837
+ * A claim the facts lane could not build for — recorded WITHOUT a decide call (schema.ts
838
+ * `decisions`): `status` names the reason, `explanation` the issues, and nothing will ever be
839
+ * acted on, so `chainStatus` is `skipped` from the start. Then the reason's consequence:
840
+ * `rubric-unusable` is the engine's manifest saying the rubric cannot be bound — the same
841
+ * handling as its decide-time `unusable` (pause the collection; steps.ts), minus the receipt, and
842
+ * the ingest completes; `invalid-facts` is OUR (or the provider's) bug, never a claim outcome —
843
+ * recorded so the claim read says why, then failed loudly, like the engine's own `VALIDATION`
844
+ * refusal this check exists to catch first.
845
+ */
846
+ async buildFailure(deps, source, db, claim, built) {
847
+ const { claimId, collectionId } = claim;
848
+ const status = BUILD_FAILURE_STATUS[built.reason];
849
+ const rubricId = built.rubricId ?? null;
850
+ const explanation = built.issues.map((i) => `${i.code}: ${i.message} (${i.path})`).join("; ");
851
+ // A first build never cached anything, but a RUBRIC_PIN_MISMATCH rebuild lands here AFTER the
852
+ // first build's connectors ran — the cache dies with the outcome, same as `record`.
853
+ await db.batch([
854
+ db
855
+ .insert(decisions)
856
+ .values({ claimId, collectionId, rubricId, status, explanation, chainStatus: "skipped" })
857
+ .onConflictDoNothing(),
858
+ factCacheDelete(db, claimId),
859
+ ]);
860
+ // Only a re-pinned rebuild gets here with a review reserved (the first build is checked before
861
+ // reserving): no receipt will ever come, so it is freed — its cap back too.
862
+ if (isChain(source)) {
863
+ await releaseChainClaim({ ...deps, db }, claimId, built.reason === "invalid-facts" ? "facts_invalid" : "facts_unusable");
864
+ }
865
+ if (built.reason === "invalid-facts")
866
+ throw this.invalidFacts(deps, claimId, collectionId, built);
867
+ await pauseUnusable(source, deps.logger, "evaluate", {
868
+ claimId,
869
+ collectionId,
870
+ rubricId,
871
+ issues: built.issues,
872
+ });
873
+ return { status, verdict: null, resultDigest: null, receiptCid: null };
874
+ }
875
+ }
876
+ /** The re-anchored rubric could not be built for — carries the result to be recorded (see `decide`, `buildFailure`). */
877
+ class BuildFailure extends Error {
878
+ result;
879
+ constructor(result) {
880
+ super(`facts lane: ${result.reason}`);
881
+ this.result = result;
882
+ this.name = "BuildFailure";
883
+ }
884
+ }
885
+ // --- the inline run (API lane, a caller that waits) ----------------------------------------------
886
+ /**
887
+ * How long an inline run may still be alive after its caller's own wait ran out: the runtime keeps
888
+ * a request's `waitUntil` work up to 30 s after the response is sent; the rest is margin. The lease
889
+ * (`claim_ingests.inlineUntil`) is the caller's deadline plus this.
890
+ */
891
+ export const INLINE_AFTERLIFE_MS = 45_000;
892
+ /**
893
+ * The accepting request's own run of `evaluate` for an API claim whose caller waits (module header)
894
+ * — called AFTER `startApiClaims` created the claim's workflow, which stays the durable fallback and
895
+ * still runs every step (it finds what this run wrote). Resolves once the claim is decided and its
896
+ * ledger row `processed` (`decided`), or at once when it does nothing (`skipped`: the workflow got
897
+ * the row first, or the claim may not run), or after ending its lease on any failure (`failed`):
898
+ * the row stays `processing`, so the workflow resumes it on its normal retries with the recovery
899
+ * read first. It never throws.
900
+ *
901
+ * The timeline and the webhooks (`notify`, idempotent by event id and leased per delivery) are
902
+ * started in `background` — the request's `waitUntil` — exactly where the workflow's notify steps
903
+ * would send them: `accepted` as the run starts, the decision once it is recorded.
904
+ */
905
+ export async function evaluateInline(env, claimId, opts) {
906
+ const deps = depsFromEnv(env, { ...currentCorrelation(), claimId, workflow: "inline" });
907
+ const scope = { metrics: deps.metrics, logger: deps.logger };
908
+ return withMetrics(scope, async () => {
909
+ const db = getDb(env);
910
+ const t0 = Date.now();
911
+ const done = (outcome) => {
912
+ deps.metrics.count("api.inline", 1, { outcome });
913
+ if (outcome !== "skipped")
914
+ deps.metrics.timing("api.inline", Date.now() - t0, { outcome });
915
+ return outcome;
916
+ };
917
+ // Take the row from `queued` only, and only for a claim that may run — no charge, or one still
918
+ // `reserved` (startApiClaims' rule, re-read here in the same statement: never an unpaid run). A
919
+ // workflow attempt that took it first owns the claim; nothing is run twice.
920
+ const taken = await db
921
+ .update(claimIngests)
922
+ .set({
923
+ status: "processing",
924
+ inlineUntil: new Date(opts.lease),
925
+ instanceId: await instanceIdFor(claimId),
926
+ })
927
+ .where(and(eq(claimIngests.claimId, claimId), eq(claimIngests.status, "queued"), eq(claimIngests.lane, "api"), notExists(db
928
+ .select({ one: sql `1` })
929
+ .from(apiCharges)
930
+ .where(and(eq(apiCharges.claimId, claimId), ne(apiCharges.status, "reserved"))))))
931
+ .returning({ claimId: claimIngests.claimId })
932
+ .catch((err) => {
933
+ deps.logger.warn("api.inline.untaken", { claimId, detail: String(err) });
934
+ return [];
935
+ });
936
+ if (taken.length === 0)
937
+ return done("skipped");
938
+ const source = sourceFor("api", env, deps);
939
+ const notify = (step) => opts.background(source.after.notify(claimId).catch((err) => {
940
+ deps.logger.error(`${step}.failed`, { claimId, detail: String(err), inline: true });
941
+ reportError(err, { lane: "notify", step, claimId });
942
+ }));
943
+ notify("notify-accepted");
944
+ // Every write below is this run's only while it still holds the row: its own lease on it (a
945
+ // workflow that took the row over cleared it).
946
+ const held = and(eq(claimIngests.claimId, claimId), eq(claimIngests.status, "processing"), eq(claimIngests.inlineUntil, new Date(opts.lease)));
947
+ try {
948
+ const result = await new ClaimEvaluator(env).evaluate(deps, source, claimId, {
949
+ inline: true,
950
+ });
951
+ // A claim that waits for review reads its tasks off the `review_required` event (record.ts):
952
+ // that event is written BEFORE the row reads `processed`, so no answer built on `processed`
953
+ // shows placeholder tasks. Its delivery stays with the notify below.
954
+ if (result.status === "pending_review")
955
+ await announceApiClaim(db, deps, claimId);
956
+ // The workflow's `mark-processed`, from the run that decided.
957
+ await db
958
+ .update(claimIngests)
959
+ .set({ status: "processed", error: null, inlineUntil: null })
960
+ .where(held);
961
+ }
962
+ catch (err) {
963
+ // Any failure — transient or deterministic — is the workflow's to handle. The row stays
964
+ // `processing` and the lease ends now, so the workflow neither waits it out nor starts
965
+ // fresh: it resumes as after its own crashed attempt — the recovery read first, as this run
966
+ // may have been decided on the engine before it failed — and finds what was recorded (a
967
+ // failure's `decisions` row, the paid connector results in the fact cache).
968
+ deps.logger.warn("api.inline.failed", { claimId, detail: String(err) });
969
+ await db
970
+ .update(claimIngests)
971
+ .set({ inlineUntil: new Date() })
972
+ .where(held)
973
+ .catch((e) => deps.logger.warn("api.inline.release-failed", { claimId, detail: String(e) }));
974
+ return done("failed");
975
+ }
976
+ notify("notify-review");
977
+ return done("decided");
978
+ });
979
+ }
980
+ //# sourceMappingURL=evaluate.js.map