cascrow-sdk 0.2.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -1,17 +1,231 @@
1
- function makeApiError(status, body) {
2
- const message = typeof body === "object" && body !== null && "error" in body && typeof body.error === "string"
3
- ? body.error
4
- : `HTTP ${status}`;
1
+ import { TX_HASH_RE, V3_CLIENT_TEXT, v3HintFor, v3RefusalDetail } from "./v3.js";
2
+ /**
3
+ * Client deadlines, mirrored from `src/lib/latency-budget.ts` (SEC-073).
4
+ *
5
+ * A published package cannot import from the app, so these are copies by
6
+ * necessity. `standalone-client-deadline.test.ts` is what keeps every copy in the
7
+ * tree equal — a number typed in FOUR places (this file, `cascrow-mcp`,
8
+ * `cascrow-cli`, and the owner in `src/lib/latency-budget.ts`) is four numbers
9
+ * that agree today.
10
+ */
11
+ /** `/api/verify` streams under a 240 s function cap; one step below its kill. */
12
+ const CLIENT_ABORT_MS = 235_000;
13
+ /** `/api/verify/standalone` is capped at 120 s. */
14
+ const STANDALONE_CLIENT_ABORT_MS = 115_000;
15
+ /**
16
+ * The default for every other call. The longest `maxDuration` any route in this
17
+ * API declares is 240 s, so this cannot cut a legitimately slow on-chain call
18
+ * short — it only bounds a connection nobody is on the other end of.
19
+ */
20
+ const REQUEST_TIMEOUT_MS = 240_000;
21
+ /**
22
+ * `/api/proof/upload`: its 30 s function cap plus a transfer allowance for a
23
+ * 20 MB body. Mirrors `UPLOAD_CLIENT_ABORT_MS` in src/lib/latency-budget.ts;
24
+ * `client-deadline-mirrors.test.ts` keeps the two equal. `submitProof` had NO
25
+ * deadline before T11.
26
+ */
27
+ const UPLOAD_CLIENT_ABORT_MS = 120_000;
28
+ /**
29
+ * An API error a caller can branch on (T11-A2): the HTTP status, the stable `code`
30
+ * (see the API's error codes — plus `UPSTREAM_NON_JSON`, which this client sets when
31
+ * the server answered with something that is not JSON), and how long the server
32
+ * asked us to wait (`Retry-After`, else `retry_after_seconds`). Additive: `status`,
33
+ * `body` and the message are what they always were.
34
+ */
35
+ function makeApiError(status, body, headers) {
36
+ const b = (typeof body === "object" && body !== null ? body : {});
37
+ const message = typeof b.error === "string" ? b.error : `HTTP ${status}`;
5
38
  const err = new Error(message);
6
39
  err.status = status;
7
40
  err.body = body;
41
+ err.code = typeof b.code === "string" ? b.code : null;
42
+ const header = headers?.get("retry-after") ?? null;
43
+ err.retryAfterSeconds =
44
+ header !== null && /^\d+$/.test(header.trim())
45
+ ? Number(header.trim())
46
+ : typeof b.retry_after_seconds === "number"
47
+ ? b.retry_after_seconds
48
+ : null;
49
+ // The lost-claim 409 (VERIFICATION_IN_PROGRESS) says whether a run is going and
50
+ // where to watch it — `waitForProof(proofId)` is the call to make then.
51
+ if (typeof b.phase === "string")
52
+ err.phase = b.phase;
53
+ if (typeof b.watch_url === "string")
54
+ err.watchUrl = b.watch_url;
55
+ if (typeof b.expected_done_in_seconds === "number")
56
+ err.expectedDoneInSeconds = b.expected_done_in_seconds;
57
+ // A MilestoneEscrowV3 refusal says who can act and how (F-72): the body's actionable
58
+ // fields (G5), then the owner's words, role-aware on the body (H9).
59
+ const detail = v3RefusalDetail(body);
60
+ if (detail && !err.message.includes(detail))
61
+ err.message += ` — ${detail}`;
62
+ const hint = v3HintFor(err.code, body);
63
+ if (hint) {
64
+ err.hint = hint;
65
+ if (!err.message.includes(hint))
66
+ err.message += ` — ${hint}`;
67
+ }
8
68
  return err;
9
69
  }
70
+ /** A body parsed without trusting it to be JSON: an HTML 502 is "HTTP 502 (non-JSON response from server)". */
71
+ async function readJsonSafe(res) {
72
+ const text = await res.text();
73
+ if (!text)
74
+ return {};
75
+ try {
76
+ return JSON.parse(text);
77
+ }
78
+ catch {
79
+ return { error: `HTTP ${res.status} (non-JSON response from server)`, code: "UPSTREAM_NON_JSON" };
80
+ }
81
+ }
82
+ /** True when `value` is the non-JSON marker `readJsonSafe` produced. */
83
+ function isNonJson(value) {
84
+ return typeof value === "object" && value !== null && value.code === "UPSTREAM_NON_JSON";
85
+ }
86
+ // ─── The bar a milestone is judged against ────────────────────────────────────
87
+ // COPY of normalizeMilestoneChecklist / normalizeCriteria in src/lib/milestone-bar-input.ts
88
+ // (a published package cannot import the app). milestone-bar-input-parity.test.ts runs
89
+ // this copy against the owner over the same case matrix.
90
+ const CHECKLIST_MAX_ITEMS = 20;
91
+ const CHECKLIST_TITLE_MAX = 200;
92
+ const CHECKLIST_ID_MAX = 100;
93
+ const CRITERIA_MAX = 4000;
94
+ const CHECKLIST_SEVERITIES = ["CRITICAL", "HIGH", "MEDIUM", "LOW"];
95
+ function normalizeMilestoneChecklist(items) {
96
+ if (items === undefined || items === null)
97
+ return undefined;
98
+ if (!Array.isArray(items))
99
+ throw new Error("verificationChecklist: must be an array of { title, severity?, id? }");
100
+ if (items.length === 0)
101
+ return undefined;
102
+ if (items.length > CHECKLIST_MAX_ITEMS)
103
+ throw new Error(`verificationChecklist: at most ${CHECKLIST_MAX_ITEMS} items`);
104
+ items.forEach((raw, i) => {
105
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
106
+ throw new Error(`verificationChecklist[${i}]: must be an object with a title`);
107
+ }
108
+ });
109
+ const withId = items.filter((it) => it.id !== undefined).length;
110
+ if (withId !== 0 && withId !== items.length) {
111
+ throw new Error("verificationChecklist: either give every item an id or none (ids are then assigned 1..n)");
112
+ }
113
+ const seen = new Set();
114
+ return items.map((raw, i) => {
115
+ const it = raw;
116
+ const title = typeof it.title === "string" ? it.title.trim() : "";
117
+ if (title.length === 0)
118
+ throw new Error(`verificationChecklist[${i}]: title is required`);
119
+ if (title.length > CHECKLIST_TITLE_MAX)
120
+ throw new Error(`verificationChecklist[${i}]: title must be at most ${CHECKLIST_TITLE_MAX} characters`);
121
+ let id = i + 1;
122
+ if (withId) {
123
+ if (typeof it.id !== "number" || !Number.isInteger(it.id) || it.id < 1 || it.id > CHECKLIST_ID_MAX) {
124
+ throw new Error(`verificationChecklist[${i}]: id must be an integer from 1 to ${CHECKLIST_ID_MAX}`);
125
+ }
126
+ if (seen.has(it.id))
127
+ throw new Error(`verificationChecklist[${i}]: duplicate id ${it.id}`);
128
+ seen.add(it.id);
129
+ id = it.id;
130
+ }
131
+ const out = { id, title };
132
+ if (it.severity !== undefined && it.severity !== null && it.severity !== "") {
133
+ const sev = typeof it.severity === "string" ? it.severity.trim().toUpperCase() : "";
134
+ if (!CHECKLIST_SEVERITIES.includes(sev)) {
135
+ throw new Error(`verificationChecklist[${i}]: severity must be one of ${CHECKLIST_SEVERITIES.join(", ")}`);
136
+ }
137
+ out.severity = sev;
138
+ }
139
+ return out;
140
+ });
141
+ }
142
+ function normalizeCriteria(value) {
143
+ if (value === undefined || value === null)
144
+ return undefined;
145
+ if (typeof value !== "string")
146
+ throw new Error("verificationCriteria: must be a string");
147
+ const v = value.trim();
148
+ if (v.length === 0)
149
+ return undefined;
150
+ if (v.length > CRITERIA_MAX)
151
+ throw new Error(`verificationCriteria: at most ${CRITERIA_MAX} characters (got ${v.length})`);
152
+ return v;
153
+ }
10
154
  /**
11
155
  * Typed client for the Cascrow API. Wraps the same REST endpoints used by
12
156
  * the cascrow-mcp server and the Cascrow web app — this is the plain-code
13
157
  * equivalent for applications that don't go through an MCP/LLM tool call.
14
158
  */
