diff --git a/README.md b/README.md index 2c9d717..cc17ec5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # shop -`shop` lets agents search millions of stores, build carts, check out and pay with the shopper's Shop account, and track orders. When a store has no agent checkout, the shopper can approve a purchase on Shop and the agent gets a card to pay at the merchant's own checkout. +`shop` lets agents search millions of stores, build carts, check out and pay with the shopper's Shop account, and track orders. When a store has no agent checkout, the shopper can approve a purchase on Shop and the agent gets a one-time credential to pay at the site's own checkout. ## Contents @@ -19,9 +19,10 @@ ```sh npm install --global @shopify/shop@latest +shop skill --install ``` -This installs the `shop` command and its agent skill. Then ask your agent: +Installs the `shop` command, then teaches your agent when and how to use it. Then ask your agent: > Find three rain jackets under US$100 that ship to the US. @@ -284,15 +285,15 @@ Some stores have no agent checkout API (UCP); the agent has to enter a payment c 1. The agent describes the purchase with `shop spend-request create`. 2. The shopper reviews and approves it on Shop. -3. The agent gets a card from `shop spend-request get`, good for up to the `approval_limit` the shopper saw and approved. -4. The agent pays at the merchant's checkout and reports the outcome with `shop spend-request complete`. +3. `shop spend-request get` returns a one-time credential that fills the card fields of the site's checkout form, good for up to the `approval_limit` the shopper saw and approved. +4. The agent pays at the site's checkout and reports the outcome with `shop spend-request complete`. -`--instrument-id` is one of the cards from `shop account`. Nested values such as line items take JSON, a `@file`, or `-` for stdin: +`--instrument-id` names one of the `selectable_instruments` from `shop account`; omit it to use the shopper's preferred card. Nested values such as line items take JSON, a `@file`, or `-` for stdin: ```sh shop spend-request create \ --business-name "Example Bookshop" --business-url https://example-bookshop.com --business-country US \ - --currency USD --approval-limit 1495 --instrument-id 3f1e2d4c-5b6a-4789-8abc-def012345678 \ + --currency USD --approval-limit 1495 \ --line-item @items.json \ --total '{"type":"total","amount":1495}' ``` @@ -320,7 +321,7 @@ shop spend-request create \ -Give the shopper the `continue_url`, then wait for their approval. `--output-file` keeps the card out of the transcript: the issued instrument is written to a new file only you can read, and stdout carries the status and terms: +Give the shopper the `continue_url`, then wait for their approval. `--output-file` keeps the credential out of the transcript: the issued instrument is written to a new file only you can read, and stdout carries the status and terms: ```sh shop spend-request get 0198e63b-1234-7abc-8def-123456789abc --wait 90 --output-file ./card.json @@ -355,9 +356,9 @@ shop spend-request get 0198e63b-1234-7abc-8def-123456789abc --wait 90 --output-f -The card arrives when the status is `consumed`. Hand the file to the program that fills the checkout form; without `--output-file`, the instrument is returned inline instead. +The credential arrives when the status is `consumed`. Hand the file to the program that fills the checkout form; without `--output-file`, the instrument is returned inline instead. -After paying at the merchant's checkout, the agent reports the outcome: +After paying at the site's checkout, the agent reports the outcome: ```sh shop spend-request complete 0198e63b-1234-7abc-8def-123456789abc \ @@ -399,14 +400,12 @@ To use the server without the CLI: - Connect an MCP host to `https://mcp.shop.com/` to access the same tools. Sign-in goes through the host's OAuth flow, against the same Shop account, and the tools behave as they do in the CLI. - A host that supports the skills extension ([SEP-2640](https://modelcontextprotocol.io/seps/2640-skills-extension)) finds the server's skill with `skills/list` on the same connection and reads its files with `resources/read`, so there is nothing to install. This package bundles the same skill. -To install the skill from this repository: +Install the skill from an installed `shop` command: ```sh -npx skills add Shopify/shop +shop skill --install ``` -The npm package's install hook runs the same `skills add` on its bundled copy. - ## Reference ```sh @@ -414,7 +413,7 @@ shop --help # the command tree shop search --help # a command's flags and examples shop search --schema # its complete input schema shop tools # every command with its MCP tool, usage and schema, as JSON — for agents -shop skill # the bundled agent guidance, as prose — what `npx skills add` installs +shop skill # the bundled agent guidance, as prose ``` Every command accepts `--arguments ` with the complete argument object from `--schema`. @@ -433,4 +432,4 @@ Errors use the same JSON envelope, and their `next` steps say what to do: {"ok":false,"v":1,"error":{"code":"auth_required","message":"Not signed in on this device","retryable":false,"next":["Run shop account login --wait 0 and show the shopper the verification URL and code","Run shop account login --wait 90 to finish once they approve","Retry this command after signing in"]}} ``` -To install the bundled skill again without reinstalling the package, run `npx skills add "$(npm root -g)/@shopify/shop/skills/shop" -g -y`. +Run `shop skill --install` again after upgrading `shop` to replace the installed guidance. diff --git a/lib/shop.wasm b/lib/shop.wasm index 56f7d9d..2516800 100644 Binary files a/lib/shop.wasm and b/lib/shop.wasm differ diff --git a/lib/skill-file.mjs b/lib/skill-file.mjs new file mode 100644 index 0000000..16c88c0 --- /dev/null +++ b/lib/skill-file.mjs @@ -0,0 +1,50 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +/** Replace one bundled-skill file inside a fixed harness root or the working directory. */ +export function writeSkillFile(request, { home = os.homedir(), cwd = process.cwd() } = {}) { + if (request === null || typeof request !== "object" || Array.isArray(request) || + Object.keys(request).sort().join(",") !== "content,path" || + typeof request.path !== "string" || !request.path || request.path.includes("\0") || + typeof request.content !== "string") { + throw new Error("invalid skill-file request"); + } + const resolved = path.resolve(cwd, request.path); + const roots = [path.join(home, ".agents", "skills"), path.join(home, ".claude", "skills"), cwd]; + // The whitelist is the seam: the module may name a file, the host decides whether it is inside a + // permitted root. Both sides are compared through the filesystem (realpath of the deepest existing + // ancestor), not lexically, so a symlinked `shop/` directory under a root cannot carry the write + // elsewhere; the final component is then refused if it is itself a link. + const permitted = roots.some(root => inside(realExisting(path.resolve(root)), realExisting(resolved))); + if (!permitted) throw new Error("skill path outside permitted directories"); + fs.mkdirSync(path.dirname(resolved), { recursive: true }); + try { + if (fs.lstatSync(resolved).isSymbolicLink()) throw new Error("skill path is a symlink"); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + fs.writeFileSync(resolved, request.content, "utf8"); + return resolved; +} + +function inside(root, target) { + const relative = path.relative(root, target); + return relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`)); +} + +/** `realpath` of the deepest existing ancestor, with the not-yet-existing tail appended lexically. */ +function realExisting(absolute) { + let existing = absolute; + const tail = []; + for (;;) { + try { return path.join(fs.realpathSync(existing), ...tail.reverse()); } + catch (error) { + if (error.code !== "ENOENT") throw error; + const parent = path.dirname(existing); + if (parent === existing) throw error; + tail.push(path.basename(existing)); + existing = parent; + } + } +} diff --git a/lib/worker.mjs b/lib/worker.mjs index f1d633c..caf1280 100644 --- a/lib/worker.mjs +++ b/lib/worker.mjs @@ -1,5 +1,6 @@ import fs from "node:fs"; import { writePrivateFile } from "./private-file.mjs"; +import { writeSkillFile } from "./skill-file.mjs"; import { parentPort, workerData } from "node:worker_threads"; import { WASI } from "node:wasi"; @@ -96,6 +97,13 @@ const shopHost = { return stage(enc.encode(writePrivateFile(request))); }); }, + skill_write(ptr, len) { + staged = null; + return guarded(() => { + const request = JSON.parse(dec.decode(view(ptr, len, FRAME_CAP))); + return stage(enc.encode(writeSkillFile(request))); + }); + }, read_result(ptr, cap) { return guarded(() => { if (staged === null) return -1; diff --git a/package.json b/package.json index 1a0965b..9b70410 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@shopify/shop", - "version": "1.0.2", + "version": "1.0.3", "description": "Shop for agents: product discovery, checkout, and Shop order tracking across millions of stores", "license": "MIT", "repository": { @@ -25,11 +25,7 @@ "plugins", ".claude-plugin", ".agents", - "scripts", "README.md", "LICENSE" - ], - "scripts": { - "postinstall": "node scripts/postinstall.mjs" - } + ] } \ No newline at end of file diff --git a/plugins/shop/.claude-plugin/plugin.json b/plugins/shop/.claude-plugin/plugin.json index 211b84d..8281b80 100644 --- a/plugins/shop/.claude-plugin/plugin.json +++ b/plugins/shop/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "shop", - "version": "1.0.2", + "version": "1.0.3", "description": "Shop across merchants through the Universal Commerce Protocol", "author": { "name": "Shopify" diff --git a/plugins/shop/.codex-plugin/plugin.json b/plugins/shop/.codex-plugin/plugin.json index 8be0ce6..1dee5bc 100644 --- a/plugins/shop/.codex-plugin/plugin.json +++ b/plugins/shop/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "shop", - "version": "1.0.2", + "version": "1.0.3", "description": "Shop across merchants through the Universal Commerce Protocol", "author": { "name": "Shopify" diff --git a/plugins/shop/plugin.json b/plugins/shop/plugin.json index 6fcab66..424bcca 100644 --- a/plugins/shop/plugin.json +++ b/plugins/shop/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "shop", - "version": "1.0.2", + "version": "1.0.3", "description": "Shop across merchants through the Universal Commerce Protocol", "author": { "name": "Shopify" diff --git a/plugins/shop/skills/shop/SKILL.md b/plugins/shop/skills/shop/SKILL.md index 250b4d8..58b3306 100644 --- a/plugins/shop/skills/shop/SKILL.md +++ b/plugins/shop/skills/shop/SKILL.md @@ -41,7 +41,7 @@ This is notation, not JSON. `?` means optional. Replace `RESOURCE` with `cart` o | `update_cart`, `update_checkout` | `{shop,id,RESOURCE:full_document}` | | `complete_checkout` | `{shop,id,checkout:{payment:{instruments:[offered_instrument_with_selected_true]}}}`. | | `get_account` | `{}` | -| `create_spend_request` | `{spend_request:{business:{name,url,country},currency,line_items?,totals,shipping_address?,instrument_id,approval_limit,context?:{intent?}},meta?}`; for payment credential entry using a browser. | +| `create_spend_request` | `{spend_request:{business:{name,url,country},currency,line_items?,totals,shipping_address?,instrument_id?,approval_limit,context?:{intent?}},meta?}`; for payment credential entry using a browser. | | `get_spend_request`, `cancel_spend_request` | `{id}`; cancel may add `meta`. Get may issue/replay a credential and is not read-only. | | `update_spend_request` | `{id,spend_request:{shipping_address?,line_items?,totals?,approval_limit?},meta?}`. | | `complete_spend_request` | `{id,result:{status:"success",order?:{id?,permalink_url?,total?,currency?}}}` or `{id,result:{status:"error",failure_code,message?}}`; present optional fields are non-null. | diff --git a/plugins/shop/skills/shop/references/transactions.md b/plugins/shop/skills/shop/references/transactions.md index 68f7264..fdb782e 100644 --- a/plugins/shop/skills/shop/references/transactions.md +++ b/plugins/shop/skills/shop/references/transactions.md @@ -39,12 +39,11 @@ The buyer approves the payee, purchase terms, saved card and spending limit in S Use `update_spend_request` with `{id,spend_request:{…},meta?}` to change the shipping address, items, totals, or spending limit. Omitted fields stay unchanged. Supplied arrays replace their previous values, and Wallet deep-merges address fields, unlike full-document cart/checkout updates. Wallet decides whether the request's current state allows the update; do not assume that buyer approval makes it editable. 1. Observe the actual payee and purchase terms. -2. Call `get_account`. When cards are returned, use the card matching nonempty `preferred_instrument_id` unless the user chose another; if an older response has cards but no preference pointer, ask the user to choose by `display.funding_instrument.description` as is when present, otherwise brand and last digits. If no cards were returned, follow `messages` and `next[]` and do not create the spend request. The preference is not checkout `selected`, an eligibility guarantee, a credential, or purchase authorization; the user still approves the payee, terms, card, and ceiling in Shop. -3. Call `create_spend_request` once with `spend_request` containing the required payee `business{name,url,country}`, `currency`, total, selected `instrument_id`, and `approval_limit` in currency minor units. Include actual line items, shipping address, and `context.intent` when available. Never invent missing terms. -4. Share the returned `continue_url` unchanged. If absent, say the spend request is waiting in Shop; never construct a review link. -5. With the CLI, run `shop spend-request get '' --wait 90`; with another binding, call `get_spend_request` again only after a successful `pending` result. After buyer approval, get returns the payment credential. This is not a read-only operation. Do not run checks concurrently. When a credential is delivered, the response includes `consumed` terms and an `instrument`. -6. Enter the issued `instrument`'s credential only into the reviewed website's checkout form. Use it exactly as returned and only for the approved purchase. Its `display` describes that issued card. Never repeat its number, expiry, CVV, or name in chat, logs, diagnostics, plans, or error text. `display.funding_instrument` is the user's saved card that pays: name that card to the user, and if the business needs to find the payment, offer the issued card's `display.last_digits`. Present either card's `description`, when present, as is; otherwise use its brand and last digits. -7. After the purchase attempt, call `complete_spend_request` with the observed `success` or `error`. Report success only from transaction evidence and include order details only if known. For an error, send the observed `failure_code` and optional `message`, preserving any unknown charge outcome. Never include a payment credential. This records your report, not payment confirmation. Follow the recovery section if report acceptance is uncertain. +2. Call `create_spend_request` once with `spend_request` containing the required payee `business{name,url,country}`, `currency`, total, and `approval_limit` in currency minor units. Omit `instrument_id` to use the account's preferred card; name one only when the user chose a different saved card (ids from `get_account.selectable_instruments[]`, shown by `display.funding_instrument.description` when present, otherwise brand and last digits). Include actual line items, shipping address, and `context.intent` when available. Never invent missing terms. The preferred card is not checkout `selected`, an eligibility guarantee, a credential, or purchase authorization; the user still approves the payee, terms, card, and ceiling in Shop. +3. Share the returned `continue_url` unchanged. If absent, say the spend request is waiting in Shop; never construct a review link. +4. With the CLI, run `shop spend-request get '' --wait 90`; with another binding, call `get_spend_request` again only after a successful `pending` result. After buyer approval, get returns the payment credential. This is not a read-only operation. Do not run checks concurrently. When a credential is delivered, the response includes `consumed` terms and an `instrument`. +5. Enter the issued `instrument`'s credential only into the reviewed website's checkout form. Use it exactly as returned and only for the approved purchase. Its `display` describes that issued card. Never repeat its number, expiry, CVV, or name in chat, logs, diagnostics, plans, or error text. `display.funding_instrument` is the user's saved card that pays: name that card to the user, and if the business needs to find the payment, offer the issued card's `display.last_digits`. Present either card's `description`, when present, as is; otherwise use its brand and last digits. +6. After the purchase attempt, call `complete_spend_request` with the observed `success` or `error`. Report success only from transaction evidence and include order details only if known. For an error, send the observed `failure_code` and optional `message`, preserving any unknown charge outcome. Never include a payment credential. This records your report, not payment confirmation. Follow the recovery section if report acceptance is uncertain. `expires_at` is the spend request's deadline for its next step (`pending`: approve by; `approved`: retrieve the credential by), not the card's expiry. `consumed` without an `instrument` does not establish credential delivery or payment. Never create a replacement spend request merely to recover an uncertain issuance. diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs deleted file mode 100644 index e72e81d..0000000 --- a/scripts/postinstall.mjs +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env node - -import { spawnSync } from 'node:child_process'; -import { posix, resolve, win32 } from 'node:path'; -import process from 'node:process'; -import { fileURLToPath, pathToFileURL } from 'node:url'; - -export const SKILL_INSTALL_TIMEOUT_MS = 60_000; - -export function installBundledSkill({ - env = process.env, - execPath = process.execPath, - moduleUrl = import.meta.url, - platform = process.platform, - spawn = spawnSync, - stdout = process.stdout, -} = {}) { - if (env.CI || Object.hasOwn(env, 'SHOP_SKIP_SKILL_INSTALL')) { - return; - } - - const skillPath = fileURLToPath(new URL('../skills/shop', moduleUrl)); - const fallback = - `shop: skill installation skipped; install the bundled skill manually:\n` + - ` path: ${JSON.stringify(skillPath)}\n` + - ` command: npx --yes skills add -g -y\n`; - const npmExecPath = env.npm_execpath; - const pathApi = platform === 'win32' ? win32 : posix; - const usesNpmCli = - typeof npmExecPath === 'string' && - pathApi.basename(npmExecPath).toLowerCase() === 'npm-cli.js'; - const command = usesNpmCli ? execPath : 'npx'; - const commandArgs = usesNpmCli - ? [pathApi.join(pathApi.dirname(npmExecPath), 'npx-cli.js')] - : []; - - if (platform === 'win32' && !usesNpmCli) { - stdout.write(fallback); - return; - } - - stdout.write(`shop: installing bundled skill from ${skillPath}\n`); - try { - const result = spawn( - command, - [...commandArgs, '--yes', 'skills', 'add', skillPath, '-g', '-y'], - { - killSignal: 'SIGKILL', - shell: false, - stdio: 'inherit', - timeout: SKILL_INSTALL_TIMEOUT_MS, - }, - ); - if (result?.error || result?.status !== 0) { - stdout.write(fallback); - return; - } - stdout.write('shop: bundled skill installed.\n'); - } catch { - stdout.write(fallback); - } -} - -try { - if ( - process.argv[1] && - pathToFileURL(resolve(process.argv[1])).href === import.meta.url - ) { - installBundledSkill(); - } -} catch { - // Package installation must never fail because skill installation failed. -} diff --git a/skills/shop/references/cli.md b/skills/shop/references/cli.md index f6ad89d..457edf3 100644 --- a/skills/shop/references/cli.md +++ b/skills/shop/references/cli.md @@ -43,6 +43,6 @@ Use `@FILE` or stdin for an object that contains a credential or other secret so Follow the spend-request flow in [transactions.md](transactions.md). Use command help for shorthand or exact argument mode. -Create the spend request with the purchase terms, selected saved card and spending limit, then share the returned `continue_url` unchanged for review and approval. After approval, run `shop spend-request get '' --wait 90` to get the payment credential. Use it unchanged in the website's checkout form. After the purchase attempt, use `shop spend-request complete` to report `success` or `error`, with order details when available or failure information for an error. On `auth_required`, follow the entrypoint's sign-in flow. +Create the spend request with the purchase terms and spending limit (and a saved card only if the user chose one), then share the returned `continue_url` unchanged for review and approval. After approval, run `shop spend-request get '' --wait 90` to get the payment credential. Use it unchanged in the website's checkout form. After the purchase attempt, use `shop spend-request complete` to report `success` or `error`, with order details when available or failure information for an error. On `auth_required`, follow the entrypoint's sign-in flow. For file-based credential handoff, add `--output-file ` to get. The new file contains `{spend_request_id,instrument}` with the complete issued instrument. Stdout retains status and approved terms, replacing the instrument with `credential_output_file`. Give the file directly to the consuming program; do not print its contents into chat. Existing files are refused and the caller owns cleanup. POSIX files are created mode `0600`; on Windows, choose a directory with private ACLs. Browser-hosted Shop cannot export files. `--dry-run` creates nothing. diff --git a/skills/shop/references/transactions.md b/skills/shop/references/transactions.md index 68f7264..fdb782e 100644 --- a/skills/shop/references/transactions.md +++ b/skills/shop/references/transactions.md @@ -39,12 +39,11 @@ The buyer approves the payee, purchase terms, saved card and spending limit in S Use `update_spend_request` with `{id,spend_request:{…},meta?}` to change the shipping address, items, totals, or spending limit. Omitted fields stay unchanged. Supplied arrays replace their previous values, and Wallet deep-merges address fields, unlike full-document cart/checkout updates. Wallet decides whether the request's current state allows the update; do not assume that buyer approval makes it editable. 1. Observe the actual payee and purchase terms. -2. Call `get_account`. When cards are returned, use the card matching nonempty `preferred_instrument_id` unless the user chose another; if an older response has cards but no preference pointer, ask the user to choose by `display.funding_instrument.description` as is when present, otherwise brand and last digits. If no cards were returned, follow `messages` and `next[]` and do not create the spend request. The preference is not checkout `selected`, an eligibility guarantee, a credential, or purchase authorization; the user still approves the payee, terms, card, and ceiling in Shop. -3. Call `create_spend_request` once with `spend_request` containing the required payee `business{name,url,country}`, `currency`, total, selected `instrument_id`, and `approval_limit` in currency minor units. Include actual line items, shipping address, and `context.intent` when available. Never invent missing terms. -4. Share the returned `continue_url` unchanged. If absent, say the spend request is waiting in Shop; never construct a review link. -5. With the CLI, run `shop spend-request get '' --wait 90`; with another binding, call `get_spend_request` again only after a successful `pending` result. After buyer approval, get returns the payment credential. This is not a read-only operation. Do not run checks concurrently. When a credential is delivered, the response includes `consumed` terms and an `instrument`. -6. Enter the issued `instrument`'s credential only into the reviewed website's checkout form. Use it exactly as returned and only for the approved purchase. Its `display` describes that issued card. Never repeat its number, expiry, CVV, or name in chat, logs, diagnostics, plans, or error text. `display.funding_instrument` is the user's saved card that pays: name that card to the user, and if the business needs to find the payment, offer the issued card's `display.last_digits`. Present either card's `description`, when present, as is; otherwise use its brand and last digits. -7. After the purchase attempt, call `complete_spend_request` with the observed `success` or `error`. Report success only from transaction evidence and include order details only if known. For an error, send the observed `failure_code` and optional `message`, preserving any unknown charge outcome. Never include a payment credential. This records your report, not payment confirmation. Follow the recovery section if report acceptance is uncertain. +2. Call `create_spend_request` once with `spend_request` containing the required payee `business{name,url,country}`, `currency`, total, and `approval_limit` in currency minor units. Omit `instrument_id` to use the account's preferred card; name one only when the user chose a different saved card (ids from `get_account.selectable_instruments[]`, shown by `display.funding_instrument.description` when present, otherwise brand and last digits). Include actual line items, shipping address, and `context.intent` when available. Never invent missing terms. The preferred card is not checkout `selected`, an eligibility guarantee, a credential, or purchase authorization; the user still approves the payee, terms, card, and ceiling in Shop. +3. Share the returned `continue_url` unchanged. If absent, say the spend request is waiting in Shop; never construct a review link. +4. With the CLI, run `shop spend-request get '' --wait 90`; with another binding, call `get_spend_request` again only after a successful `pending` result. After buyer approval, get returns the payment credential. This is not a read-only operation. Do not run checks concurrently. When a credential is delivered, the response includes `consumed` terms and an `instrument`. +5. Enter the issued `instrument`'s credential only into the reviewed website's checkout form. Use it exactly as returned and only for the approved purchase. Its `display` describes that issued card. Never repeat its number, expiry, CVV, or name in chat, logs, diagnostics, plans, or error text. `display.funding_instrument` is the user's saved card that pays: name that card to the user, and if the business needs to find the payment, offer the issued card's `display.last_digits`. Present either card's `description`, when present, as is; otherwise use its brand and last digits. +6. After the purchase attempt, call `complete_spend_request` with the observed `success` or `error`. Report success only from transaction evidence and include order details only if known. For an error, send the observed `failure_code` and optional `message`, preserving any unknown charge outcome. Never include a payment credential. This records your report, not payment confirmation. Follow the recovery section if report acceptance is uncertain. `expires_at` is the spend request's deadline for its next step (`pending`: approve by; `approved`: retrieve the credential by), not the card's expiry. `consumed` without an `instrument` does not establish credential delivery or payment. Never create a replacement spend request merely to recover an uncertain issuance.