callx402 1.0.1 → 1.0.3

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.
@@ -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
 
@@ -0,0 +1,84 @@
1
+ # Secure, on-demand callx402 redemption
2
+
3
+ A publicly visible on-chain transaction hash is **never** sufficient to redeem
4
+ a paid callx402 action. The payer must produce an EIP-191 `personal_sign`
5
+ signature, outside Payload, over the exact action, transaction hash, quote,
6
+ action inputs, wallet, Base network, Payload recipient, nonce and expiration.
7
+
8
+ **Never provide your seed phrase or private key to Payload or the CLI.**
9
+ The CLI does not ask for or store wallet secrets.
10
+
11
+ ## Human use
12
+
13
+ ```bash
14
+ callx402 diagnose --evidence '{"error":"settlement timeout"}'
15
+ ```
16
+
17
+ The CLI displays a free **USDC** quote before asking permission to spend.
18
+ For USDC it presents the payment details, then asks for the paying wallet
19
+ address and a signature created by your wallet over the canonical message.
20
+ For Stripe card checkout it explicitly confirms the separate card price.
21
+
22
+ Only actions supported by the hosted service are presented as paid services.
23
+ Some advanced commands require an optional local runtime and must not be
24
+ misrepresented as completed hosted executions.
25
+
26
+ ## Non-interactive agent use
27
+
28
+ An agent must obtain a free quote **before** payment:
29
+
30
+ ```text
31
+ GET https://payload-rail.fly.dev/v1/callx402/quote?action=resolve&path=x402
32
+ ```
33
+
34
+ Preserve both `quote_id` and `quote_inputs` from this exact quote response.
35
+ Quote IDs include issuance time; requesting a fresh quote after signing will
36
+ produce a different ID. Use the original quote before it expires.
37
+
38
+ Use `actionRequestHash(action, body)` from `core/payer-auth.js` for the exact
39
+ input the agent will redeem. For example, if the CLI will send
40
+ `{evidence: "{\\"error\\":\\"settlement timeout\\"}"}`, pass that exact evidence
41
+ **string** to the hash function; do not hash a parsed object instead.
42
+
43
+ The owner’s wallet must sign the canonical
44
+ `buildAuthMessage({wallet, action, txHash, quote_id, request_hash, network, recipient, nonce, expiry})`
45
+ via EIP-191 `personal_sign` with expiry no further than 15 minutes ahead.
46
+ The resulting JSON file should include:
47
+
48
+ ```json
49
+ {
50
+ "wallet": "0x...",
51
+ "action": "resolve",
52
+ "txHash": "0x...",
53
+ "quote_id": "the original quote ID",
54
+ "quote_inputs": {"action":"resolve","path":"x402","...":"other exact canonical inputs"},
55
+ "quoted_price_usd": "0.25",
56
+ "request_hash": "0x...",
57
+ "network": "eip155:8453",
58
+ "recipient": "0x...",
59
+ "nonce": "an unpredictable unique value",
60
+ "expiry": 1790000000,
61
+ "signature": "0x...65-byte EIP-191 signature"
62
+ }
63
+ ```
64
+
65
+ The example is illustrative, not a usable authorization; use the exact returned
66
+ canonical `quote_inputs`. Run:
67
+
68
+ ```bash
69
+ callx402 resolve --evidence @evidence.json --tx-hash 0xYOUR_TX_HASH \
70
+ --payer-auth @signed-auth.json --approve
71
+ ```
72
+
73
+ The CLI verifies that the signed action, transaction, quote and request
74
+ digest match what it is about to submit. The Rail independently re-derives
75
+ the quote, recovers the signer, matches the chain payer, checks recipient
76
+ and network, and atomically enforces one-time use. A rejected request
77
+ does not authorize execution.
78
+
79
+ **Contract wallets:** EIP-1271 is **not yet exposed on this redemption
80
+ path**. The current secured release supports EOA EIP-191 signatures only.
81
+ Do not claim contract-wallet redemption support until integration tests pass.
82
+
83
+ **No live-money tests:** Developers must use mocked verifier / Stripe
84
+ clients or pre-existing read-only evidence for automated tests.
@@ -0,0 +1,135 @@
1
+ ---
2
+ id: CX402-DUPPAY-001
3
+ title: "x402 duplicate-payment risk: one intent, two authorizations"
4
+ category: duplicate-payment-risk
5
+ severity: critical
6
+ economic_risk: "A retry that signs a second authorization for the same intent produces two on-chain transfers for one purchase. No error message, no warning."
7
+ search_phrases: ["x402 duplicate payment", "x402 paid twice", "x402 double payment", "x402 retry double charge", "x402 second authorization same intent"]
8
+ reading_minutes: 4
9
+ sources: ["https://github.com/x402-foundation/x402/issues/452", "https://github.com/x402-foundation/x402/issues/831", "https://github.com/x402-foundation/x402 (v2 spec, section 9)"]
10
+ related: ["CX402-SAFFRETRY-001", "CX402-SETTLEPEND-001", "CX402-PAIDNORES-001"]
11
+ updated: 2026-10-07
12
+ ---
13
+
14
+ # x402 duplicate-payment risk: one intent, two authorizations
15
+
16
+ ## THE PROBLEM
17
+
18
+ You have one intent — pay for one thing — but the payment path may have
19
+ already produced an authorization. A retry that mints a *second*
20
+ authorization for the same intent can settle twice: two on-chain transfers,
21
+ one purchase. The protocol does not stop you; the second signature is a
22
+ fresh, valid instruction to move funds.
23
+
24
+ ## WHY IT HAPPENS
25
+
26
+ Three protocol facts combine:
27
+
28
+ 1. **Authorizations are bearer-like.** An EIP-3009 authorization, once
29
+ signed, instructs the token contract to move funds. Signing a second
30
+ one for the same intent creates a second independent instruction — the
31
+ chain cannot know you meant them as alternatives.
32
+ 2. **Ambiguous states invite the second signature.** `settlement_pending`
33
+ ("broadcast but unconfirmed"), timeouts after payment was sent, and
34
+ `AuthorizationUsed` reverts (which look like genuine failures) all leave
35
+ the operator unsure whether the first authorization settled. The
36
+ instinct is to sign again — exactly the wrong move while the first
37
+ authorization may still be live
38
+ ([x402-foundation/x402#452](https://github.com/x402-foundation/x402/issues/452),
39
+ [x402-foundation/x402#831](https://github.com/x402-foundation/x402/issues/831)).
40
+ 3. **Duplicate submission is undefined.** The x402 spec does not define how
41
+ facilitators handle the same payload twice, so there is no standard
42
+ "already settled" dedupe to save you.
43
+
44
+ ## DO NOT DO THIS
45
+
46
+ Do not sign a second authorization for the same intent while the first
47
+ one's fate is unknown. "Unknown" is not "failed." A second signature is
48
+ never a status check — it is a second payment instruction.
49
+
50
+ Do not treat a retry as safe because the first attempt "probably" failed.
51
+ Probably is not evidence, and the cost of being wrong is a full second
52
+ payment.
53
+
54
+ ## THE SAFE FIX
55
+
56
+ 1. **Prove the first authorization dead before minting a second.** For
57
+ `settlement_pending`, the spec requires the broadcast hash — reconcile
58
+ it on chain. Confirmed means stop. Failed/dropped means (and only means)
59
+ it is safe to sign again.
60
+ 2. **Separate the questions.** "Did the money move?" is answered by
61
+ settlement evidence, not by retrying. "Is this specific retry safe to
62
+ sign?" is answered only after the first question is settled.
63
+ 3. **One nonce per intent, client-side.** Until the ecosystem standardizes
64
+ retry-by-nonce, never mint a second authorization for an intent while
65
+ the first is unresolved. Key retries by the EIP-3009 nonce where your
66
+ client supports it.
67
+
68
+ ## VERIFY
69
+
70
+ Reconcile exactly one on-chain transfer per intent: one transaction hash,
71
+ correct recipient, correct amount, confirmed. If a retry produced a second
72
+ confirmed transfer for the same intent, the retry logic is unsafe — fix it
73
+ before it runs again. Log every retry decision with the evidence that
74
+ justified it.
75
+
76
+ ## Using Callx402 for this condition
77
+
78
+ Start free and read-only. First, settle the money question with `resolve`:
79
+
80
+ ```
81
+ callx402 resolve --evidence '{"txHash":"0x...","network":"base"}'
82
+ ```
83
+
84
+ Then get the read-only retry verdict with `recover`:
85
+
86
+ ```
87
+ callx402 recover <operationId> --identity <id>
88
+ ```
89
+
90
+ A SAFE_RETRY verdict is the free, read-only answer. If the settlement
91
+ behind the operation is UNKNOWN, `recover` refuses outright
92
+ (SETTLEMENT_UNKNOWN -> HUMAN_REVIEW) — no retry, no repay, no bypass.
93
+
94
+ When you need the production rail to judge the duplicate-payment risk of a
95
+ *specific* retry plan, use the paid one-off `duplicate_payment_risk`
96
+ action. Quote first (free), then pay deliberately — never blind-retry a
97
+ payment to reach it:
98
+
99
+ ```sh
100
+ # 1. Free quote: $0.25 on the x402 path
101
+ curl "https://payload-rail.fly.dev/v1/callx402/quote?action=duplicate_payment_risk&path=x402"
102
+
103
+ # 2. Invoke unauthenticated -> HTTP 402 with exact USDC terms (Base, 300s window)
104
+ curl -X POST https://payload-rail.fly.dev/v1/callx402/actions/duplicate_payment_risk \
105
+ -H 'Content-Type: application/json' -d '{"intent_summary":"..."}'
106
+
107
+ # 3. Pay from an authorized wallet, then retry with {txHash, quote_id}
108
+ ```
109
+
110
+ The action's fail-closed rule: an ambiguous authorization state blocks the
111
+ retry rather than blessing it. For the adjacent narrowed question — "is
112
+ THIS specific retry safe to sign?" — the paid `safe_retry` action follows
113
+ the same quote-then-pay shape. Both are rail-only paid actions: there is no
114
+ CLI command or MCP tool for them (typing one at the CLI prints a usage
115
+ error that points to `docs/problem-map.md`). Start with the free
116
+ `resolve`/`recover` sequence above before spending.
117
+
118
+ Tested: the free sequence (`resolve` UNKNOWN -> exit 5, `recover`
119
+ read-only verdicts) is covered by the dispatch and veyline command suites.
120
+ The paid action's quote endpoint and 402 payment terms were verified live
121
+ against the rail on 2026-10-07; the paid verdict itself was not executed
122
+ (no payment was made).
123
+
124
+ Limitation: the free verdicts reason from recorded evidence only. If the
125
+ operation was never recorded, they say so instead of inventing a basis —
126
+ gather the settlement evidence first.
127
+
128
+ ## SOURCES / VERIFICATION SCOPE
129
+
130
+ Protocol claims cite the x402 v2 spec and the issue tracker links in the
131
+ frontmatter, independently of callx402. Callx402 claims above were checked
132
+ against the source tree (`core/veyline.js`, `core/index.js`, `bin/callx402.js`),
133
+ the dispatch and veyline command test suites, and live rail responses
134
+ (quote endpoint, 402 terms, fee schedule) as of 2026-10-07. No payment was
135
+ made and no paid verdict was executed for this article.
@@ -84,7 +84,9 @@ callx402 evidence <operationId>
84
84
 
85
85
  It accepts an operation id (and optionally `--dir` to point at a different ledger store). It evaluates the operation ledger's recorded events: the latest per-plane states (payment, execution, delivery), the protocols involved, and an event trail. It returns those states, the trail, and a plain statement of basis — including "no events recorded for this operation," stated plainly rather than hidden.
86
86
 
87
- The follow-on sequence: `explain <operationId>` classifies the recorded state — NO_BASIS, INCOMPLETE (any UNKNOWN plane means do not retry, do not repay), KNOWN_SAFE, RECOVERY_CANDIDATE, or PARTIAL — and a RECOVERY_CANDIDATE verdict points you to `recover <operationId>`, which returns the read-only safe-recovery verdict described in the companion retry article.
87
+ The follow-on sequence: `explain <operationId>` classifies the recorded state — NO_BASIS, INCOMPLETE (any UNKNOWN plane means do not retry, do not repay), KNOWN_SAFE, RECOVERY_CANDIDATE, or PARTIAL — and a RECOVERY_CANDIDATE verdict points you to `recover <operationId>`, which returns the read-only safe-recovery verdict described in the companion retry article (`cx402-safretry-001-retry-after-payment.md`).
88
+
89
+ When the free read-only verdict is not enough and you need the production rail to recover the incident, the paid one-off `recover` rail action follows the quote-then-pay shape: free quote at `GET /v1/callx402/quote?action=recover&path=x402` ($0.50 on the x402 path), then `POST /v1/callx402/actions/recover` returns HTTP 402 with the exact USDC terms — pay deliberately from an authorized wallet and retry with `{txHash, quote_id}`. See `docs/problem-map.md` for the full problem-to-action map.
88
90
 
89
91
  What the sequence deliberately refuses to do: all three steps are read-only. None of them re-executes the tool, re-settles the payment, or triggers a retry. `recover` states its verdict and stops there.
90
92
 
@@ -97,7 +97,19 @@ It returns the decision with its reasoning and a terminal assessment, ending wit
97
97
 
98
98
  What it deliberately refuses to do: it never charges, never executes, and never retries on your behalf. When the settlement state behind the operation is UNKNOWN, the recovery path refuses outright — an explicit retry after an unknown settlement could double-spend, so the pipeline demands manual settlement resolution first (the `resolve` action in the companion settlement article) and offers no bypass.
99
99
 
100
- Tested: yes. The veyline command suite covers RECOVERABLE and SAFE_RETRY decisions, the identity-from-evidence fallback, the unknown-identity refusal path, and the disabled-subsystem refusal (exit 3).
100
+ For the narrowed question — "is THIS specific retry safe to sign?" — the paid one-off `safe_retry` rail action judges the concrete retry plan. It is rail-only (no CLI command; typing `callx402 safe_retry` prints a usage error pointing to `docs/problem-map.md`). Quote first (free), then pay deliberately:
101
+
102
+ ```sh
103
+ curl "https://payload-rail.fly.dev/v1/callx402/quote?action=safe_retry&path=x402"
104
+ # -> {"quote_id":"...","quoted_price_usd":"0.25", ...}
105
+ curl -X POST https://payload-rail.fly.dev/v1/callx402/actions/safe_retry \
106
+ -H 'Content-Type: application/json' -d '{"retry_plan":"..."}'
107
+ # -> HTTP 402 with exact USDC terms; pay from an authorized wallet, retry with {txHash, quote_id}
108
+ ```
109
+
110
+ Its fail-closed rule: a retry that would double-pay is refused. Start with the free `recover` verdict above before spending. The sibling risk — a retry that would mint a second authorization for the same intent — is covered in the companion duplicate-payment article (`cx402-duppay-001-duplicate-payment-risk.md`).
111
+
112
+ Tested: yes. The veyline command suite covers RECOVERABLE and SAFE_RETRY decisions, the identity-from-evidence fallback, the unknown-identity refusal path, and the disabled-subsystem refusal (exit 3). The paid `safe_retry` quote endpoint and 402 terms were verified live against the rail on 2026-10-07; the paid verdict itself was not executed (no payment made).
101
113
 
102
114
  Limitation: it reasons from recorded evidence only. If the operation was never recorded in the ledger, there is no basis for a verdict, and it says so instead of inventing one.
103
115
 
@@ -1,6 +1,6 @@
1
1
  # What Callx402 Is, What It Can Do, and How to Use It
2
2
 
3
- **Version:** 1.0.0 · **Status:** v1.0.0 released, installable from GitHub · **Verified against:** `cli-help-verified-2026-10-06.txt`, `SPEC.md`, `core/`, `server/index.js`, `sdk/`, and the test suite (2026-10-06).
3
+ **Version:** 1.0.1 · **Status:** published on npm as `callx402@1.0.1` · **Verified against:** `cli-help-verified-2026-10-06.txt`, `SPEC.md`, `core/`, `server/index.js`, `sdk/`, and the test suite (2026-10-06), plus a clean-install audit 2026-10-07.
4
4
 
5
5
  ## The one-paragraph definition
6
6
 
@@ -20,7 +20,7 @@ Callx402 sits between the caller and the subsystems. There are three entry point
20
20
 
21
21
  All three entry points converge on one dispatcher, `core/index.js` (`runAction` / `runIntent`), and return one shared shape: the result envelope from SPEC section 4.
22
22
 
23
- The subsystems themselves live in the x402 paid API starter kit v2.0.0 tree (`~/workspace/products/x402-paid-api-starter-kit/v2.0.0`). Callx402 imports from that tree via lazy `require` and never writes anything under it; an integrity test ("zero files changed under the v2.0.0 tree") guards this invariant. A separate Veyline recovery track (`core/veyline.js`) resolves modules from the Veyline engineering recovery directory instead.
23
+ The subsystems themselves live in the Veyline Developer Primer v2.0.0 tree (x402-paid-api-starter-kit) (`~/workspace/products/x402-paid-api-starter-kit/v2.0.0`). Callx402 imports from that tree via lazy `require` and never writes anything under it; an integrity test ("zero files changed under the v2.0.0 tree") guards this invariant. A separate Veyline recovery track (`core/veyline.js`) resolves modules from the Veyline engineering recovery directory instead.
24
24
 
25
25
  ## The Veyline relationship
26
26
 
@@ -217,7 +217,7 @@ Hard invariants, enforced in the core and honored by the CLI, SDKs, and HTTP ser
217
217
  - **The approval threshold fails closed.** Planned cost above the threshold (default $5.00 USD) without explicit `--approve` refuses with exit 4. Default budget is $1.00 USD.
218
218
  - **Idempotency keys are honored.** A duplicate key returns the original stored result with `deduped:true`; nothing re-executes. The store is a file-backed JSONL log (`~/.config/callx402/idempotency.jsonl`, overridable with `CALLX402_IDEMPOTENCY_FILE`).
219
219
  - **Disabled or unreachable subsystems fail closed.** They name the subsystem and its flag; success is never faked.
220
- - **Feature flags are per-module.** Each subsystem exposes `FLAG` and `enabled()`; callx402 checks before dispatch and never force-enables anything that guards money movement. Default state is off: a fresh `callx402 status` reports 15/15 subsystems reachable and 0/15 enabled.
220
+ - **Feature flags are per-module.** Each subsystem exposes `FLAG` and `enabled()`; callx402 checks before dispatch and never force-enables anything that guards money movement. Default state is off: with the v2.0.0 tree present, a fresh `callx402 status` reports 15/15 subsystems reachable and 0/15 enabled; on a bare `npm install callx402` (no tree) it reports 0/15 reachable.
221
221
 
222
222
  ## Configuration
223
223
 
@@ -227,9 +227,10 @@ Config file: `~/.config/callx402/config.json` (override the path with `CALLX402_
227
227
 
228
228
  ## Current limitations
229
229
 
230
+ - **Subsystem commands need the v2.0.0 tree.** `diagnose`, `rescue`, `route`, `resolve`, `doctor`, `monitor`, `preflight`, and `inspect` dispatch into the Veyline Developer Primer v2.0.0 tree, which is a separate checkout, not an npm dependency. On a bare `npm install callx402` they exit 3 with `subsystem_unreachable`. Point `CALLX402_V2_ROOT` at the tree, or run in remote mode against your own callx402 server.
230
231
  - **No live execution route is wired.** The `execute` pipeline is honest about this: without a real executable route it returns "no executable route available," moves no money, and issues no effect proof. Execution side effects would require a live route plus an enabled execution kernel.
231
232
  - **Subsystems are disabled by default.** Each action exits 3 naming the flag (e.g. `PAYLOAD_SENTINEL=1`) until the operator enables it.
232
- - **Rescue is triage only.** Even with auth, callx402 runs detection, quoting, and a free-vs-paid offer; paid execution is explicitly not performed.
233
+ - **Rescue is triage only.** Even with auth, this local command runs detection, quoting, and a free-vs-paid offer; paid execution is performed only through the paid on-demand callx402 action path, never by this local command.
233
234
  - **Diagnosis and resolution depend on supplied evidence.** `diagnose` evaluates evidence deterministically; `resolve` on empty evidence yields UNKNOWN.
234
235
  - **The capability graph is experimental** (`createGraph()` throws unless `PAYLOAD_MCP_FABRIC=1`), so `route` and `inspect` have nothing to rank or look up until tools are registered under that flag.
235
236
  - **HTTP covers five POST routes only** (`/call`, `/rescue`, `/route`, `/resolve`, `/diagnose`); `monitor`, `preflight`, `inspect`, `evidence`, `explain`, `recover`, and `config` are CLI/SDK only.
package/logo-400.png ADDED
Binary file
package/mcp/README.md CHANGED
@@ -20,6 +20,11 @@ Only diagnostic, read-only actions are exposed. Nothing here charges, executes,
20
20
  retries, or repays. If a subsystem is unreachable, the tool fails closed
21
21
  instead of inventing an answer.
22
22
 
23
+ Setup note: `x402_status` works with zero setup. The other five tools
24
+ dispatch into the v2.0.0 subsystem tree, so they report
25
+ `subsystem_unreachable` until `CALLX402_V2_ROOT` points at the tree and the
26
+ matching flag is enabled (see the main README, "Full subsystem commands").
27
+
23
28
  Tool names are namespaced `x402_*` — never `call_x402` (live collision on the
24
29
  fiatdock marketplace).
25
30
 
package/mcp/index.js CHANGED
@@ -24,7 +24,7 @@
24
24
  */
25
25
  const readline = require('node:readline');
26
26
 
27
- const VERSION = '1.0.0';
27
+ const VERSION = '1.0.2';
28
28
  const PROTOCOL_VERSION = '2024-11-05';
29
29
  const SERVER_NAME = 'callx402';
30
30
  const TIMEOUT_MS = Number(process.env.CALLX402_TIMEOUT_MS || 30000);
package/package.json CHANGED
@@ -24,8 +24,9 @@
24
24
  "url": "https://github.com/Payloadhq/callx402"
25
25
  },
26
26
  "scripts": {
27
- "smoke": "node bin/callx402.js status --json"
27
+ "smoke": "node bin/callx402.js status --json",
28
+ "test": "node --test test/*.test.js"
28
29
  },
29
- "version": "1.0.1",
30
+ "version": "1.0.3",
30
31
  "mcpName": "io.github.payload-tools/callx402"
31
- }
32
+ }
package/server.json CHANGED
@@ -10,7 +10,7 @@
10
10
  "transport": {
11
11
  "type": "stdio"
12
12
  },
13
- "version": "1.0.1"
13
+ "version": "1.0.2"
14
14
  }
15
15
  ],
16
16
  "repository": {
@@ -18,6 +18,6 @@
18
18
  "url": "https://github.com/Payloadhq/callx402"
19
19
  },
20
20
  "title": "callx402 MCP server",
21
- "version": "1.0.1",
21
+ "version": "1.0.2",
22
22
  "websiteUrl": "https://github.com/Payloadhq/callx402"
23
23
  }
@@ -8,15 +8,14 @@
8
8
  */
9
9
 
10
10
  const { test } = require('node:test');
11
- const { scratchDir, cliEnv, runCLI, assertEnvelope } = require('./helpers');
12
-
11
+ const { scratchDir, cliEnv, runCLI, assertEnvelope, needsV2Tree } = require('./helpers');
13
12
  const scratch = scratchDir('cli');
14
13
 
15
14
  function env(extra) {
16
15
  return cliEnv(scratch, extra);
17
16
  }
18
17
 
19
- test('diagnose: disabled subsystem -> exit 3, honest disabled envelope', async (t) => {
18
+ test('diagnose: disabled subsystem -> exit 3, honest disabled envelope', needsV2Tree, async (t) => {
20
19
  const r = await runCLI(['diagnose', '--json'], env());
21
20
  t.assert.strictEqual(r.code, 3);
22
21
  assertEnvelope(t, r.json, 'diagnose');
@@ -27,7 +26,7 @@ test('diagnose: disabled subsystem -> exit 3, honest disabled envelope', async (
27
26
  t.assert.match(r.json.error.message, /PAYLOAD_MCP_DOCTOR=1/);
28
27
  });
29
28
 
30
- test('diagnose: enabled -> exit 0, real doctor report with stages', async (t) => {
29
+ test('diagnose: enabled -> exit 0, real doctor report with stages', needsV2Tree, async (t) => {
31
30
  const r = await runCLI(['diagnose', '--json'], env({ PAYLOAD_MCP_DOCTOR: '1' }));
32
31
  t.assert.strictEqual(r.code, 0);
33
32
  assertEnvelope(t, r.json, 'diagnose');
@@ -38,13 +37,13 @@ test('diagnose: enabled -> exit 0, real doctor report with stages', async (t) =>
38
37
  t.assert.strictEqual(typeof r.json.data.report.whatFailed, 'string');
39
38
  });
40
39
 
41
- test('doctor: alias of diagnose -> envelope action is diagnose', async (t) => {
40
+ test('doctor: alias of diagnose -> envelope action is diagnose', needsV2Tree, async (t) => {
42
41
  const r = await runCLI(['doctor', '--json'], env({ PAYLOAD_MCP_DOCTOR: '1' }));
43
42
  t.assert.strictEqual(r.code, 0);
44
43
  assertEnvelope(t, r.json, 'diagnose');
45
44
  });
46
45
 
47
- test('rescue: enabled but no auth -> auth_required, exit 3, nothing executed', async (t) => {
46
+ test('rescue: enabled but no auth -> auth_required, exit 3, nothing executed', needsV2Tree, async (t) => {
48
47
  const r = await runCLI(['rescue', '--incident', 'inc-1', '--json'], env({ PAYLOAD_RESCUE: '1' }));
49
48
  t.assert.strictEqual(r.code, 3);
50
49
  assertEnvelope(t, r.json, 'rescue');
@@ -54,7 +53,7 @@ test('rescue: enabled but no auth -> auth_required, exit 3, nothing executed', a
54
53
  t.assert.match(r.json.disposition, /no authorization/i);
55
54
  });
56
55
 
57
- test('rescue: with --auth -> real rescue triage, read-only, exit 0', async (t) => {
56
+ test('rescue: with --auth -> real rescue triage, read-only, exit 0', needsV2Tree, async (t) => {
58
57
  const r = await runCLI(['rescue', '--incident', 'inc-1', '--auth', '--json'], env({ PAYLOAD_RESCUE: '1' }));
59
58
  t.assert.strictEqual(r.code, 0);
60
59
  assertEnvelope(t, r.json, 'rescue');
@@ -62,14 +61,14 @@ test('rescue: with --auth -> real rescue triage, read-only, exit 0', async (t) =
62
61
  t.assert.match(r.json.disposition, /read-only/i);
63
62
  });
64
63
 
65
- test('route: disabled subsystem -> exit 3', async (t) => {
64
+ test('route: disabled subsystem -> exit 3', needsV2Tree, async (t) => {
66
65
  const r = await runCLI(['route', '--goal', 'pick a tool', '--json'], env());
67
66
  t.assert.strictEqual(r.code, 3);
68
67
  assertEnvelope(t, r.json, 'route');
69
68
  t.assert.strictEqual(r.json.error.code, 'subsystem_disabled');
70
69
  });
71
70
 
72
- test('route: enabled, no candidates -> honest no-route, exit 0', async (t) => {
71
+ test('route: enabled, no candidates -> honest no-route, exit 0', needsV2Tree, async (t) => {
73
72
  const r = await runCLI(['route', '--goal', 'pick a tool', '--json'], env({ PAYLOAD_MCP_ROUTES: '1' }));
74
73
  t.assert.strictEqual(r.code, 0);
75
74
  assertEnvelope(t, r.json, 'route');
@@ -78,14 +77,14 @@ test('route: enabled, no candidates -> honest no-route, exit 0', async (t) => {
78
77
  t.assert.strictEqual(r.json.data.candidates, 0);
79
78
  });
80
79
 
81
- test('route: missing --goal -> usage error, exit 2', async (t) => {
80
+ test('route: missing --goal -> usage error, exit 2', needsV2Tree, async (t) => {
82
81
  const r = await runCLI(['route', '--json'], env({ PAYLOAD_MCP_ROUTES: '1' }));
83
82
  t.assert.strictEqual(r.code, 2);
84
83
  assertEnvelope(t, r.json, 'route');
85
84
  t.assert.strictEqual(r.json.error.code, 'usage');
86
85
  });
87
86
 
88
- test('resolve: empty evidence -> settlement UNKNOWN, exit 5', async (t) => {
87
+ test('resolve: empty evidence -> settlement UNKNOWN, exit 5', needsV2Tree, async (t) => {
89
88
  const r = await runCLI(['resolve', '--evidence', '{}', '--json'], env({ PAYLOAD_SETTLEMENT_RESOLVER: '1' }));
90
89
  t.assert.strictEqual(r.code, 5);
91
90
  assertEnvelope(t, r.json, 'resolve');
@@ -94,7 +93,7 @@ test('resolve: empty evidence -> settlement UNKNOWN, exit 5', async (t) => {
94
93
  t.assert.strictEqual(r.json.error.code, 'settlement_unknown');
95
94
  });
96
95
 
97
- test('resolve: disabled subsystem -> exit 3', async (t) => {
96
+ test('resolve: disabled subsystem -> exit 3', needsV2Tree, async (t) => {
98
97
  const r = await runCLI(['resolve', '--evidence', '{}', '--json'], env());
99
98
  t.assert.strictEqual(r.code, 3);
100
99
  assertEnvelope(t, r.json, 'resolve');
@@ -123,14 +122,14 @@ test('intent mode: bare quoted string dispatches execute', async (t) => {
123
122
  assertEnvelope(t, r.json, 'execute');
124
123
  });
125
124
 
126
- test('monitor: disabled sentinel -> exit 3', async (t) => {
125
+ test('monitor: disabled sentinel -> exit 3', needsV2Tree, async (t) => {
127
126
  const r = await runCLI(['monitor', '--once', '--json'], env());
128
127
  t.assert.strictEqual(r.code, 3);
129
128
  assertEnvelope(t, r.json, 'monitor');
130
129
  t.assert.strictEqual(r.json.error.code, 'subsystem_disabled');
131
130
  });
132
131
 
133
- test('monitor: inert sentinel snapshot -> exit 0', async (t) => {
132
+ test('monitor: inert sentinel snapshot -> exit 0', needsV2Tree, async (t) => {
134
133
  const r = await runCLI(['monitor', '--once', '--json'], env({ PAYLOAD_SENTINEL: '1' }));
135
134
  t.assert.strictEqual(r.code, 0);
136
135
  assertEnvelope(t, r.json, 'monitor');
@@ -138,7 +137,7 @@ test('monitor: inert sentinel snapshot -> exit 0', async (t) => {
138
137
  t.assert.ok(r.json.data.snapshot, 'snapshot present');
139
138
  });
140
139
 
141
- test('status: exit 0, all subsystems reported', async (t) => {
140
+ test('status: exit 0, all subsystems reported', needsV2Tree, async (t) => {
142
141
  const r = await runCLI(['status', '--json'], env());
143
142
  t.assert.strictEqual(r.code, 0);
144
143
  assertEnvelope(t, r.json, 'status');
@@ -151,7 +150,7 @@ test('status: exit 0, all subsystems reported', async (t) => {
151
150
  t.assert.strictEqual(subs.doctor.flag, 'PAYLOAD_MCP_DOCTOR');
152
151
  });
153
152
 
154
- test('preflight: disabled -> exit 3; enabled -> exit 0 with report', async (t) => {
153
+ test('preflight: disabled -> exit 3; enabled -> exit 0 with report', needsV2Tree, async (t) => {
155
154
  const off = await runCLI(['preflight', '--json'], env());
156
155
  t.assert.strictEqual(off.code, 3);
157
156
  assertEnvelope(t, off.json, 'preflight');
@@ -163,7 +162,7 @@ test('preflight: disabled -> exit 3; enabled -> exit 0 with report', async (t) =
163
162
  t.assert.ok(on.json.data.report, 'preflight report present');
164
163
  });
165
164
 
166
- test('inspect: capability stats without query -> exit 0', async (t) => {
165
+ test('inspect: capability stats without query -> exit 0', needsV2Tree, async (t) => {
167
166
  const r = await runCLI(['inspect', '--json'], env({ PAYLOAD_MCP_FABRIC: '1' }));
168
167
  t.assert.strictEqual(r.code, 0);
169
168
  assertEnvelope(t, r.json, 'inspect');
@@ -205,7 +204,7 @@ test('unknown command -> exit 2 and lists valid commands', async (t) => {
205
204
  }
206
205
  });
207
206
 
208
- test('malformed input: bad JSON evidence -> exit 2 with clear message', async (t) => {
207
+ test('malformed input: bad JSON evidence -> exit 2 with clear message', needsV2Tree, async (t) => {
209
208
  const r = await runCLI(['resolve', '--evidence', '{not-json', '--json'], env({ PAYLOAD_SETTLEMENT_RESOLVER: '1' }));
210
209
  t.assert.strictEqual(r.code, 2);
211
210
  assertEnvelope(t, r.json, 'resolve');
@@ -230,7 +229,8 @@ test('malformed input: bad flag values -> exit 2 with clear message', async (t)
230
229
  test('--version prints version and exits 0', async (t) => {
231
230
  const r = await runCLI(['--version'], env());
232
231
  t.assert.strictEqual(r.code, 0);
233
- t.assert.match(r.stdout.trim(), /^callx402 0\.1\.0$/);
232
+ const pkg = require('../package.json');
233
+ t.assert.strictEqual(r.stdout.trim(), `callx402 ${pkg.version}`);
234
234
  });
235
235
 
236
236
  test('--help exits 0 and documents commands', async (t) => {
@@ -240,6 +240,25 @@ test('--help exits 0 and documents commands', async (t) => {
240
240
  t.assert.match(r.stdout, /Exit codes: 0 ok/);
241
241
  });
242
242
 
243
+ test('<command> --help prints that command usage, not the global help', async (t) => {
244
+ const r = await runCLI(['recover', '--help'], env());
245
+ t.assert.strictEqual(r.code, 0);
246
+ t.assert.match(r.stdout, /callx402 recover/);
247
+ t.assert.match(r.stdout, /PAYLOAD_VEYLINE_RECOVERY=1/);
248
+ t.assert.doesNotMatch(r.stdout, /callx402 <command>/);
249
+ const r2 = await runCLI(['resolve', '--help'], env());
250
+ t.assert.strictEqual(r2.code, 0);
251
+ t.assert.match(r2.stdout, /settlement UNKNOWN, exit 5/);
252
+ });
253
+
254
+ test('rail-only paid action as command -> exit 2 with routing hint', async (t) => {
255
+ const r = await runCLI(['safe_retry'], env());
256
+ t.assert.strictEqual(r.code, 2);
257
+ t.assert.match(r.stderr, /unknown command 'safe_retry'/);
258
+ t.assert.match(r.stderr, /paid on-demand rail action, not a CLI command/);
259
+ t.assert.match(r.stderr, /problem-map\.md/);
260
+ });
261
+
243
262
  test('human-readable (non-JSON) output is concise', async (t) => {
244
263
  const r = await runCLI(['status'], env());
245
264
  t.assert.strictEqual(r.code, 0);
package/test/helpers.js CHANGED
@@ -40,7 +40,10 @@ function cliEnv(scratch, extra = {}) {
40
40
  CALLX402_CONFIG: path.join(scratch, 'config.json'),
41
41
  CALLX402_IDEMPOTENCY_FILE: path.join(scratch, 'idempotency.jsonl'),
42
42
  // Hermetic default: local mode unless the test explicitly asks for remote.
43
+ // CALLX402_MODE=local is the legacy remote-server toggle; CALLX402_LOCAL=1
44
+ // forces the v2.0.0 runtime tree path (new default is the hosted rail).
43
45
  CALLX402_MODE: 'local',
46
+ CALLX402_LOCAL: '1',
44
47
  ...extra,
45
48
  };
46
49
  }
@@ -101,3 +104,20 @@ async function withEnv(vars, fn) {
101
104
  }
102
105
 
103
106
  module.exports = { ROOT, CLI, V2_ROOT, ENVELOPE_KEYS, scratchDir, cliEnv, runCLI, assertEnvelope, withEnv };
107
+
108
+ /**
109
+ * v2 tree availability gate. The v2.0.0 runtime tree is the optional advanced
110
+ * self-hosted mode, not a requirement. Tests that exercise the local subsystem
111
+ * path skip (not fail) when the tree is absent — on a clean machine the CLI
112
+ * routes to the hosted rail by default instead.
113
+ */
114
+ function v2TreeAvailable() {
115
+ try {
116
+ require('node:fs').accessSync(require('node:path').join(V2_ROOT, 'lib', 'mcp', 'doctor.js'));
117
+ return true;
118
+ } catch { return false; }
119
+ }
120
+ const needsV2Tree = { skip: !v2TreeAvailable() };
121
+
122
+ module.exports.v2TreeAvailable = v2TreeAvailable;
123
+ module.exports.needsV2Tree = needsV2Tree;
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  const { test } = require('node:test');
13
+ const { needsV2Tree } = require('./helpers');
13
14
  const fs = require('node:fs');
14
15
  const path = require('node:path');
15
16
 
@@ -25,7 +26,7 @@ function walk(dir, out) {
25
26
  return out;
26
27
  }
27
28
 
28
- test('zero files changed under the v2.0.0 tree', (t) => {
29
+ test('zero files changed under the v2.0.0 tree', needsV2Tree, (t) => {
29
30
  t.assert.ok(fs.existsSync(MARKER), 'integrity marker exists (created before the suite ran)');
30
31
  t.assert.ok(fs.existsSync(V2_ROOT), 'v2.0.0 tree exists');
31
32
  const markerMs = fs.statSync(MARKER).mtimeMs;