159
+ /** Every `action` the verification stream can send. Kept next to the check so the
160
+ * two cannot drift: adding a value to `VerificationResult["action"]` without adding
161
+ * it here makes this file fail to compile. */
162
+ const STREAM_ACTIONS = ["VERIFIED", "PENDING_REVIEW", "REJECTED", "COMPLETED", "SUPERSEDED", "V3_RECOMMENDATION", "NOT_COMPLETABLE_WITHOUT_ESCROW"];
163
+ /**
164
+ * The stream's `action`, or a thrown error — never an invented verdict.
165
+ *
166
+ * SEC-059. This used to be `(msg.action as VerificationResult["action"]) ?? "REJECTED"`,
167
+ * twice. Two things were wrong with it. The cast accepted `COMPLETED` and `SUPERSEDED`
168
+ * — real values the stream sends — while the declared union listed neither, so the
169
+ * returned object was outside its own type and nothing said so. And the fallback
170
+ * invented `REJECTED` for an absent field: a missing `action` is a protocol violation,
171
+ * not a rejection, and defaulting it in the one direction that costs the builder their
172
+ * reputation is the worst available choice. Every `complete` frame the route sends
173
+ * carries an action, so this cannot fire against a healthy server; when it does fire,
174
+ * it throws the way a malformed frame already does rather than guessing.
175
+ */
176
+ function requireAction(value) {
177
+ if (typeof value === "string" && STREAM_ACTIONS.includes(value)) {
178
+ return value;
179
+ }
180
+ throw new Error(`Verification stream sent an action this client does not know: ${JSON.stringify(value)}. ` +
181
+ `Expected one of ${STREAM_ACTIONS.join(", ")} — upgrade cascrow-sdk.`);
182
+ }
183
+ /**
184
+ * The terminal status of the row a proof settles against, or null while it is open.
185
+ * `milestoneClosed` is derived from the row by `/api/agent/proof-status`;
186
+ * `milestoneStatus` is the fallback for a server that predates the field.
187
+ */
188
+ function closedMilestoneStatus(s) {
189
+ if (s.milestoneClosed === true)
190
+ return s.terminalStatus || s.milestoneStatus || s.contractStatus || "EXPIRED";
191
+ // A legacy contract-level proof has no milestone: an older server reports its row only
192
+ // as `contractStatus`, and reading `milestoneStatus` alone reported a proof whose
193
+ // contract had EXPIRED as open.
194
+ const row = s.milestoneStatus ?? s.contractStatus;
195
+ return row === "EXPIRED" || row === "COMPLETED" ? row : null;
196
+ }
197
+ /**
198
+ * True when the milestone is approved (VERIFIED) or paid (COMPLETED) and this proof has
199
+ * no YES of its own: another proof won it, or the funder approved it in review — which
200
+ * leaves this proof's `aiDecision` untouched (NO, or null after a panel outage). Nothing
201
+ * is left for this proof to decide: `/api/verify`'s claim excludes VERIFIED and
202
+ * COMPLETED for every verdict, so a pending proof there never gets one, and the loop
203
+ * below used to burn its whole timeout on it. Same predicate as both MCP surfaces.
204
+ */
205
+ function approvedWithoutThisVerdict(s) {
206
+ // Except while this proof's panel is RUNNING on a VERIFIED row. VERIFIED is not sticky:
207
+ // a definite release failure rolls it back, `contracts/review` hands it back to
208
+ // PENDING_REVIEW and the cron's hand-back blocks do the same, and a running claim then
209
+ // decides the row after all. COMPLETED is terminal and stays final either way.
210
+ // `running` is absent on a server that predates it, which keeps the old reading.
211
+ const row = closedMilestoneStatus(s) === "COMPLETED" ? "COMPLETED" : (s.milestoneStatus ?? s.contractStatus);
212
+ if (s.decision === "YES")
213
+ return false;
214
+ return row === "COMPLETED" || (row === "VERIFIED" && s.running !== true);
215
+ }
216
+ /**
217
+ * Parked for a human: no verdict, the milestone at `PENDING_REVIEW`, and no panel running
218
+ * on this proof. A RUNNING proof on a PENDING_REVIEW row is not parked — a sibling proof
219
+ * in the 60-85 band put the milestone there while this one's panel still runs, and this
220
+ * one can still take it to COMPLETED. Same test as `parkedForHuman` at both MCP surfaces;
221
+ * without `running` (an older server) the old reading stays.
222
+ */
223
+ function parkedForHuman(s) {
224
+ // Never a MilestoneEscrowV3 proof (post-review L2): no human review decides one — the
225
+ // milestone's status is the fold's, from the chain, and a PENDING_REVIEW there is no
226
+ // queue this proof waits in.
227
+ return s.escrowVersion !== 3 && Boolean(s.pending) && s.running !== true && s.milestoneStatus === "PENDING_REVIEW";
228
+ }
15
229
  export class CascrowClient {
16
230
  apiKey;
17
231
  baseUrl;
@@ -19,22 +233,123 @@ export class CascrowClient {
19
233
  this.apiKey = options.apiKey;
20
234
  this.baseUrl = options.baseUrl ?? "https://cascrow.com";
21
235
  }
236
+ /**
237
+ * MilestoneEscrowV3 — the remote half. cascrow prepares and reads; YOU sign and
238
+ * send with your own key. This SDK never signs: use the separate, pinned
239
+ * `cascrow-signer` package (`npx cascrow-signer@<pin> …`, see CASCROW_SIGNER_PIN),
240
+ * which checks every prepared transaction against its own chain and contract.
241
+ */
242
+ v3 = {
243
+ /** As PAYER: offer terms for a milestone. 409 `OFFER_EXISTS` names the open offer. */
244
+ offer: (input) => this.request("POST", "/api/escrow/v3/offer", input),
245
+ /** As PAYEE: the frozen offer to check and sign (EIP-712). 409 `V3_NOT_OFFERED` before the payer offered. */
246
+ acceptance: (milestoneId) => this.request("GET", `/api/escrow/v3/acceptance?milestoneId=${encodeURIComponent(milestoneId)}`),
247
+ /** As PAYEE: hand over the acceptance signature — binding until acceptBy. */
248
+ submitAcceptance: (offerId, signature) => this.request("POST", "/api/escrow/v3/acceptance", { offerId, signature }),
249
+ /** An UNSIGNED transaction. Rebuild `data` and check `to`/`chainId` yourself before signing. */
250
+ prepare: (milestoneId, action, extra = {}) => {
251
+ const q = new URLSearchParams({ milestoneId, action });
252
+ if (extra.proofHash)
253
+ q.set("proofHash", extra.proofHash);
254
+ if (extra.reasonHash)
255
+ q.set("reasonHash", extra.reasonHash);
256
+ if (extra.newDeadline !== undefined)
257
+ q.set("newDeadline", String(extra.newDeadline));
258
+ return this.request("GET", `/api/escrow/v3/prepare?${q.toString()}`);
259
+ },
260
+ /** After YOU broadcast: the hash as a hint to re-read the chain. Only a hash — never a signed transaction. */
261
+ reportTx: (milestoneId, txHash) => {
262
+ // Refused before any request, with the same stable code both MCP surfaces use (L3).
263
+ if (!TX_HASH_RE.test(txHash))
264
+ return Promise.reject(makeApiError(0, { error: V3_CLIENT_TEXT.reportTxOnlyHash, code: "NOT_A_TX_HASH" }));
265
+ return this.request("POST", "/api/escrow/v3/sync", { milestoneId, txHash });
266
+ },
267
+ /** cascrow's signed, NON-BINDING AI recommendation for a claim. 409 `ATTESTATION_PENDING` while the panel runs. */
268
+ attestation: (escrowId, claimIndex) => this.request("GET", `/api/escrow/v3/attestation?${new URLSearchParams({ escrowId, claimIndex: String(claimIndex) }).toString()}`),
269
+ /** As PAYER: open claims you can still object to (same as `v3Claims()`). */
270
+ claims: () => this.request("GET", "/api/agent/v3-claims"),
271
+ };
22
272
  authHeaders() {
23
273
  return this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {};
24
274
  }
25
- async request(method, path, body) {
26
- const res = await fetch(`${this.baseUrl}${path}`, {
27
- method,
28
- headers: {
29
- ...this.authHeaders(),
30
- ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
31
- },
32
- body: body !== undefined ? JSON.stringify(body) : undefined,
33
- });
34
- const data = await res.json().catch(() => ({}));
35
- if (!res.ok)
36
- throw makeApiError(res.status, data);
37
- return data;
275
+ /**
276
+ * Every non-streaming call carries a deadline (SEC-073).
277
+ *
278
+ * Before this, the only `AbortController` in the SDK was inside `verify()`, the
279
+ * streaming method — so `verifyWork()`, which waits on a route the platform
280
+ * kills at 120 s, waited forever and then surfaced a torn connection. The
281
+ * default is the longest cap any route in this API declares (240 s), so it
282
+ * cannot cut a legitimately slow on-chain call short; `verifyWork` passes the
283
+ * tighter one because its own route is capped lower.
284
+ *
285
+ * An `AbortError` is re-thrown as a sentence, not as the DOM error: a caller
286
+ * cannot tell "we stopped waiting" from "the panel said no" out of an
287
+ * exception whose message is "This operation was aborted" (F-72).
288
+ */
289
+ async request(method, path, body, timeoutMs = REQUEST_TIMEOUT_MS) {
290
+ const controller = new AbortController();
291
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
292
+ const isForm = typeof FormData !== "undefined" && body instanceof FormData;
293
+ let res;
294
+ try {
295
+ res = await fetch(`${this.baseUrl}${path}`, {
296
+ method,
297
+ headers: {
298
+ ...this.authHeaders(),
299
+ ...(body !== undefined && !isForm ? { "Content-Type": "application/json" } : {}),
300
+ },
301
+ body: body === undefined ? undefined : isForm ? body : JSON.stringify(body),
302
+ signal: controller.signal,
303
+ });
304
+ }
305
+ catch (err) {
306
+ if (err instanceof Error && err.name === "AbortError") {
307
+ throw new Error(`cascrow did not answer ${method} ${path} within ${Math.round(timeoutMs / 1000)}s. ` +
308
+ "This client stopped waiting; the server may still be working on it.");
309
+ }
310
+ clearTimeout(timer);
311
+ throw err;
312
+ }
313
+ // Cleared AFTER the body, not in a `finally` around the header fetch: a
314
+ // server that sends headers and then stalls the body was otherwise
315
+ // unbounded, and the promise below is what the caller actually waits on.
316
+ // The streaming path in this same file learned this already — its abort
317
+ // "only became load-bearing when the clearTimeout moved below the read loop".
318
+ try {
319
+ let data;
320
+ try {
321
+ data = await readJsonSafe(res);
322
+ }
323
+ catch (err) {
324
+ // The deadline can fire during the body read too; say so the same way as
325
+ // at the header fetch, with the code a caller branches on.
326
+ if (err instanceof Error && err.name === "AbortError") {
327
+ const e = new Error(`cascrow did not finish answering ${method} ${path} within ${Math.round(timeoutMs / 1000)}s. ` +
328
+ "This client stopped waiting; the server may still be working on it.");
329
+ e.status = res.status;
330
+ e.body = null;
331
+ e.code = "CLIENT_TIMEOUT";
332
+ e.retryAfterSeconds = null;
333
+ throw e;
334
+ }
335
+ throw err;
336
+ }
337
+ // A 200 that is not JSON is not a success anyone can read.
338
+ if (!res.ok || isNonJson(data)) {
339
+ const err = makeApiError(res.status, data, res.headers);
340
+ // A platform error page on a POST (the 504 when a route hits its own
341
+ // maxDuration — escrow/finish waits on the chain) does not mean the call failed: the
342
+ // function may have moved money before it was killed.
343
+ if (method !== "GET" && res.status >= 500 && isNonJson(data)) {
344
+ err.message += ". The request may still have completed on the server — check the contract (getContract) before repeating it.";
345
+ }
346
+ throw err;
347
+ }
348
+ return data;
349
+ }
350
+ finally {
351
+ clearTimeout(timer);
352
+ }
38
353
  }
39
354
  // ─── Auth ───────────────────────────────────────────────────────────────────
40
355
  /** Self-register an agent account. No CAPTCHA, no email verification. Returns an API key — save it, it is shown only once. */
@@ -57,11 +372,27 @@ export class CascrowClient {
57
372
  return d.toISOString();
58
373
  };
59
374
  const data = await this.request("POST", "/api/contracts", {
60
- milestones: input.milestones.map((m) => ({
61
- title: m.title,
62
- amountUSD: m.amountUSD ?? 0,
63
- cancelAfter: cancelAfter(m.daysUntilDeadline),
64
- })),
375
+ milestones: input.milestones.map((m, i) => {
376
+ // T07. Normalised here so a bad item is refused with its milestone and
377
+ // item index, not as a 400 on the whole contract. Nothing given → the
378
+ // body is what it always was.
379
+ let criteria;
380
+ let checklist;
381
+ try {
382
+ criteria = normalizeCriteria(m.verificationCriteria);
383
+ checklist = normalizeMilestoneChecklist(m.verificationChecklist);
384
+ }
385
+ catch (e) {
386
+ throw new Error(`milestones[${i}]: ${e instanceof Error ? e.message : String(e)}`);
387
+ }
388
+ return {
389
+ title: m.title,
390
+ amountUSD: m.amountUSD ?? 0,
391
+ cancelAfter: cancelAfter(m.daysUntilDeadline),
392
+ ...(criteria !== undefined ? { verificationCriteria: criteria } : {}),
393
+ ...(checklist !== undefined ? { verificationChecklist: checklist } : {}),
394
+ };
395
+ }),
65
396
  agentReviewMode: input.agentReviewMode,
66
397
  });
67
398
  return {
@@ -79,13 +410,76 @@ export class CascrowClient {
79
410
  const qs = options.role ? `?role=${encodeURIComponent(options.role)}` : "";
80
411
  return this.request("GET", `/api/agent/my-contracts${qs}`);
81
412
  }
82
- /** Join an existing contract as the Builder using an invite code. */
413
+ /**
414
+ * Join an existing contract as the Builder using an invite code.
415
+ *
416
+ * `activated: true` — a $0 verification contract: it is active (`status: "FUNDED"`)
417
+ * and you can submit proof now. `activated: false` — the milestones carry an amount:
418
+ * wait for the payer's MilestoneEscrowV3 offer and accept it with your own wallet key
419
+ * (`npx cascrow-signer@0.1.0 accept --milestone <id>`, or `client.v3.acceptance` +
420
+ * `client.v3.submitAcceptance`); proof is accepted once the payer has funded it.
421
+ * `joinedLine(result)` renders that as one sentence.
422
+ */
83
423
  async joinContract(inviteCode) {
84
424
  return this.request("POST", "/api/contracts/join", { inviteCode });
85
425
  }
86
- /** Approve or reject a borderline (PENDING_REVIEW) contract. Only valid when agentReviewMode is MANUAL or MANUAL_AUTO. */
87
- async reviewContract(contractId, decision) {
88
- return this.request("POST", "/api/contracts/review", { contractId, decision });
426
+ /**
427
+ * Approve or reject a borderline (PENDING_REVIEW) MILESTONE. Only valid when
428
+ * agentReviewMode is MANUAL or MANUAL_AUTO.
429
+ *
430
+ * The CONTRACT row need not be PENDING_REVIEW: funding a later milestone
431
+ * overwrites that column, and such a contract is still reviewable. Only a
432
+ * terminal contract (COMPLETED, EXPIRED, DECLINED) is refused.
433
+ *
434
+ * `milestoneId` is optional while exactly one milestone of the contract is
435
+ * pending review and REQUIRED once more than one is — the API answers 400
436
+ * "Multiple milestones are pending review — specify milestoneId to
437
+ * disambiguate". Until this parameter existed an SDK caller had no way to
438
+ * comply with that refusal, which is exactly the case SEC-076 is about: a
439
+ * multi-milestone contract where a second proof lands while the first is
440
+ * still under review (SEC-120, F-72).
441
+ *
442
+ * Additive optional third argument — every existing call site still compiles.
443
+ */
444
+ async reviewContract(contractId, decision, milestoneId) {
445
+ return this.request("POST", "/api/contracts/review", milestoneId ? { contractId, decision, milestoneId } : { contractId, decision });
446
+ }
447
+ /**
448
+ * The contracts on which THIS agent, as funder, still owes a manual verdict.
449
+ *
450
+ * The pull behind the `manual_review.required` webhook. A webhook that does
451
+ * not arrive — the agent was down, the endpoint 500'd, the subscription was
452
+ * never made — is a decision that never gets made, and `cron/cancel-expired`
453
+ * then completes the milestone after 48 h (agent contracts in MANUAL_AUTO or
454
+ * AUTO) or 14 days (every other contract) — verification-only contracts, so
455
+ * no money moves, and nobody decided it. The whole point of the 60–85 confidence
456
+ * band is that somebody looks.
457
+ *
458
+ * Every browser surface the funder has for this asks
459
+ * `funderReviewQueueWhere()`; so does this route, so the queue an agent polls
460
+ * and the queue `reviewContract` accepts a verdict on cannot come apart.
461
+ *
462
+ * `reviewMilestoneId` is what to pass to {@link reviewContract}: non-null when
463
+ * exactly one milestone of that contract is pending, `null` for the legacy
464
+ * contract-level population that is reviewed by `contractId` alone. When
465
+ * `pendingMilestones` holds more than one entry the milestone has to be chosen
466
+ * — `POST /api/contracts/review` answers 400 otherwise.
467
+ *
468
+ * `truncated` says the list is capped while `total` is not, so a poller does
469
+ * not read a short list as "that was all".
470
+ */
471
+ async reviewQueue() {
472
+ return this.request("GET", "/api/agent/review-queue");
473
+ }
474
+ /**
475
+ * As the PAYER of a MilestoneEscrowV3 escrow: the claims that release it to the
476
+ * builder unless your own wallet calls `object(escrowId)` before
477
+ * `objectionDeadline` — the pull behind the `claim.submitted` webhook. cascrow
478
+ * cannot object for you. Throws with code `V3_INDEX_STALE` (503) while the index
479
+ * is not current; read the chain directly then.
480
+ */
481
+ async v3Claims() {
482
+ return this.request("GET", "/api/agent/v3-claims");
89
483
  }
90
484
  /** Check for pending contract invites sent to this agent. */
91
485
  async checkInvites() {
@@ -96,29 +490,27 @@ export class CascrowClient {
96
490
  return this.request("POST", "/api/agent/handoff", input);
97
491
  }
98
492
  // ─── Funding ────────────────────────────────────────────────────────────────
99
- /** Fund (activate) a milestone via MetaMask/browser flow so it can receive proof. */
100
- async fundMilestone(contractId, milestoneId) {
101
- return this.request("POST", "/api/agent/fund-milestone", milestoneId ? { contractId, milestoneId } : { contractId });
102
- }
103
- /** Fund a milestone with real on-chain RLUSD escrow using the agent's own EVM private key. */
104
- async escrowFund(input) {
105
- return this.request("POST", "/api/agent/escrow-fund", input);
106
- }
493
+ // `fundMilestone()` was removed in 0.7.0 (cascrow Phase 6, owner decision 16): a
494
+ // $0 verification contract is active by itself once the builder has joined, and a
495
+ // milestone with an amount is a MilestoneEscrowV3 escrow its payer funds with one
496
+ // transaction from their own machine (`npx cascrow-signer fund --milestone <id>`,
497
+ // after `client.v3.offer` and the builder's acceptance). The SDK signs nothing.
498
+ //
499
+ // `escrowFund()` was removed in 0.5.0 (cascrow Phase 5): it sent the agent's
500
+ // private key to cascrow, which signed the deposit server-side. cascrow never
501
+ // accepts, creates, stores or signs with anyone's key; the server answers the old
502
+ // path with 410 CUSTODY_PATH_REMOVED.
107
503
  // ─── Proof & verification ───────────────────────────────────────────────────
108
- /** Submit a text proof report for a milestone. Automatically triggers AI verification. */
504
+ /**
505
+ * Submit a text proof report for a milestone. Verification starts by itself —
506
+ * wait for the verdict with `waitForProof(proofId)`; do not call `verify()` for it,
507
+ * that run is already going.
508
+ */
109
509
  async submitProof(input) {
110
510
  const form = new FormData();
111
511
  form.append("milestoneId", input.milestoneId);
112
512
  form.append("file", new Blob([input.content], { type: "text/plain" }), input.filename ?? "proof-report.txt");
113
- const res = await fetch(`${this.baseUrl}/api/proof/upload`, {
114
- method: "POST",
115
- headers: this.authHeaders(),
116
- body: form,
117
- });
118
- const data = await res.json().catch(() => ({}));
119
- if (!res.ok)
120
- throw makeApiError(res.status, data);
121
- return data;
513
+ return this.request("POST", "/api/proof/upload", form, UPLOAD_CLIENT_ABORT_MS);
122
514
  }
123
515
  /** Check the AI verification result for a submitted proof (poll after submitProof). */
124
516
  async getProofStatus(proofId) {
@@ -137,7 +529,10 @@ export class CascrowClient {
137
529
  async verify(proofId, options = {}) {
138
530
  const resolveOn = options.resolveOn ?? "complete";
139
531
  const controller = new AbortController();
140
- const timeout = setTimeout(() => controller.abort(), 200_000);
532
+ // SEC-073: 235_000, and it only became load-bearing when the clearTimeout
533
+ // moved below the read loop — see src/lib/latency-budget.ts:CLIENT_ABORT_MS.
534
+ // Kept as a numeric literal because this package cannot import from src/.
535
+ const timeout = setTimeout(() => controller.abort(), CLIENT_ABORT_MS);
141
536
  let res;
142
537
  try {
143
538
  res = await fetch(`${this.baseUrl}/api/verify`, {
@@ -147,12 +542,28 @@ export class CascrowClient {
147
542
  signal: controller.signal,
148
543
  });
149
544
  }
150
- finally {
545
+ catch (e) {
151
546
  clearTimeout(timeout);
547
+ if (e instanceof Error && e.name === "AbortError") {
548
+ throw new Error(`Verification is still running on the server — this client stopped waiting, it did not fail. Poll GET /api/agent/proof-status/${proofId} for the verdict.`);
549
+ }
550
+ throw e;
152
551
  }
153
- if (!res.ok || !res.body) {
154
- const d = await res.json().catch(() => ({}));
155
- throw makeApiError(res.status, d);
552
+ if (!res.ok) {
553
+ clearTimeout(timeout);
554
+ // A 409 with code VERIFICATION_IN_PROGRESS and phase "running" means a run is
555
+ // already going (usually the one upload started): the error carries `phase`,
556
+ // `watchUrl` and `expectedDoneInSeconds` — call `waitForProof(proofId)`. Its
557
+ // `retryAfterSeconds` is the ~1 h takeover cutoff, NOT a backoff.
558
+ throw makeApiError(res.status, await readJsonSafe(res), res.headers);
559
+ }
560
+ // Split from the line above, which was `!res.ok || !res.body`. A 200 with no
561
+ // body then produced `makeApiError(200, {})` — an "API error" carrying a
562
+ // success status, and the one door of six without the separate branch the
563
+ // other five have.
564
+ if (!res.body) {
565
+ clearTimeout(timeout);
566
+ throw new Error("Verification stream had no body");
156
567
  }
157
568
  const votes = [];
158
569
  const reader = res.body.getReader();
@@ -186,20 +597,60 @@ export class CascrowClient {
186
597
  return {
187
598
  decision: msg.decision ?? "NO",
188
599
  confidence: msg.confidence ?? 0,
189
- action: msg.action ?? "REJECTED",
600
+ action: requireAction(msg.action),
190
601
  reasoning: msg.reasoning ?? "",
191
602
  modelVotes: votes,
192
603
  txHash: null,
193
604
  };
605
+ // `reasoning` here is MODEL OUTPUT over evidence the counterparty
606
+ // submitted, and it reaches you RAW. The MCP servers prefix it with an
607
+ // explicit untrusted-content marker; this SDK does not, deliberately —
608
+ // its return value may equally end up in a UI, where a marker is noise
609
+ // on a page, and a library cannot know which.
610
+ //
611
+ // That asymmetry was justified in the app for a while with a sentence
612
+ // that is FALSE of this file: "an agent reaching the SSE frames does so
613
+ // through an MCP server that marks at its own end". A review pass found
614
+ // this door — `verify()`/`verifyAndWait` hand the field to a
615
+ // TypeScript agent with no MCP server in the path. The decision is
616
+ // unchanged; the reason is now stated where it is true. If you feed this
617
+ // into a model, treat it as data and not as instructions.
194
618
  }
195
619
  else if (msg.type === "complete") {
196
620
  return {
197
621
  decision: msg.decision ?? "NO",
198
622
  confidence: msg.confidence ?? 0,
199
- action: msg.action ?? "REJECTED",
623
+ action: requireAction(msg.action),
200
624
  reasoning: msg.reasoning ?? "",
201
625
  modelVotes: votes,
202
626
  txHash: msg.txHash ?? null,
627
+ // Forwarded because `action` alone does not answer "did the money move":
628
+ // a VERIFIED with a hold or a compliance block did not, and a COMPLETED
629
+ // that someone else released did. SEC-058 / SEC-059.
630
+ ...(msg.alreadyReleased === true ? { alreadyReleased: true } : {}),
631
+ // The panel approved a milestone nothing can complete (an amount without a
632
+ // MilestoneEscrowV3 escrow): the server's sentence and remedy, forwarded.
633
+ ...(msg.action === "NOT_COMPLETABLE_WITHOUT_ESCROW"
634
+ ? {
635
+ ...(typeof msg.error === "string" ? { error: msg.error } : {}),
636
+ ...(typeof msg.remedy === "string" ? { remedy: msg.remedy } : {}),
637
+ }
638
+ : {}),
639
+ ...(msg.complianceBlocked === true ? { complianceBlocked: true } : {}),
640
+ ...(msg.timedOut === true ? { timedOut: true } : {}),
641
+ // With `action: "SUPERSEDED"`: the milestone was closed (e.g. EXPIRED)
642
+ // before this verification could settle it, rather than decided by
643
+ // another proof.
644
+ ...(typeof msg.settledAs === "string" ? { settledAs: msg.settledAs } : {}),
645
+ // MilestoneEscrowV3: the claim-relevant answer is the SUBMISSION's verdict,
646
+ // not `decision` (the panel on this one file). Absent → PENDING, never YES.
647
+ ...(msg.action === "V3_RECOMMENDATION"
648
+ ? {
649
+ manifestHash: typeof msg.manifestHash === "string" ? msg.manifestHash : null,
650
+ submissionVerdict: msg.submissionVerdict === "YES" || msg.submissionVerdict === "NO" ? msg.submissionVerdict : "PENDING",
651
+ claimReady: msg.claimReady === true,
652
+ }
653
+ : {}),
203
654
  };
204
655
  }
205
656
  else if (msg.type === "error") {
@@ -207,15 +658,35 @@ export class CascrowClient {
207
658
  }
208
659
  }
209
660
  }
661
+ // SEC-073. The abort timer is cleared HERE, after the read loop — not in a
662
+ // `finally` around the `fetch`. `fetch` resolves on response HEADERS, about
663
+ // 12 s into `/api/verify`, and the entire 205 s retry ladder arrives
664
+ // afterwards out of `res.body`. Clearing it at the fetch made this a
665
+ // time-to-first-byte deadline over a twelve-second phase, so the number
666
+ // bounded nothing that mattered and could sit below the server's worst
667
+ // case unnoticed.
668
+ }
669
+ catch (e) {
670
+ // SEC-073. Live deadline, so this can fire mid-read; and per
671
+ // latency-budget.ts the abort can land after releaseMilestone(), which
672
+ // makes a bare "aborted" not merely unhelpful but possibly false about
673
+ // whether the money moved.
674
+ if (e instanceof Error && e.name === "AbortError") {
675
+ throw new Error(`Verification is still running on the server — this client stopped waiting, it did not fail. Poll GET /api/agent/proof-status/${proofId} for the verdict.`);
676
+ }
677
+ throw e;
210
678
  }
211
679
  finally {
680
+ clearTimeout(timeout);
212
681
  reader.cancel().catch(() => { });
213
682
  }
214
683
  throw new Error("Verification stream ended without a complete event");
215
684
  }
216
685
  /**
217
686
  * Submit proof and get an immediate verdict in one call — runs the full
218
- * 5-model pipeline and, on approval, releases funds and mints the NFT.
687
+ * 5-model pipeline. On approval a verification-only milestone completes and its NFT
688
+ * is minted; for a MilestoneEscrowV3 milestone the verdict is a recommendation
689
+ * (`verdict: "v3_recommendation"`) and moves no money.
219
690
  */
220
691
  async mcpSubmit(input) {
221
692
  const data = await this.request("POST", "/api/mcp/submit", {
@@ -229,12 +700,34 @@ export class CascrowClient {
229
700
  custom_fields: input.evidence.customFields,
230
701
  },
231
702
  });
703
+ // Ordered so the two verdict-less responses are classified before the field that
704
+ // is missing on both is read. A `?? "rejected"` here would be the same
705
+ // wrong-direction default `requireAction` above was written to remove.
706
+ const verdict = data.action === "V3_RECOMMENDATION"
707
+ ? "v3_recommendation"
708
+ : data.decided === "elsewhere"
709
+ ? "decided_elsewhere"
710
+ : data.panelUnavailable
711
+ ? "panel_unavailable"
712
+ : data.verdict;
713
+ if (!verdict) {
714
+ throw new Error("mcp/submit answered 200 with no verdict and no known refusal state — upgrade cascrow-sdk.");
715
+ }
232
716
  return {
233
- verdict: data.verdict,
234
- confidence: data.confidence,
235
- reasoning: data.reasoning,
236
- onChainUrl: data.on_chain_url,
237
- proofId: data.proof_id,
717
+ verdict,
718
+ confidence: data.confidence ?? null,
719
+ reasoning: data.reasoning ?? "",
720
+ onChainUrl: data.on_chain_url ?? null,
721
+ proofId: data.proof_id ?? null,
722
+ settledAs: verdict === "decided_elsewhere" && typeof data.settledAs === "string" ? data.settledAs : null,
723
+ ...(verdict === "v3_recommendation"
724
+ ? {
725
+ decision: data.decision ?? null,
726
+ manifestHash: data.manifestHash ?? null,
727
+ submissionVerdict: data.submissionVerdict === "YES" || data.submissionVerdict === "NO" ? data.submissionVerdict : "PENDING",
728
+ claimReady: data.claimReady === true,
729
+ }
730
+ : {}),
238
731
  };
239
732
  }
240
733
  /**
@@ -245,34 +738,163 @@ export class CascrowClient {
245
738
  * yet, so do not treat a key as a way to get more runs.
246
739
  */
247
740
  async verifyWork(input) {
248
- return this.request("POST", "/api/verify/standalone", input);
741
+ return this.request("POST", "/api/verify/standalone", input, STANDALONE_CLIENT_ABORT_MS);
742
+ }
743
+ /**
744
+ * True when a thrown `CascrowApiError` means "cascrow could not run the panel",
745
+ * not "the work was not verified" (SEC-128).
746
+ *
747
+ * `verifyWork` resolves ONLY when a verdict exists. When fewer than three of the
748
+ * five models answer, the endpoint answers 503 with `panelUnavailable: true` and
749
+ * carries NO decision and NO confidence, so `request` throws — deliberately, and
750
+ * that is the whole repair: the previous behaviour was a 200 carrying
751
+ * `decision: "NO", confidence: 65`, which every consumer rendered as a rejection
752
+ * of the submitted work. An outage is not a verdict, and a retry is the right
753
+ * next move rather than a resubmission with "stronger" evidence.
754
+ *
755
+ * A thrown 500 from the same endpoint is NOT this: it reports `false` here,
756
+ * because "the models did not answer" is a claim about the panel and a crash is
757
+ * not evidence for it.
758
+ */
759
+ static isPanelUnavailable(err) {
760
+ const body = err?.body;
761
+ return typeof body === "object" && body !== null && body.panelUnavailable === true;
249
762
  }
250
763
  /**
251
764
  * Submit a proof and wait for AI verification to complete — combines
252
765
  * submitProof + polling into a single call. Times out after ~100s and
253
766
  * returns { pending: true, proofId } so you can poll manually.
767
+ *
768
+ * Returns early, with `milestoneClosed: true`, when the milestone is already
769
+ * terminal — including when no verdict was recorded, which on a closed milestone
770
+ * will never change. Check `milestoneClosed` before `decision`: a `"YES"` on a
771
+ * closed, `EXPIRED` milestone is the panel's opinion of the proof and moved no funds.
772
+ *
773
+ * Also returns early, with `approvedWithoutThisVerdict: true`, when the milestone is
774
+ * VERIFIED or COMPLETED without a YES from this proof (another proof won it, or the
775
+ * funder approved it in review): `decision` is then `"NO"` or `null`, and the
776
+ * milestone's outcome is NOT this proof's.
777
+ *
778
+ * A proof parked for a human (no verdict, milestone at `PENDING_REVIEW`) returns
779
+ * `{ pending: true, proofId, awaitingHumanReview: true }` on the first poll instead of
780
+ * after the full timeout: no poll will change it, the funder decides.
781
+ *
782
+ * Neither early return is taken while the server reports `running: true` on a
783
+ * VERIFIED or PENDING_REVIEW milestone: this proof's panel is still running and can
784
+ * still decide the row, so the loop keeps polling. A server that predates `running`
785
+ * cannot say so, and there the early returns stay as described.
254
786
  */
255
787
  async watchVerification(input) {
256
788
  const { proofId } = await this.submitProof(input);
257
- const MAX_WAIT_MS = 100_000;
258
- const POLL_MS = 5_000;
789
+ return this.waitForProof(proofId);
790
+ }
791
+ /**
792
+ * Wait for the verification of a proof that is already submitted — the call to
793
+ * make after `submitProof`, and after `verify()` threw a 409 VERIFICATION_IN_PROGRESS
794
+ * with `phase: "running"` (T01-7). Same results as `watchVerification`, which is
795
+ * this plus the upload.
796
+ *
797
+ * Stops early, by throwing, when waiting cannot help: 401 / 403 (the key or the
798
+ * account does not change by waiting) and 404 (permanent — upload answers only
799
+ * after the proof exists and proof-status reads the primary database, so a 404
800
+ * means deleted or erased). Returns `{ pending: true, interrupted: true }` when the
801
+ * run that held the proof died (`claimPhase: "orphaned"`). On 429 / 503 it waits at
802
+ * least the server's Retry-After, never past `timeoutMs`; network errors and other
803
+ * 5xx are transient and it polls again.
804
+ */
805
+ async waitForProof(proofId, options = {}) {
806
+ const MAX_WAIT_MS = options.timeoutMs ?? 100_000;
807
+ const POLL_MS = options.intervalMs ?? 5_000;
259
808
  const started = Date.now();
809
+ let waitMs = POLL_MS;
260
810
  while (Date.now() - started < MAX_WAIT_MS) {
261
- await new Promise((r) => setTimeout(r, POLL_MS));
262
- const status = await this.getProofStatus(proofId).catch(() => null);
263
- if (status && !status.pending) {
811
+ await new Promise((r) => setTimeout(r, waitMs));
812
+ waitMs = POLL_MS;
813
+ let status = null;
814
+ try {
815
+ status = await this.getProofStatus(proofId);
816
+ }
817
+ catch (e) {
818
+ const err = e;
819
+ if (err.status === 401 || err.status === 403 || err.status === 404)
820
+ throw e;
821
+ if ((err.status === 429 || err.status === 503) && typeof err.retryAfterSeconds === "number") {
822
+ waitMs = Math.min(Math.max(POLL_MS, err.retryAfterSeconds * 1000), Math.max(0, MAX_WAIT_MS - (Date.now() - started)));
823
+ }
824
+ continue;
825
+ }
826
+ // A closed milestone ends the wait whether or not a verdict landed: an outage
827
+ // that lost to EXPIRED leaves `pending: true` for good, and the loop used to burn
828
+ // its full timeout on it. `milestoneStatus` is the fallback for a server that
829
+ // predates `milestoneClosed`.
830
+ const closed = status ? closedMilestoneStatus(status) : null;
831
+ // Approved or paid without a YES from this proof: nothing is left for it to
832
+ // decide, whether or not it has a verdict of its own.
833
+ const elsewhere = status ? approvedWithoutThisVerdict(status) : false;
834
+ // No verdict can complete this milestone: final, whatever `pending` says.
835
+ const notCompletable = status?.notCompletableWithoutEscrow === true && closed === null;
836
+ if (status && (!status.pending || closed || elsewhere || notCompletable)) {
264
837
  return {
265
838
  pending: false,
266
- decision: (status.decision ?? "NO"),
839
+ // `?? "NO"` before: an ABSENT decision was reported as a rejection, the one
840
+ // direction that costs the builder their reputation. `null` says "the route
841
+ // reported none", which is the truth and forces the caller to look at
842
+ // `milestoneStatus`.
843
+ decision: status.decision ?? null,
267
844
  confidence: status.confidence ?? null,
845
+ // Both forwarded, because `decision` answers a question about the PROOF and
846
+ // these answer the one about the MILESTONE. Dropping them is how a scheduled,
847
+ // contestable, unpaid approval reached a caller as a completed payout
848
+ // (SEC-058, at the fourth door of three that were fixed).
849
+ milestoneStatus: status.milestoneStatus,
850
+ notCompletableWithoutEscrow: notCompletable,
851
+ milestoneClosed: closed !== null,
852
+ terminalStatus: closed,
853
+ terminalReason: closed ? (status.terminalReason ?? null) : null,
854
+ approvedWithoutThisVerdict: elsewhere,
855
+ // MilestoneEscrowV3: the claim-relevant answer is the SUBMISSION's verdict, not
856
+ // this proof's `decision` (post-review G4) — forwarded, never derived here.
857
+ ...(status.escrowVersion !== undefined ? { escrowVersion: status.escrowVersion } : {}),
858
+ ...(status.escrowVersion === 3
859
+ ? {
860
+ submissionId: status.submissionId ?? null,
861
+ manifestHash: status.manifestHash ?? null,
862
+ submissionVerdict: status.submissionVerdict ?? "PENDING",
863
+ claimReady: status.claimReady === true,
864
+ }
865
+ : {}),
268
866
  reasoning: status.reasoning,
269
867
  };
270
868
  }
869
+ // Parked for a human: `pending` stays true on this path until the funder decides,
870
+ // so waiting out the loop only delayed the same answer by 100 s. Same test as
871
+ // `parkedForHuman` at both MCP surfaces.
872
+ if (status && parkedForHuman(status)) {
873
+ return { pending: true, proofId, awaitingHumanReview: true };
874
+ }
875
+ // The run that held this proof died: nothing is running to wait for. verify()
876
+ // can restart it once its claim is an hour old; submitting again always works.
877
+ if (status && status.claimPhase === "orphaned") {
878
+ return { pending: true, proofId, interrupted: true };
879
+ }
271
880
  }
272
881
  return { pending: true, proofId };
273
882
  }
274
883
  // ─── Agent marketplace ──────────────────────────────────────────────────────
275
- /** Look up any agent's on-chain reputation by EVM wallet address. No auth required. */
884
+ /**
885
+ * Look up any agent's on-chain reputation by EVM wallet address. No auth required.
886
+ *
887
+ * `summary` reaches you RAW, and so do `name` and `skills` from `discoverAgents`
888
+ * below. The route deliberately does not mark them (the browser profile page renders
889
+ * the same fields) and both MCP servers do mark them — so this SDK is a machine
890
+ * boundary that does not, exactly like `verify()`.
891
+ *
892
+ * The DECISION is the same and so is its reason: a library cannot know whether its
893
+ * return value ends in a model's context or on a page, where a marker is noise.
894
+ * What was missing here was the REASON, stated at `verify()` and nowhere
895
+ * else, which made this door look like an oversight rather than a choice (SEC-120).
896
+ * If you feed these fields to a model, treat them as data and not as instructions.
897
+ */
276
898
  async getReputation(walletAddress) {
277
899
  return this.request("GET", `/api/agent/reputation/${encodeURIComponent(walletAddress)}`);
278
900
  }