callx402 1.0.1 → 1.0.2
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/.github/npm-publish.yml.staged +27 -0
- package/.github/workflows/npm-publish.yml +29 -0
- package/README.md +161 -10
- package/bin/callx402.js +296 -13
- package/core/index.js +35 -6
- package/core/rail.js +233 -0
- package/core/veyline.js +240 -0
- package/docs/README.md +11 -9
- package/docs/action-reference.md +2 -2
- package/docs/cli-help-verified-2026-10-06.txt +36 -0
- package/docs/problem-map.md +30 -15
- package/docs/troubleshooting/cx402-duppay-001-duplicate-payment-risk.md +135 -0
- package/docs/troubleshooting/cx402-paidnores-001-paid-no-result.md +3 -1
- package/docs/troubleshooting/cx402-safretry-001-retry-after-payment.md +13 -1
- package/docs/what-is-callx402.md +5 -4
- package/logo-400.png +0 -0
- package/mcp/README.md +5 -0
- package/mcp/index.js +1 -1
- package/package.json +3 -2
- package/server.json +2 -2
- package/test/cli-dispatch.test.js +38 -19
- package/test/helpers.js +20 -0
- package/test/integrity.test.js +2 -1
- package/test/intent-pipeline.test.js +2 -3
- package/test/remote.test.js +2 -3
- package/test/safety.test.js +4 -5
- package/test/sdk.test.js +3 -4
- package/test/server.test.js +4 -5
- package/test/subsystems.test.js +7 -8
- package/test/veyline-commands.test.js +193 -0
package/core/rail.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* core/rail.js — lightweight client for the hosted Payload Rail.
|
|
5
|
+
*
|
|
6
|
+
* When the v2.0.0 runtime tree is not available locally (or unless
|
|
7
|
+
* CALLX402_LOCAL=1), subsystem commands route here automatically:
|
|
8
|
+
*
|
|
9
|
+
* invoke -> free quote -> user approves -> payment/authorization -> result
|
|
10
|
+
*
|
|
11
|
+
* Payment paths (no wallet required in the CLI):
|
|
12
|
+
* - x402: user pays USDC on Base from their own wallet, pastes the tx hash.
|
|
13
|
+
* - card: CLI opens the Stripe checkout in a browser; user pastes the
|
|
14
|
+
* single-use credit ID from the success page.
|
|
15
|
+
*
|
|
16
|
+
* The rail verifies payment exactly once, executes read-only diagnostic
|
|
17
|
+
* actions server-side, meters the invocation, and returns the result.
|
|
18
|
+
* Nothing here signs, holds keys, or moves money.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const RAIL_BASE = process.env.CALLX402_RAIL || 'https://payload-rail.fly.dev';
|
|
22
|
+
const RAIL_ACTIONS = [
|
|
23
|
+
'diagnose', 'doctor', 'rescue', 'route', 'resolve', 'execute', 'monitor',
|
|
24
|
+
'preflight', 'inspect', 'evidence', 'explain', 'recover',
|
|
25
|
+
'settlement_interpretation', 'safe_retry', 'failure_classification',
|
|
26
|
+
'duplicate_payment_risk',
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// Map CLI command names to rail action names.
|
|
30
|
+
const ACTION_ALIASES = { doctor: 'diagnose' };
|
|
31
|
+
|
|
32
|
+
function railActionFor(command) {
|
|
33
|
+
return ACTION_ALIASES[command] || command;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isRailCommand(command) {
|
|
37
|
+
return RAIL_ACTIONS.includes(command);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** True when the caller explicitly prefers the local v2 tree. */
|
|
41
|
+
function preferLocal() {
|
|
42
|
+
return process.env.CALLX402_LOCAL === '1';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function railFetch(path, opts = {}) {
|
|
46
|
+
const res = await fetch(`${RAIL_BASE}${path}`, {
|
|
47
|
+
...opts,
|
|
48
|
+
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
|
|
49
|
+
});
|
|
50
|
+
const text = await res.text();
|
|
51
|
+
let body = null;
|
|
52
|
+
try { body = text ? JSON.parse(text) : null; } catch { body = { _raw: text }; }
|
|
53
|
+
return { status: res.status, body };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function getQuote(action) {
|
|
57
|
+
const { status, body } = await railFetch(
|
|
58
|
+
`/v1/callx402/quote?action=${encodeURIComponent(action)}&path=x402`,
|
|
59
|
+
);
|
|
60
|
+
if (status !== 200 || !body || !body.quoted_price_usd) {
|
|
61
|
+
throw new Error(`quote unavailable (HTTP ${status})`);
|
|
62
|
+
}
|
|
63
|
+
return body;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function prompt(question) {
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
const rl = require('node:readline').createInterface({
|
|
69
|
+
input: process.stdin,
|
|
70
|
+
output: process.stdout,
|
|
71
|
+
});
|
|
72
|
+
rl.question(question, (answer) => {
|
|
73
|
+
rl.close();
|
|
74
|
+
resolve(answer.trim());
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function openBrowser(url) {
|
|
80
|
+
const { execSync } = require('node:child_process');
|
|
81
|
+
const platform = process.platform;
|
|
82
|
+
try {
|
|
83
|
+
if (platform === 'darwin') execSync(`open "${url}"`, { stdio: 'ignore' });
|
|
84
|
+
else if (platform === 'win32') execSync(`start "" "${url}"`, { stdio: 'ignore' });
|
|
85
|
+
else execSync(`xdg-open "${url}"`, { stdio: 'ignore' });
|
|
86
|
+
return true;
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function buildEvidence(action, args) {
|
|
93
|
+
// Prefer explicit --evidence JSON (or @file); otherwise assemble from args.
|
|
94
|
+
if (args.evidence) {
|
|
95
|
+
const raw = String(args.evidence);
|
|
96
|
+
if (raw.startsWith('@')) {
|
|
97
|
+
const fs = require('node:fs');
|
|
98
|
+
const path = require('node:path');
|
|
99
|
+
return fs.readFileSync(path.resolve(raw.slice(1)), 'utf8');
|
|
100
|
+
}
|
|
101
|
+
return raw;
|
|
102
|
+
}
|
|
103
|
+
const ev = {};
|
|
104
|
+
if (args.target) ev.target = args.target;
|
|
105
|
+
if (args.incident) ev.incident = args.incident;
|
|
106
|
+
if (args.goal) ev.goal = args.goal;
|
|
107
|
+
if (args.intent) ev.intent = args.intent;
|
|
108
|
+
if (args.query) ev.query = args.query;
|
|
109
|
+
if (args.operationId) ev.operationId = args.operationId;
|
|
110
|
+
if (args.identity) ev.identity = args.identity;
|
|
111
|
+
return ev;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Invoke a paid action via the hosted rail.
|
|
116
|
+
* Returns { ok, result } for display by the caller.
|
|
117
|
+
*/
|
|
118
|
+
async function invokeRail(command, args, opts = {}) {
|
|
119
|
+
const action = railActionFor(command);
|
|
120
|
+
const evidence = buildEvidence(action, args);
|
|
121
|
+
const interactive = process.stdin.isTTY && !opts.yes;
|
|
122
|
+
|
|
123
|
+
// 1. Free quote first.
|
|
124
|
+
let quote;
|
|
125
|
+
try {
|
|
126
|
+
quote = await getQuote(action);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
return { ok: false, error: `Could not reach the hosted rail for a free quote: ${err.message}` };
|
|
129
|
+
}
|
|
130
|
+
const price = quote.quoted_price_usd;
|
|
131
|
+
const quoteId = quote.quote_id;
|
|
132
|
+
|
|
133
|
+
if (!opts.json) {
|
|
134
|
+
console.log(`callx402 (hosted) — action '${action}'`);
|
|
135
|
+
console.log(` Free quote: $${price} (quote ${String(quoteId).slice(0, 8)}…)`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 2. User approves the spend.
|
|
139
|
+
if (!opts.yes) {
|
|
140
|
+
if (!interactive) {
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
error: `Payment of $${price} required. Re-run with --yes to approve non-interactively, or run interactively.`,
|
|
144
|
+
quote: { action, price_usd: price, quote_id: quoteId },
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const approve = await prompt(` Approve $${price} for '${action}'? [y/N] `);
|
|
148
|
+
if (!/^y(es)?$/i.test(approve)) {
|
|
149
|
+
return { ok: false, error: 'Cancelled by user before payment.' };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 3. Payment: x402 (paste tx hash) or card (browser checkout -> credit ID).
|
|
154
|
+
let payBody = { evidence, quote_id: quoteId, quote };
|
|
155
|
+
if (!interactive) {
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
error: 'Interactive payment required.',
|
|
159
|
+
quote: { action, price_usd: price, quote_id: quoteId },
|
|
160
|
+
payment_instructions: `POST ${RAIL_BASE}/v1/callx402/actions/${action} with { evidence, quote_id, quote } to receive a 402, pay, then re-POST with { txHash } or { credit_id }.`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const method = await prompt(' Pay with [1] USDC on Base (paste tx hash after paying) or [2] card via browser? [1/2] ');
|
|
164
|
+
if (method === '2') {
|
|
165
|
+
// Stripe checkout path.
|
|
166
|
+
const { status, body } = await railFetch('/v1/callx402/checkout', {
|
|
167
|
+
method: 'POST',
|
|
168
|
+
body: JSON.stringify({ action }),
|
|
169
|
+
});
|
|
170
|
+
if (status !== 200 && status !== 201) {
|
|
171
|
+
return { ok: false, error: `Checkout creation failed (HTTP ${status}).` };
|
|
172
|
+
}
|
|
173
|
+
const url = body.checkout_url || body.url;
|
|
174
|
+
if (!url) return { ok: false, error: 'Checkout URL not returned by rail.' };
|
|
175
|
+
console.log(` Opening checkout in your browser…`);
|
|
176
|
+
if (!openBrowser(url)) console.log(` Open this URL manually: ${url}`);
|
|
177
|
+
console.log(' Complete the payment, then paste the single-use credit ID from the success page.');
|
|
178
|
+
const creditId = await prompt(' Credit ID: ');
|
|
179
|
+
if (!creditId) return { ok: false, error: 'No credit ID provided.' };
|
|
180
|
+
payBody = { ...payBody, credit_id: creditId };
|
|
181
|
+
} else {
|
|
182
|
+
// x402 path: get exact payment terms from the 402, then user pays.
|
|
183
|
+
const { status, body } = await railFetch(`/v1/callx402/actions/${action}`, {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
body: JSON.stringify({ evidence, quote_id: quoteId, quote }),
|
|
186
|
+
});
|
|
187
|
+
if (status !== 402) {
|
|
188
|
+
return { ok: false, error: `Expected a 402 payment challenge, got HTTP ${status}.` };
|
|
189
|
+
}
|
|
190
|
+
const accept = (body.accepts && body.accepts[0]) || {};
|
|
191
|
+
const amountUsdc = accept.amount ? (Number(accept.amount) / 1e6).toFixed(2) : price;
|
|
192
|
+
console.log(` Send ${amountUsdc} USDC on Base (eip155:8453) to:`);
|
|
193
|
+
console.log(` ${accept.payTo || '(see 402 response)'}`);
|
|
194
|
+
console.log(' Then paste the transaction hash:');
|
|
195
|
+
const txHash = await prompt(' Tx hash: ');
|
|
196
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(txHash)) {
|
|
197
|
+
return { ok: false, error: 'Invalid transaction hash format.' };
|
|
198
|
+
}
|
|
199
|
+
payBody = { ...payBody, txHash: txHash.toLowerCase() };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 4. Submit payment proof -> verified, executed, metered -> result.
|
|
203
|
+
const { status, body } = await railFetch(`/v1/callx402/actions/${action}`, {
|
|
204
|
+
method: 'POST',
|
|
205
|
+
body: JSON.stringify(payBody),
|
|
206
|
+
});
|
|
207
|
+
if (status === 402) {
|
|
208
|
+
return { ok: false, error: 'Payment not yet recognized. The quote may have expired — re-run to get a fresh quote.' };
|
|
209
|
+
}
|
|
210
|
+
if (status === 409) {
|
|
211
|
+
return { ok: false, error: `Already used: ${body?.error?.message || 'this payment was already consumed'}.` };
|
|
212
|
+
}
|
|
213
|
+
if (status !== 200 || !body || !body.ok) {
|
|
214
|
+
return { ok: false, error: `Rail error (HTTP ${status}): ${body?.error?.message || 'unknown'}` };
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
ok: true,
|
|
218
|
+
action,
|
|
219
|
+
invocation_id: body.invocation_id,
|
|
220
|
+
via: body.via,
|
|
221
|
+
execution: body.execution || null,
|
|
222
|
+
notice: body.notice,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = {
|
|
227
|
+
RAIL_BASE,
|
|
228
|
+
isRailCommand,
|
|
229
|
+
preferLocal,
|
|
230
|
+
railActionFor,
|
|
231
|
+
invokeRail,
|
|
232
|
+
getQuote,
|
|
233
|
+
};
|
package/core/veyline.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* core/veyline.js — dispatch handlers for the Veyline Recovery track:
|
|
5
|
+
* callx402 evidence <operationId> [--dir <path>] [--evidence <json|@file>]
|
|
6
|
+
* callx402 explain <operationId> [--dir <path>] [--evidence <json|@file>]
|
|
7
|
+
* callx402 recover <operationId> [--dir <path>] [--evidence <json|@file>]
|
|
8
|
+
* [--identity <id>]
|
|
9
|
+
*
|
|
10
|
+
* ADDITIVE-ONLY: this file is new; no existing command is touched. It
|
|
11
|
+
* resolves the Veyline recovery modules from the recovery dir
|
|
12
|
+
* (CALLX402_VEYLINE_ROOT env, config veylineRoot, or the default relative
|
|
13
|
+
* path) — it does NOT go through core/subsystems.js, whose hard rule
|
|
14
|
+
* limits requires to the v2.0.0 tree.
|
|
15
|
+
*
|
|
16
|
+
* All three commands are read-only. `recover` reports the recovery
|
|
17
|
+
* decision the network would make (RECOVERABLE / SAFE_RETRY /
|
|
18
|
+
* EXECUTED_BUT_UNRECOVERABLE / SETTLEMENT_UNKNOWN); it never charges,
|
|
19
|
+
* never executes, never repays. Performing the approved safe action stays
|
|
20
|
+
* with the operator's own adapters.
|
|
21
|
+
*
|
|
22
|
+
* Gate: PAYLOAD_VEYLINE_LEDGER=1 for evidence/explain,
|
|
23
|
+
* PAYLOAD_VEYLINE_RECOVERY=1 for recover. Default OFF.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const { EXIT, ok, fail } = require('./envelope');
|
|
28
|
+
|
|
29
|
+
function veylineRoot(config = {}) {
|
|
30
|
+
return config.veylineRoot
|
|
31
|
+
|| process.env.CALLX402_VEYLINE_ROOT
|
|
32
|
+
|| path.resolve(__dirname, '..', '..', 'veyline', 'engineering', 'recovery');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function loadVeyline(config, deps) {
|
|
36
|
+
if (deps && deps.veyline) {
|
|
37
|
+
return { modules: deps.veyline, flag: '(test override)', overridden: true };
|
|
38
|
+
}
|
|
39
|
+
const root = veylineRoot(config);
|
|
40
|
+
try {
|
|
41
|
+
const modules = {
|
|
42
|
+
ledger: require(path.join(root, 'operation-ledger.js')),
|
|
43
|
+
recovery: require(path.join(root, 'recovery-state.js')),
|
|
44
|
+
planner: require(path.join(root, 'rescue-planner.js')),
|
|
45
|
+
trust: require(path.join(root, 'trust-graph.js')),
|
|
46
|
+
intel: require(path.join(root, 'incident-intel.js')),
|
|
47
|
+
continuity: require(path.join(root, 'continuity.js')),
|
|
48
|
+
root,
|
|
49
|
+
};
|
|
50
|
+
return { modules, flag: null, overridden: false };
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return { modules: null, error: err.message };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readJsonInput(input) {
|
|
57
|
+
if (input === undefined || input === null || input === '') return {};
|
|
58
|
+
if (typeof input !== 'string') return input;
|
|
59
|
+
const s = input.trim();
|
|
60
|
+
if (s.startsWith('@')) {
|
|
61
|
+
const fs = require('fs');
|
|
62
|
+
return JSON.parse(fs.readFileSync(path.resolve(s.slice(1)), 'utf8'));
|
|
63
|
+
}
|
|
64
|
+
return JSON.parse(s);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function disabledEnvelope(action, flag) {
|
|
68
|
+
return {
|
|
69
|
+
result: fail(action, {
|
|
70
|
+
subsystem: 'veyline', subsystemStatus: 'disabled',
|
|
71
|
+
code: 'subsystem_disabled',
|
|
72
|
+
message: `veyline recovery subsystem is disabled. Set ${flag}=1 to enable it. Nothing was executed.`,
|
|
73
|
+
disposition: 'veyline disabled: nothing executed.',
|
|
74
|
+
data: { flag },
|
|
75
|
+
}),
|
|
76
|
+
exitCode: EXIT.UNAVAILABLE,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function gate(modules, action, flag) {
|
|
81
|
+
if (!modules) {
|
|
82
|
+
return {
|
|
83
|
+
result: fail(action, { subsystem: 'veyline', code: 'subsystem_unreachable', message: 'veyline recovery modules could not be loaded. Nothing was executed.' }),
|
|
84
|
+
exitCode: EXIT.UNAVAILABLE,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const mod = action === 'recover' ? modules.recovery : modules.ledger;
|
|
88
|
+
const on = typeof mod.enabled === 'function' ? mod.enabled() : false;
|
|
89
|
+
if (!on) return disabledEnvelope(action, flag);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function ledgerFor(modules, dir) {
|
|
94
|
+
const d = dir || path.join(process.cwd(), '.veyline');
|
|
95
|
+
return modules.ledger.createOperationLedger({ dir: d });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function actEvidence(args, ctx) {
|
|
99
|
+
const { modules, error } = loadVeyline(ctx.config, ctx.deps);
|
|
100
|
+
if (error || !modules) {
|
|
101
|
+
return { result: fail('evidence', { subsystem: 'veyline', code: 'subsystem_unreachable', message: `veyline modules unreachable: ${error || 'unknown'}` }), exitCode: EXIT.UNAVAILABLE };
|
|
102
|
+
}
|
|
103
|
+
const g = gate(modules, 'evidence', modules.ledger.FLAG);
|
|
104
|
+
if (g) return g;
|
|
105
|
+
try {
|
|
106
|
+
const operationId = args.operationId;
|
|
107
|
+
if (!operationId) {
|
|
108
|
+
return { result: fail('evidence', { subsystem: 'veyline', code: 'usage', message: 'operationId is required: callx402 evidence <operationId>' }), exitCode: EXIT.USAGE };
|
|
109
|
+
}
|
|
110
|
+
const ledger = ledgerFor(modules, args.dir);
|
|
111
|
+
const events = ledger.events(operationId);
|
|
112
|
+
const states = ledger.latestStates(operationId);
|
|
113
|
+
const protocols = ledger.protocols(operationId);
|
|
114
|
+
const data = {
|
|
115
|
+
operationId, events, states, protocols,
|
|
116
|
+
eventCount: events.length,
|
|
117
|
+
basis: events.length > 0 ? 'recorded ledger evidence' : 'no_basis: no events recorded for this operation',
|
|
118
|
+
};
|
|
119
|
+
return {
|
|
120
|
+
result: ok('evidence', {
|
|
121
|
+
subsystem: 'veyline',
|
|
122
|
+
disposition: events.length > 0
|
|
123
|
+
? `Evidence for ${operationId}: ${events.length} event(s) across ${protocols.join(', ') || 'no protocols'}.`
|
|
124
|
+
: `No evidence recorded for ${operationId}.`,
|
|
125
|
+
data,
|
|
126
|
+
}),
|
|
127
|
+
exitCode: EXIT.OK,
|
|
128
|
+
};
|
|
129
|
+
} catch (err) {
|
|
130
|
+
const isMalformed = err instanceof SyntaxError;
|
|
131
|
+
return { result: fail('evidence', { subsystem: 'veyline', code: isMalformed ? 'usage' : 'failed', message: err.message }), exitCode: isMalformed ? EXIT.USAGE : EXIT.FAIL };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function actExplain(args, ctx) {
|
|
136
|
+
const { modules, error } = loadVeyline(ctx.config, ctx.deps);
|
|
137
|
+
if (error || !modules) {
|
|
138
|
+
return { result: fail('explain', { subsystem: 'veyline', code: 'subsystem_unreachable', message: `veyline modules unreachable: ${error || 'unknown'}` }), exitCode: EXIT.UNAVAILABLE };
|
|
139
|
+
}
|
|
140
|
+
const g = gate(modules, 'explain', modules.ledger.FLAG);
|
|
141
|
+
if (g) return g;
|
|
142
|
+
try {
|
|
143
|
+
const operationId = args.operationId;
|
|
144
|
+
if (!operationId) {
|
|
145
|
+
return { result: fail('explain', { subsystem: 'veyline', code: 'usage', message: 'operationId is required: callx402 explain <operationId>' }), exitCode: EXIT.USAGE };
|
|
146
|
+
}
|
|
147
|
+
const ledger = ledgerFor(modules, args.dir);
|
|
148
|
+
const events = ledger.events(operationId);
|
|
149
|
+
const states = ledger.latestStates(operationId);
|
|
150
|
+
let assessment;
|
|
151
|
+
if (events.length === 0) {
|
|
152
|
+
assessment = 'NO_BASIS: nothing recorded; no claim can be made about this operation.';
|
|
153
|
+
} else if (states.payment === 'UNKNOWN' || states.execution === 'UNKNOWN') {
|
|
154
|
+
assessment = 'INCOMPLETE: a plane is UNKNOWN — do not retry, do not repay; gather fresh evidence or escalate to human review.';
|
|
155
|
+
} else if (states.payment === 'SETTLED' && states.execution === 'SUCCEEDED' && (states.delivery === 'DELIVERED' || states.delivery === 'ACKED')) {
|
|
156
|
+
assessment = 'KNOWN_SAFE: paid, executed, delivered.';
|
|
157
|
+
} else if (states.delivery === 'LOST') {
|
|
158
|
+
assessment = 'RECOVERY_CANDIDATE: delivery lost — run `callx402 recover <operationId>` to see the safe recovery decision.';
|
|
159
|
+
} else {
|
|
160
|
+
assessment = 'PARTIAL: some planes resolved, some not — see states; safe action depends on the unresolved plane.';
|
|
161
|
+
}
|
|
162
|
+
const trail = events.map((e) => `[${e.seq}] ${e.at} ${e.kind}${e.protocol ? `/${e.protocol}` : ''}: ${e.summary || ''}`.trim());
|
|
163
|
+
return {
|
|
164
|
+
result: ok('explain', {
|
|
165
|
+
subsystem: 'veyline',
|
|
166
|
+
disposition: `Explanation for ${operationId}: ${assessment}`,
|
|
167
|
+
data: { operationId, states, assessment, trail, eventCount: events.length },
|
|
168
|
+
}),
|
|
169
|
+
exitCode: EXIT.OK,
|
|
170
|
+
};
|
|
171
|
+
} catch (err) {
|
|
172
|
+
const isMalformed = err instanceof SyntaxError;
|
|
173
|
+
return { result: fail('explain', { subsystem: 'veyline', code: isMalformed ? 'usage' : 'failed', message: err.message }), exitCode: isMalformed ? EXIT.USAGE : EXIT.FAIL };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function actRecover(args, ctx) {
|
|
178
|
+
const { modules, error } = loadVeyline(ctx.config, ctx.deps);
|
|
179
|
+
if (error || !modules) {
|
|
180
|
+
return { result: fail('recover', { subsystem: 'veyline', code: 'subsystem_unreachable', message: `veyline modules unreachable: ${error || 'unknown'}` }), exitCode: EXIT.UNAVAILABLE };
|
|
181
|
+
}
|
|
182
|
+
const g = gate(modules, 'recover', modules.recovery.FLAG);
|
|
183
|
+
if (g) return g;
|
|
184
|
+
try {
|
|
185
|
+
const operationId = args.operationId;
|
|
186
|
+
if (!operationId) {
|
|
187
|
+
return { result: fail('recover', { subsystem: 'veyline', code: 'usage', message: 'operationId is required: callx402 recover <operationId>' }), exitCode: EXIT.USAGE };
|
|
188
|
+
}
|
|
189
|
+
const evidence = readJsonInput(args.evidence);
|
|
190
|
+
const dir = args.dir || path.join(process.cwd(), '.veyline');
|
|
191
|
+
const ledger = ledgerFor(modules, args.dir);
|
|
192
|
+
// File-backed cache shared via --dir: the CLI reads the same recovery
|
|
193
|
+
// substrate a server wrote. Without --dir the cache is in-memory.
|
|
194
|
+
const cache = args.dir && typeof modules.recovery.createFileBackedCache === 'function'
|
|
195
|
+
? modules.recovery.createFileBackedCache({ file: path.join(dir, 'recovery-cache.jsonl') })
|
|
196
|
+
: undefined;
|
|
197
|
+
const network = modules.recovery.createRecoveryNetwork({ ledger, cache });
|
|
198
|
+
let identity = args.identity || evidence.identity || null;
|
|
199
|
+
if (!identity && evidence.toolName && evidence.payer && evidence.paymentAuthFingerprint) {
|
|
200
|
+
identity = network.buildOperationIdentity({
|
|
201
|
+
toolName: evidence.toolName,
|
|
202
|
+
args: evidence.args,
|
|
203
|
+
payer: evidence.payer,
|
|
204
|
+
paymentAuthFingerprint: evidence.paymentAuthFingerprint,
|
|
205
|
+
traceId: evidence.traceId,
|
|
206
|
+
operationId,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
if (!identity) {
|
|
210
|
+
return {
|
|
211
|
+
result: fail('recover', {
|
|
212
|
+
subsystem: 'veyline', code: 'usage',
|
|
213
|
+
message: 'an operation identity is required: pass --identity <id> or --evidence with toolName/payer/paymentAuthFingerprint (+args/traceId).',
|
|
214
|
+
}),
|
|
215
|
+
exitCode: EXIT.USAGE,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
const decision = network.onLostResult({
|
|
219
|
+
operationId,
|
|
220
|
+
identity,
|
|
221
|
+
credentials: { payer: evidence.payer, paymentAuthFingerprint: evidence.paymentAuthFingerprint },
|
|
222
|
+
evidence,
|
|
223
|
+
});
|
|
224
|
+
const terminal = decision.recoveryState === 'RECOVERABLE' || decision.recoveryState === 'SAFE_RETRY'
|
|
225
|
+
? 'KNOWN_SAFE' : 'HUMAN_REVIEW';
|
|
226
|
+
return {
|
|
227
|
+
result: ok('recover', {
|
|
228
|
+
subsystem: 'veyline',
|
|
229
|
+
disposition: `Recovery decision for ${operationId}: ${decision.recoveryState} -> ${terminal}. Read-only: nothing charged, nothing executed.`,
|
|
230
|
+
data: { operationId, decision, terminal },
|
|
231
|
+
}),
|
|
232
|
+
exitCode: EXIT.OK,
|
|
233
|
+
};
|
|
234
|
+
} catch (err) {
|
|
235
|
+
const isMalformed = err instanceof SyntaxError;
|
|
236
|
+
return { result: fail('recover', { subsystem: 'veyline', code: isMalformed ? 'usage' : 'failed', message: err.message }), exitCode: isMalformed ? EXIT.USAGE : EXIT.FAIL };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
module.exports = { actEvidence, actExplain, actRecover, veylineRoot, loadVeyline };
|
package/docs/README.md
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
action and entry-point layer for x402 infrastructure: you state an intent in
|
|
5
5
|
plain language (or call an HTTP endpoint), and callx402 dispatches into the
|
|
6
6
|
existing x402 subsystems that do the real work. It dispatches only. It never
|
|
7
|
-
moves money by itself. The flagship product
|
|
8
|
-
|
|
7
|
+
moves money by itself. The flagship product is Veyline; callx402 is how you
|
|
8
|
+
access it. **Positioning: callx402 is the action layer of Veyline.**
|
|
9
9
|
|
|
10
10
|
Status of this docs set: landing index is live. Pages and assets marked
|
|
11
11
|
"planned" below are not published yet. Nothing here is aspirational: every
|
|
@@ -46,18 +46,20 @@ Check subsystem reachability and feature flags:
|
|
|
46
46
|
callx402 status
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
Run an intent in plain language:
|
|
49
|
+
Run an intent in plain language (single quotes: in double quotes your shell
|
|
50
|
+
would expand `$1` to nothing):
|
|
50
51
|
|
|
51
52
|
```sh
|
|
52
|
-
callx402
|
|
53
|
+
callx402 'complete this job for under $1'
|
|
53
54
|
```
|
|
54
55
|
|
|
55
|
-
Diagnose, rescue, route, resolve
|
|
56
|
+
Diagnose, rescue, route, resolve (need the v2.0.0 tree plus each
|
|
57
|
+
subsystem's flag; see the main README, "Full subsystem commands"):
|
|
56
58
|
|
|
57
59
|
```sh
|
|
58
|
-
callx402 diagnose --target
|
|
60
|
+
callx402 diagnose --target 'https://api.example.com/x402/pay'
|
|
59
61
|
callx402 rescue --incident inc_123
|
|
60
|
-
callx402 route --goal
|
|
62
|
+
callx402 route --goal 'fetch 100 product prices for under 0.50 USD'
|
|
61
63
|
callx402 resolve --evidence '{"txHash":"0xabc..."}'
|
|
62
64
|
```
|
|
63
65
|
|
|
@@ -123,7 +125,7 @@ Planned. No recipes published yet.
|
|
|
123
125
|
The subsystem tree callx402 dispatches into: Sentinel, Rescue, Doctor,
|
|
124
126
|
Router, Valuator, SpendGuard, Settlement Resolver, Intent Engine, Capability
|
|
125
127
|
Graph, Economic Planner, MCP fabric, and RevRule. Every dispatch goes
|
|
126
|
-
through the real subsystem in the
|
|
128
|
+
through the real subsystem in the Veyline Developer Primer v2.0.0 tree (x402-paid-api-starter-kit),
|
|
127
129
|
which callx402 imports but never modifies. If a subsystem is disabled or
|
|
128
130
|
unreachable, callx402 reports that plainly and fails closed. It never fakes
|
|
129
131
|
success.
|
|
@@ -288,7 +290,7 @@ guards real money movement is ever force-enabled by callx402.
|
|
|
288
290
|
|
|
289
291
|
### Subsystem docs
|
|
290
292
|
|
|
291
|
-
callx402 dispatches into the
|
|
293
|
+
callx402 dispatches into the Veyline Developer Primer v2.0.0 tree (x402-paid-api-starter-kit). Its docs
|
|
292
294
|
are the reference for subsystem behavior:
|
|
293
295
|
|
|
294
296
|
- `../x402-paid-api-starter-kit/v2.0.0/README.md` — kit overview
|
package/docs/action-reference.md
CHANGED
|
@@ -67,7 +67,7 @@ Shared notes for all actions:
|
|
|
67
67
|
- **CAN IT MOVE MONEY?** No.
|
|
68
68
|
- **CAN IT CREATE OR RETRY AN AUTHORIZATION?** No.
|
|
69
69
|
- **FAIL-CLOSED CONDITIONS:** Missing `--goal` → exit 2; router disabled or unreachable → exit 3. With no candidates at all, the action is honest ("No candidate tools available... No route selected; nothing executed.") and still exits 0.
|
|
70
|
-
- **EXAMPLE:** `callx402 route --goal
|
|
70
|
+
- **EXAMPLE:** `callx402 route --goal 'fetch 100 product prices for under 0.50 USD' --speed cheap --json`
|
|
71
71
|
- **CURRENT LIMITATIONS:** Without registered tools in the capability graph (experimental; requires `PAYLOAD_MCP_FABRIC=1`) there is nothing to rank, so routing returns an honest no-route result. Route selects; it never executes.
|
|
72
72
|
- **VERIFICATION STATUS:** Tested. `test/cli-dispatch.test.js`: "route: disabled subsystem -> exit 3", "route: enabled, no candidates -> honest no-route, exit 0", "route: missing --goal -> usage error, exit 2", "router dispatch honors the fast speed policy", "router dispatch runs real selectTool/rankTools over candidates".
|
|
73
73
|
|
|
@@ -97,7 +97,7 @@ Shared notes for all actions:
|
|
|
97
97
|
- **CAN IT MOVE MONEY?** It is the only action designed with execution side effects, and it is budget-gated, dry-run capable, and idempotent. In the current build it cannot move money: no live executable route is wired, so execution honestly does not happen. [implementation, tested]
|
|
98
98
|
- **CAN IT CREATE OR RETRY AN AUTHORIZATION?** No in the current build. `--force-retry` after a stored run whose settlement is UNKNOWN is explicitly refused (`unsafe_retry_refused`, exit 5) to prevent double-spend.
|
|
99
99
|
- **FAIL-CLOSED CONDITIONS:** Missing `--intent` → exit 2; over budget → exit 4, zero side effects; above approval threshold without `--approve` → exit 4; retry after UNKNOWN → exit 5; individual stages (intent engine, planner, router, kernel) disabled or unreachable are recorded in `data.stages` and planning continues honestly.
|
|
100
|
-
- **EXAMPLE:** `callx402 execute --intent
|
|
100
|
+
- **EXAMPLE:** `callx402 execute --intent 'complete this job for under $1' --max-budget 1.00 --dry-run --idempotency-key job-42 --json`
|
|
101
101
|
- **CURRENT LIMITATIONS:** No live executable route is wired in this build; `execute` currently plans honestly and stops. Effect proofs, settlement resolution, and receipts are only populated on real execution. Live execution would require a wired route plus an enabled execution kernel.
|
|
102
102
|
- **VERIFICATION STATUS:** Tested. `test/intent-pipeline.test.js` (8 tests, incl. "execute: dry-run intent -> exit 0, honest no-route disposition", "cost above approval threshold without --approve fails closed, exit 4", "explicit --approve passes the threshold gate", "over-budget intent -> budget_refused, exit 4, zero side effects", "duplicate idempotencyKey: second call deduped with ORIGINAL result", "unsafe retry refusal", "intent pipeline walks stages in order", "networks/assets/providers constrain planner, router and spendguard"). `test/safety.test.js` (7 tests) covers the money-safety invariants end to end. Real CLI run verified 2026-10-06: intent-mode dispatch returns the honest "no executable route available" disposition with per-stage detail.
|
|
103
103
|
|
|
@@ -37,3 +37,39 @@ Options:
|
|
|
37
37
|
|
|
38
38
|
Exit codes: 0 ok · 1 failure · 2 usage · 3 disabled/unreachable ·
|
|
39
39
|
4 budget refused · 5 settlement unknown (never auto-retry, never repay).
|
|
40
|
+
|
|
41
|
+
================================================================================
|
|
42
|
+
ADDENDUM 2026-10-07 — per-command help and rail-only routing hint
|
|
43
|
+
================================================================================
|
|
44
|
+
Change: `callx402 <command> --help` now prints that command's own usage
|
|
45
|
+
instead of the global help (bin/callx402.js COMMAND_HELP). Bare
|
|
46
|
+
`callx402 --help` output is unchanged (verified identical above). Unknown
|
|
47
|
+
commands matching a rail-only paid action (safe_retry, duplicate_payment_risk,
|
|
48
|
+
failure_classification, settlement_interpretation, hyphenated aliases) now get
|
|
49
|
+
a routing hint to docs/problem-map.md instead of a bare usage error.
|
|
50
|
+
|
|
51
|
+
$ callx402 recover --help (exit 0)
|
|
52
|
+
callx402 recover — report the safe recovery decision (read-only).
|
|
53
|
+
|
|
54
|
+
Usage:
|
|
55
|
+
callx402 recover <operationId> [--identity <id> | --evidence <json>]
|
|
56
|
+
|
|
57
|
+
Evaluates recorded history against the operation identity and returns the
|
|
58
|
+
recovery decision: RECOVERABLE, SAFE_RETRY, EXECUTED_BUT_UNRECOVERABLE, or
|
|
59
|
+
SETTLEMENT_UNKNOWN, mapped to KNOWN_SAFE or HUMAN_REVIEW. Never charges,
|
|
60
|
+
never executes, never repays. Requires PAYLOAD_VEYLINE_RECOVERY=1.
|
|
61
|
+
|
|
62
|
+
This is the read-only answer to "is a retry safe?" — a SAFE_RETRY verdict
|
|
63
|
+
here is advisory; performing the safe action stays with your own adapters.
|
|
64
|
+
For the paid one-off verdict on a specific retry plan, use the 'safe_retry'
|
|
65
|
+
rail action (quote first, then pay; see docs/problem-map.md). The MCP tool
|
|
66
|
+
x402_recover covers the same free diagnostic.
|
|
67
|
+
|
|
68
|
+
Exit codes: 0 ok · 1 failure · 2 usage (missing identity/evidence) ·
|
|
69
|
+
3 disabled/unreachable · 4 budget refused ·
|
|
70
|
+
5 settlement unknown (never auto-retry, never repay).
|
|
71
|
+
|
|
72
|
+
$ callx402 safe_retry (exit 2, stderr)
|
|
73
|
+
callx402: unknown command 'safe_retry' — 'safe_retry' is a paid on-demand rail action, not a CLI command. Get the free quote, then pay deliberately: see docs/problem-map.md
|
|
74
|
+
Valid commands: diagnose, rescue, route, resolve, doctor, execute, monitor, status, preflight, inspect, config, evidence, explain, recover
|
|
75
|
+
Run 'callx402 --help' for usage, or 'callx402 <command> --help' for one command.
|
package/docs/problem-map.md
CHANGED
|
@@ -18,23 +18,38 @@ curl -X POST https://payload-rail.fly.dev/v1/callx402/actions/<action> \
|
|
|
18
18
|
Free first: `GET /v1/callx402/quote?action=<action>&path=x402` returns the
|
|
19
19
|
exact deterministic quote before anything is paid.
|
|
20
20
|
|
|
21
|
+
**Two tiers.** Several actions exist in both a free local form and the paid
|
|
22
|
+
rail form. The free form is read-only and never charges: the CLI command (or
|
|
23
|
+
the matching `x402_*` MCP tool) runs against your own machine (requires the
|
|
24
|
+
v2.0.0 runtime tree and `CALLX402_LOCAL=1`). The paid form runs on the
|
|
25
|
+
production rail: quote first, payment verified exactly once, then the
|
|
26
|
+
diagnostic executes server-side and the result is returned — zero local setup.
|
|
27
|
+
The "Free path" column names the free equivalent; "rail only" means the action
|
|
28
|
+
exists only as a paid rail action.
|
|
29
|
+
|
|
30
|
+
**Fees.** The table lists the Stripe-path fee per action. The x402 path
|
|
31
|
+
quoted by the free quote endpoint is exactly that fee divided by 20
|
|
32
|
+
(e.g. `resolve`: $5.00 Stripe path / $0.25 x402 path; `recover`: $10.00 /
|
|
33
|
+
$0.50). The quote endpoint is the source of truth; the division is
|
|
34
|
+
deterministic, not an estimate.
|
|
35
|
+
|
|
21
36
|
## The map
|
|
22
37
|
|
|
23
|
-
| # | You see this problem | Invoke this action | Fee (Stripe path) | Fail-closed rule |
|
|
24
|
-
|
|
25
|
-
| 1 | Settlement uncertainty — can't tell from current info whether the payment settled | `resolve` | $5.00 | Unknown stays unknown; never auto-retry, never repay |
|
|
26
|
-
| 2 | Lost result after payment — paid, but the result never arrived | `recover` | $10.00 | Read-only: nothing charged, nothing executed; returns the safe recovery decision or the proven prior result, never a blind re-execution |
|
|
27
|
-
| 3 | Retry uncertainty — something failed; need what/why before deciding if retry is safe | `diagnose` | $2.00 | Unknown failure class returns UNKNOWN, never a guess |
|
|
28
|
-
| 4 | Retry uncertainty, narrowed — is THIS specific retry safe to sign? | `safe_retry` | $5.00 | A retry that would double-pay is refused |
|
|
29
|
-
| 5 | Duplicate-payment risk — a retry might sign a second authorization for the same intent | `duplicate_payment_risk` | $5.00 | Ambiguous authorization state blocks the retry |
|
|
30
|
-
| 6 | Need proof — ground truth before acting on any incident | `evidence` | $2.00 | First step in any incident; returns what is recorded, nothing invented |
|
|
31
|
-
| 7 | Need explanation — plain-language state assessment before retry/refund/escalate | `explain` | $1.00 | INCOMPLETE means do not retry, do not repay |
|
|
32
|
-
| 8 | Pre-commit check — intent, payment readiness, risk before an economic operation commits | `preflight` | $3.00 | Blocked stays blocked until the reasons clear |
|
|
33
|
-
| 9 | Failure triage — classify the failure so the right playbook runs | `failure_classification` | $2.00 | Unclassifiable failures escalate to human review |
|
|
34
|
-
| 10 | Settlement-record interpretation — read a raw settlement record, state what it means | `settlement_interpretation` | $3.00 | Ambiguous records are reported ambiguous |
|
|
35
|
-
| 11 | Rescue a failed transaction — triage and recover the incident end to end | `rescue` | $15.00 | Never fabricates success; unreachable subsystems fail closed |
|
|
36
|
-
| 12 | Watch an operation/workload for state changes, failures, anomalies | `monitor` | $5.00 | An unwatched gap is reported as a gap, not as healthy |
|
|
37
|
-
| 13 | Run an economic operation through the protected path (exactly-once) | `execute` | $10.00 | Over-budget intents refused with zero side effects |
|
|
38
|
+
| # | You see this problem | Invoke this action | Fee (Stripe path) | Free path | Fail-closed rule |
|
|
39
|
+
|---|---|---|---|---|---|
|
|
40
|
+
| 1 | Settlement uncertainty — can't tell from current info whether the payment settled | `resolve` | $5.00 | `callx402 resolve --evidence ...` / `x402_resolve` | Unknown stays unknown; never auto-retry, never repay |
|
|
41
|
+
| 2 | Lost result after payment — paid, but the result never arrived | `recover` | $10.00 | `callx402 recover <op> --identity ...` / `x402_recover` | Read-only: nothing charged, nothing executed; returns the safe recovery decision or the proven prior result, never a blind re-execution |
|
|
42
|
+
| 3 | Retry uncertainty — something failed; need what/why before deciding if retry is safe | `diagnose` | $2.00 | `callx402 diagnose --evidence ...` / `x402_diagnose` | Unknown failure class returns UNKNOWN, never a guess |
|
|
43
|
+
| 4 | Retry uncertainty, narrowed — is THIS specific retry safe to sign? | `safe_retry` | $5.00 | rail only (start free with `recover`: a SAFE_RETRY verdict there is the read-only answer) | A retry that would double-pay is refused |
|
|
44
|
+
| 5 | Duplicate-payment risk — a retry might sign a second authorization for the same intent | `duplicate_payment_risk` | $5.00 | rail only (start free: `resolve` the settlement, then `recover` for the retry verdict) | Ambiguous authorization state blocks the retry |
|
|
45
|
+
| 6 | Need proof — ground truth before acting on any incident | `evidence` | $2.00 | `callx402 evidence <op>` / `x402_evidence` | First step in any incident; returns what is recorded, nothing invented |
|
|
46
|
+
| 7 | Need explanation — plain-language state assessment before retry/refund/escalate | `explain` | $1.00 | `callx402 explain <op>` / `x402_explain` | INCOMPLETE means do not retry, do not repay |
|
|
47
|
+
| 8 | Pre-commit check — intent, payment readiness, risk before an economic operation commits | `preflight` | $3.00 | `callx402 preflight` (local checks) | Blocked stays blocked until the reasons clear |
|
|
48
|
+
| 9 | Failure triage — classify the failure so the right playbook runs | `failure_classification` | $2.00 | rail only (start free with `diagnose`) | Unclassifiable failures escalate to human review |
|
|
49
|
+
| 10 | Settlement-record interpretation — read a raw settlement record, state what it means | `settlement_interpretation` | $3.00 | rail only (start free with `resolve --evidence ...`) | Ambiguous records are reported ambiguous |
|
|
50
|
+
| 11 | Rescue a failed transaction — triage and recover the incident end to end | `rescue` | $15.00 | `callx402 rescue --incident <id>` (triage and quote only, never executes) | Never fabricates success; unreachable subsystems fail closed |
|
|
51
|
+
| 12 | Watch an operation/workload for state changes, failures, anomalies | `monitor` | $5.00 | `callx402 monitor --once` (local sentinel snapshot) | An unwatched gap is reported as a gap, not as healthy |
|
|
52
|
+
| 13 | Run an economic operation through the protected path (exactly-once) | `execute` | $10.00 | `callx402 execute --intent ... --dry-run` (plans only) | Over-budget intents refused with zero side effects |
|
|
38
53
|
|
|
39
54
|
## Where each surface consumes this map
|
|
40
55
|
|