Pay a provider only for work a receipt proves

f0c36de6a0e0 · AtlantisPleb · · parent 69cd68c1e78c

Pay a provider only for work a receipt proves

VP-1 deleted Pylon's provider loop whole on 2026-07-14 (21e82ce8), and
what survived it is the wrong half. `advanceLaborJob` in
apps/pylon/src/coordinator/labor-job-state.ts still stands, uncalled,
with `settle` legal straight out of `delivered` — and `delivered` is set
by the provider. That machine pays a claim.

The gate here is the corrected port. It settles against the NIP-LBR
closeout receipt that packages/nip90 kept and grew through the purge, so
an earning needs a receipt that names this job and this provider, was not
issued by the provider to itself, carries a verification command and the
evidence it produced, carries the platform's own closeout, is
content-addressable, landed inside the lease window, and prices the job
exactly as the lease did. A lease earns nothing. A submission earns
nothing. Time online earns nothing, and cannot: uptime reaches neither
argument.

Nothing moves. A settled decision reports payout_rail not_connected and
custody none, because outbound payout stays on the MDK/Nexus bridge and
settlement authority stays in the platform receipt systems.

The receipt arrives as a file. Presence and the claim/lease transport
need a market lane the do-not-build register still defers, so `settle`
takes the proof at that seam rather than pretending to fetch it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KnhfrafYx5ZGaMbzZEJQ2d
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified docs/assure-repo/false-green-candidates.v1.json
  • modified docs/assure-repo/surface-inventory.v1.json
  • modified packages/openagents-cli/src/cli.ts
  • added packages/openagents-cli/src/provider-command.ts
  • added packages/openagents-cli/src/provider-settlement.ts
  • added packages/openagents-cli/test/provider-command.test.ts
  • added packages/openagents-cli/test/provider-settlement.test.ts

Diff

7 files changed, +767 -3

docs/assure-repo/false-green-candidates.v1.json modified +1 -1

@@ -4,7 +4,7 @@

4 4
  "note": "Heuristic false-green LEADS, not findings. A finding requires a demonstrated reproduction (surviving mutation via mutation-runner). Do not treat a candidate as a confirmed false green. Coverage-theater leads may include tests that delegate their assertion to a custom helper the classifier does not recognise; verify before acting.",
5 5
  "sourceDigest": "sha256:dd810dd48c5bdbc9becd7fcc01dd41a4ca2abf0b2d6f6a545907247f6e3e8361",
6 6
  "summary": {
7
    "filesScanned": 2476,
7
    "filesScanned": 2478,
8 8
    "candidateCount": 16,
9 9
    "byMode": {
10 10
      "false_green_coverage_theater": 15,
docs/assure-repo/surface-inventory.v1.json modified +2 -2

@@ -1,7 +1,7 @@

1 1
{
2 2
  "schemaVersion": "1",
3 3
  "repository": "OpenAgentsInc/openagents",
4
  "sourceDigest": "sha256:1d9036ecce5eea778b592bc94744162f06c5593790e4f7735114cec9f5d19c3a",
4
  "sourceDigest": "sha256:d8cd295762f49628cb5b8f0b739a7c2f863cfa6b021515152743307a3736f13c",
5 5
  "surfaces": [
6 6
    {
7 7
      "id": "app:@openagentsinc/acceptance-runner",

@@ -1876,7 +1876,7 @@

1876 1876
      "oracles": [
1877 1877
        {
1878 1878
          "type": "test",
1879
          "ref": "packages/openagents-cli (71 tracked test files)"
1879
          "ref": "packages/openagents-cli (73 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/src/cli.ts modified +4

@@ -155,6 +155,7 @@ import { IssueClient } from "./issue-client.js";

155 155
import { runGitCredentialHelper } from "./git-credential-helper.js";
156 156
import { Output, type OutputMode } from "./output.js";
157 157
import { ProjectClient } from "./project-client.js";
158
import { makeProviderCommand } from "./provider-command.js";
158 159
import { parseRepositoryTarget, RepositoryClient } from "./repository-client.js";
159 160
import { RequestBodyInput } from "./request-body-input.js";
160 161
import { SecretInput } from "./secret-input.js";

@@ -3957,6 +3958,8 @@ const identityCommand = makeIdentityCommand(rootCommand);

3957 3958
3958 3959
const traceCommand = makeTraceCommand(rootCommand);
3959 3960
3961
const providerCommand = makeProviderCommand(rootCommand);
3962
3960 3963
// The deploy command group: named operator deployment commands over the
3961 3964
// operator-only fleet promotion API from OpenAgentsInc/openagents.com#57.
3962 3965
// It consumes only that API — never `/admin/forge`, SSH, or an internal RPC —

@@ -4344,6 +4347,7 @@ export const openagentsCommand = rootCommand.pipe(

4344 4347
    identityCommand,
4345 4348
    issueCommand,
4346 4349
    projectCommand,
4350
    providerCommand,
4347 4351
    repoCommand,
4348 4352
    traceCommand,
4349 4353
  ]),
packages/openagents-cli/src/provider-command.ts added +173

@@ -0,0 +1,173 @@

1
/**
2
 * The `openagents provider` command family.
3
 *
4
 * This is the earning half of provider mode, revived at the one seam that can
5
 * be proven offline: the settlement decision. `provider settle` reads a lease
6
 * and, when one exists, the NIP-LBR closeout receipt that covers it, and says
7
 * what the job earned. Unverified work earns zero and says which gate stopped
8
 * it.
9
 *
10
 * What is deliberately not here: presence, the claim and lease transport, and
11
 * any payout. Presence needs a market transport the do-not-build register keeps
12
 * deferred (an open market lane later uses the relay/provider-daemon/skeptical-
13
 * client shape, not a NIP-90/DVM revival), and payout stays on the MDK/Nexus
14
 * bridge. So `settle` takes the receipt as a file at that seam rather than
15
 * pretending to fetch it, in the same spirit as `trace upload` naming the
16
 * server half it is waiting for.
17
 *
18
 * The family is defined through a factory taking the root command, so the
19
 * registration hunk in `cli.ts` stays a single import and a single list entry.
20
 */
21
22
import { readFileSync } from "node:fs";
23
import { resolve } from "node:path";
24
25
import { Effect, Option } from "effect";
26
import { Command, Flag } from "effect/unstable/cli";
27
28
import { InputError } from "./errors.js";
29
import { Output, type OutputMode } from "./output.js";
30
import {
31
  settleLease,
32
  type LaborCloseoutReceipt,
33
  type ProviderLease,
34
  type SettlementDecision,
35
} from "./provider-settlement.js";
36
37
/** The shared flags a provider handler reads back off the root command. */
38
interface SharedFlags {
39
  readonly json: boolean;
40
}
41
42
const outputMode = (json: boolean): OutputMode => (json ? "json" : "human");
43
44
const readJsonFile = (path: string, label: string) =>
45
  Effect.try({
46
    try: () => JSON.parse(readFileSync(resolve(path), "utf8")) as unknown,
47
    catch: () =>
48
      new InputError({ message: `The ${label} file at ${path} could not be read as JSON.` }),
49
  });
50
51
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
52
  typeof value === "object" && value !== null && !Array.isArray(value)
53
    ? (value as Record<string, unknown>)
54
    : undefined;
55
56
const text = (value: unknown): string => (typeof value === "string" ? value : "");
57
const count = (value: unknown): number => (typeof value === "number" ? value : Number.NaN);
58
59
/**
60
 * Read a lease document.
61
 *
62
 * The four fields the gate needs are required; a lease missing one is a typo
63
 * the reader would rather hear about now than as a mysterious refusal.
64
 */
65
const decodeLease = (value: unknown, path: string) =>
66
  Effect.gen(function* () {
67
    const record = asRecord(value);
68
    if (record === undefined) {
69
      return yield* new InputError({ message: `The lease at ${path} is not a JSON object.` });
70
    }
71
    const missing = ["job_id", "lane", "provider", "expires_at"].filter((field) =>
72
      typeof record[field] === "string" ? record[field] === "" : true,
73
    );
74
    if (missing.length > 0) {
75
      return yield* new InputError({
76
        message: `The lease at ${path} is missing ${missing.join(", ")}.`,
77
      });
78
    }
79
    const lease: ProviderLease = {
80
      job_id: text(record["job_id"]),
81
      lane: text(record["lane"]),
82
      provider: text(record["provider"]),
83
      price_msats: count(record["price_msats"]),
84
      expires_at: text(record["expires_at"]),
85
    };
86
    return lease;
87
  });
88
89
/**
90
 * Read a closeout receipt.
91
 *
92
 * Absent or malformed fields become empty strings rather than an error: the
93
 * settlement gate already has a named refusal for each of them, and a receipt
94
 * that is missing its verification refs should be refused as unverified work,
95
 * not as a bad file.
96
 */
97
const decodeCloseout = (value: unknown, path: string) =>
98
  Effect.gen(function* () {
99
    const record = asRecord(value);
100
    if (record === undefined) {
101
      return yield* new InputError({ message: `The closeout at ${path} is not a JSON object.` });
102
    }
103
    const closeout: LaborCloseoutReceipt = {
104
      receiptRef: text(record["receiptRef"]),
105
      requestId: text(record["requestId"]),
106
      requesterPubkey: text(record["requesterPubkey"]),
107
      providerPubkey: text(record["providerPubkey"]),
108
      quotedAmountMsats: count(record["quotedAmountMsats"]),
109
      verificationCommandRef: text(record["verificationCommandRef"]),
110
      testRef: text(record["testRef"]),
111
      platformCloseoutRef: text(record["platformCloseoutRef"]),
112
      digest: text(record["digest"]),
113
      settled_at: text(record["settled_at"]),
114
    };
115
    return closeout;
116
  });
117
118
const decisionHuman = (decision: SettlementDecision): ReadonlyArray<string> => [
119
  `Job: ${decision.job_id}`,
120
  `Outcome: ${decision.state}`,
121
  `Earned: ${decision.earned_msats} msats`,
122
  ...(decision.refusal === undefined ? [] : [`Refused: ${decision.refusal}`]),
123
  ...(decision.receipt_ref === undefined ? [] : [`Receipt: ${decision.receipt_ref}`]),
124
  decision.reason,
125
  "Accrual only: this command holds no key, connects no payout rail, and moves nothing.",
126
];
127
128
export const makeProviderCommand = <R>(root: Effect.Effect<SharedFlags, never, R>) => {
129
  const leaseFlag = Flag.string("lease").pipe(
130
    Flag.withDescription("Path to the lease document the buyer granted for this job"),
131
  );
132
  const closeoutFlag = Flag.string("closeout").pipe(
133
    Flag.optional,
134
    Flag.withDescription(
135
      "Path to the NIP-LBR closeout receipt covering this job. Omit it to see what an unverified job earns.",
136
    ),
137
  );
138
139
  const settleCommand = Command.make(
140
    "settle",
141
    { lease: leaseFlag, closeout: closeoutFlag },
142
    ({ closeout: closeoutPath, lease: leasePath }) =>
143
      Effect.gen(function* () {
144
        const flags = yield* root;
145
        const output = yield* Output;
146
147
        const lease = yield* decodeLease(yield* readJsonFile(leasePath, "lease"), leasePath);
148
        const closeout = Option.isSome(closeoutPath)
149
          ? yield* decodeCloseout(
150
              yield* readJsonFile(closeoutPath.value, "closeout"),
151
              closeoutPath.value,
152
            )
153
          : undefined;
154
155
        const decision = settleLease(lease, closeout);
156
        yield* output.write(
157
          { value: decision, human: decisionHuman(decision) },
158
          outputMode(flags.json),
159
        );
160
      }),
161
  ).pipe(
162
    Command.withDescription(
163
      "Decide what one leased job earned. Payment follows a NIP-LBR closeout receipt that names a verification command, its evidence, and the platform's own closeout; a lease, a submission, or time spent online earns nothing. The decision accrues and never pays: no key is held and no payout rail is connected.",
164
    ),
165
  );
166
167
  return Command.make("provider").pipe(
168
    Command.withDescription(
169
      "Earn on verified work. The settlement gate is live; presence, the claim and lease transport, and payout are not wired, so this decides what work is owed rather than moving anything.",
170
    ),
171
    Command.withSubcommands([settleCommand]),
172
  );
173
};
packages/openagents-cli/src/provider-settlement.ts added +265

@@ -0,0 +1,265 @@

1
/**
2
 * Provider mode: the settlement gate.
3
 *
4
 * ## What died, and what the corpse still teaches
5
 *
6
 * Pylon's provider loop -- NIP-89 announce, quote, execute on your own agent,
7
 * publish an output-only result, record a msat earning -- was deleted whole on
8
 * 2026-07-14 by VP-1 (`21e82ce8`, "retire money sites and wallet authority"),
9
 * along with `apps/pylon/src/provider-nip90.ts`, `labor-market.ts`, and
10
 * `multi-earning-ledger.ts`. It was not removed because it broke. It was
11
 * removed because payout and settlement were not part of the accepted MVP, and
12
 * `INVARIANTS.md` still says any revival needs a fresh design that does not
13
 * quietly restore custody.
14
 *
15
 * One piece survived the purge, uncalled, at
16
 * `apps/pylon/src/coordinator/labor-job-state.ts`. Its transition table reads:
17
 *
18
 *     accept: { quoted: "accepted" }
19
 *     start:  { accepted: "in_progress" }
20
 *     deliver:{ in_progress: "delivered" }
21
 *     settle: { delivered: "settled" }
22
 *
23
 * `settle` is legal straight out of `delivered`, and `delivered` is set by the
24
 * provider. That machine pays a claim. The doctrine the Pylon arc actually
25
 * earned -- a lease is not an earning claim, only a settlement receipt is --
26
 * cannot be expressed in it.
27
 *
28
 * ## What this is
29
 *
30
 * The corrected gate, on the surviving protocol substrate. `packages/nip90`
31
 * kept and grew the NIP-LBR lane through the purge: `lbr.ts` (request, quote,
32
 * acceptance, result), `lbr-bond.ts`, and `lbr-closeout.ts`, whose
33
 * `LbrLaborCloseout` is a content-addressed, public-safe receipt binding one
34
 * complete labor lifecycle. That receipt -- not the provider's own submission
35
 * -- is the thing this module will pay against.
36
 *
37
 * The closeout is read structurally rather than imported: `@openagentsinc/cli`
38
 * ships as a plain-tsc npm package with a deliberately small dependency set,
39
 * and `@openagentsinc/nip90` pulls a git-tarball dependency that does not
40
 * belong in it. {@link LaborCloseoutReceipt} mirrors only the public-safe
41
 * fields the gate reads, and every one of them is a field `lbr-closeout.ts`
42
 * already produces.
43
 *
44
 * ## What this refuses to be
45
 *
46
 * It moves no money and holds no key. A settled decision is an accrual record:
47
 * `payout_rail` is `not_connected` and `custody` is `none`. Outbound payout
48
 * stays on the MDK/Nexus bridge, and settlement authority stays in the platform
49
 * receipt systems, exactly as `docs/nips/LBR.md` says. The number here is what
50
 * a verified job is owed, recorded so a ledger can accrue while the rail stays
51
 * a separate, explicit owner decision.
52
 *
53
 * Presence is not an input. `settleLease` takes a lease and a closeout receipt.
54
 * Being online, advertising capacity, or holding a live lease reaches neither
55
 * argument, so a provider that never earns a closeout earns zero no matter how
56
 * long it is up.
57
 */
58
59
/**
60
 * A lease: the buyer's grant of one job to one provider at one price.
61
 *
62
 * `price_msats` is what the job is worth once it verifies. Holding the lease
63
 * earns none of it. `provider` is the provider's public key; deriving it is the
64
 * identity seam's job, not this module's.
65
 */
66
export interface ProviderLease {
67
  readonly job_id: string;
68
  readonly lane: string;
69
  readonly provider: string;
70
  readonly price_msats: number;
71
  readonly expires_at: string;
72
}
73
74
/**
75
 * The public-safe fields of a NIP-LBR closeout receipt this gate reads.
76
 *
77
 * A structural mirror of `LbrLaborCloseout` from
78
 * `packages/nip90/src/lbr-closeout.ts`; the names are that module's names so a
79
 * receipt produced there is accepted here without translation.
80
 */
81
export interface LaborCloseoutReceipt {
82
  /** `lbr-closeout:<requestId>:<digest>`. */
83
  readonly receiptRef: string;
84
  readonly requestId: string;
85
  readonly requesterPubkey: string;
86
  readonly providerPubkey: string;
87
  readonly quotedAmountMsats: number;
88
  /** What was run to check the work. Empty means nothing checked it. */
89
  readonly verificationCommandRef: string;
90
  /** The evidence that check produced. */
91
  readonly testRef: string;
92
  /** The platform's own closeout. Settlement authority lives there, not here. */
93
  readonly platformCloseoutRef: string;
94
  /** SHA-256 over the canonical projection, making the receipt dereferenceable. */
95
  readonly digest: string;
96
  readonly settled_at: string;
97
}
98
99
/** Why a settlement did not happen. Each one is a distinct, nameable failure. */
100
export type SettlementRefusal =
101
  | "price_not_payable"
102
  | "no_closeout"
103
  | "closeout_job_mismatch"
104
  | "closeout_provider_mismatch"
105
  | "self_dealt"
106
  | "work_not_verified"
107
  | "no_settlement_authority"
108
  | "receipt_not_addressable"
109
  | "lease_expired"
110
  | "price_mismatch";
111
112
export interface SettlementDecision {
113
  readonly schema: "openagents.provider_settlement.v1";
114
  readonly job_id: string;
115
  readonly state: "settled" | "unsettled";
116
  /** What the verified job is owed. Zero on every path but a clean receipt. */
117
  readonly earned_msats: number;
118
  readonly reason: string;
119
  readonly refusal?: SettlementRefusal;
120
  /** No rail is wired. A settled decision accrues; it does not pay. */
121
  readonly payout_rail: "not_connected";
122
  readonly custody: "none";
123
  readonly receipt_ref?: string;
124
}
125
126
const DIGEST_PATTERN = /^[a-f0-9]{64}$/i;
127
128
const unsettled = (
129
  job_id: string,
130
  refusal: SettlementRefusal,
131
  reason: string,
132
): SettlementDecision => ({
133
  schema: "openagents.provider_settlement.v1",
134
  job_id,
135
  state: "unsettled",
136
  earned_msats: 0,
137
  reason,
138
  refusal,
139
  payout_rail: "not_connected",
140
  custody: "none",
141
});
142
143
const blank = (value: string | undefined): boolean =>
144
  typeof value !== "string" || value.trim().length === 0;
145
146
const parsedTime = (value: string): number | undefined => {
147
  const at = Date.parse(value);
148
  return Number.isNaN(at) ? undefined : at;
149
};
150
151
/**
152
 * Decide what one leased job earns.
153
 *
154
 * The gates run in the order a reader would check them by hand, so a refusal
155
 * names the first thing that is actually wrong rather than the last. Only the
156
 * final branch returns a non-zero amount, and reaching it needs a closeout
157
 * receipt that exists, names this job and this provider, was not issued by the
158
 * provider to itself, carries both a verification command and the evidence it
159
 * produced, carries the platform's own closeout, is content-addressable, landed
160
 * inside the lease window, and prices the job exactly as the lease did.
161
 */
162
export const settleLease = (
163
  lease: ProviderLease,
164
  closeout?: LaborCloseoutReceipt,
165
): SettlementDecision => {
166
  if (!Number.isFinite(lease.price_msats) || lease.price_msats <= 0) {
167
    return unsettled(
168
      lease.job_id,
169
      "price_not_payable",
170
      `The lease prices this job at ${lease.price_msats} msats, so there is nothing to settle.`,
171
    );
172
  }
173
174
  if (closeout === undefined) {
175
    return unsettled(
176
      lease.job_id,
177
      "no_closeout",
178
      "No closeout receipt covers this job. A lease is not an earning claim and a submission is not a receipt, so this earns nothing.",
179
    );
180
  }
181
182
  if (closeout.requestId !== lease.job_id) {
183
    return unsettled(
184
      lease.job_id,
185
      "closeout_job_mismatch",
186
      `The receipt closes out job ${closeout.requestId}, not the leased job ${lease.job_id}.`,
187
    );
188
  }
189
190
  if (closeout.providerPubkey !== lease.provider) {
191
    return unsettled(
192
      lease.job_id,
193
      "closeout_provider_mismatch",
194
      `The receipt credits provider ${closeout.providerPubkey}, but the lease is held by ${lease.provider}.`,
195
    );
196
  }
197
198
  if (closeout.requesterPubkey === closeout.providerPubkey) {
199
    return unsettled(
200
      lease.job_id,
201
      "self_dealt",
202
      "The receipt names the same key as requester and provider. A provider cannot buy its own work into an earning.",
203
    );
204
  }
205
206
  if (blank(closeout.verificationCommandRef) || blank(closeout.testRef)) {
207
    return unsettled(
208
      lease.job_id,
209
      "work_not_verified",
210
      "The receipt carries no verification command and evidence pair, so nothing checked this work. Unverified work earns nothing.",
211
    );
212
  }
213
214
  if (blank(closeout.platformCloseoutRef)) {
215
    return unsettled(
216
      lease.job_id,
217
      "no_settlement_authority",
218
      "The receipt carries no platform closeout ref. Settlement authority stays in the platform receipt systems; the relay is only transport.",
219
    );
220
  }
221
222
  if (!DIGEST_PATTERN.test(closeout.digest)) {
223
    return unsettled(
224
      lease.job_id,
225
      "receipt_not_addressable",
226
      "The receipt digest is not a 32-byte hex hash, so the receipt cannot be dereferenced and re-verified.",
227
    );
228
  }
229
230
  const expiresAt = parsedTime(lease.expires_at);
231
  const settledAt = parsedTime(closeout.settled_at);
232
  if (expiresAt === undefined || settledAt === undefined) {
233
    return unsettled(
234
      lease.job_id,
235
      "lease_expired",
236
      "The lease window could not be read, so the closeout cannot be placed inside it.",
237
    );
238
  }
239
  if (settledAt > expiresAt) {
240
    return unsettled(
241
      lease.job_id,
242
      "lease_expired",
243
      `The job closed out at ${closeout.settled_at}, after the lease expired at ${lease.expires_at}.`,
244
    );
245
  }
246
247
  if (closeout.quotedAmountMsats !== lease.price_msats) {
248
    return unsettled(
249
      lease.job_id,
250
      "price_mismatch",
251
      `The receipt quotes ${closeout.quotedAmountMsats} msats but the lease priced the job at ${lease.price_msats} msats.`,
252
    );
253
  }
254
255
  return {
256
    schema: "openagents.provider_settlement.v1",
257
    job_id: lease.job_id,
258
    state: "settled",
259
    earned_msats: closeout.quotedAmountMsats,
260
    reason: `Verified by ${closeout.verificationCommandRef} with evidence ${closeout.testRef}, closed out by ${closeout.platformCloseoutRef}. Accrued, not paid: no payout rail is connected.`,
261
    payout_rail: "not_connected",
262
    custody: "none",
263
    receipt_ref: closeout.receiptRef,
264
  };
265
};
packages/openagents-cli/test/provider-command.test.ts added +162

@@ -0,0 +1,162 @@

1
import { mkdtempSync, writeFileSync } from "node:fs";
2
import { tmpdir } from "node:os";
3
import { join } from "node:path";
4
5
import * as NodeServices from "@effect/platform-node/NodeServices";
6
import { Effect, Layer } from "effect";
7
import { describe, expect, it } from "vitest";
8
9
import { runCliWith } from "../src/cli.js";
10
import { credentialStoreUnavailableLayer } from "../src/credential-store.js";
11
import { environmentLayerFromValues } from "../src/environment.js";
12
import { outputTestLayer, type OutputDocument, type OutputMode } from "../src/output.js";
13
import { persistedConfigurationTestLayer } from "../src/persisted-configuration.js";
14
import { terminalSessionTestLayer } from "../src/terminal-session.js";
15
16
interface Written {
17
  readonly document: OutputDocument;
18
  readonly mode: OutputMode;
19
}
20
21
const harness = () => {
22
  const written: Array<Written> = [];
23
  const layer = Layer.mergeAll(
24
    NodeServices.layer,
25
    environmentLayerFromValues({}),
26
    persistedConfigurationTestLayer({}),
27
    terminalSessionTestLayer(false),
28
    credentialStoreUnavailableLayer,
29
    outputTestLayer((document, mode) =>
30
      Effect.sync(() => {
31
        written.push({ document, mode });
32
      }),
33
    ),
34
  );
35
  const run = (argv: ReadonlyArray<string>) =>
36
    Effect.runPromise(
37
      runCliWith([...argv]).pipe(Effect.provide(layer)) as Effect.Effect<void, unknown>,
38
    );
39
  const fail = (argv: ReadonlyArray<string>) =>
40
    Effect.runPromise(
41
      runCliWith([...argv]).pipe(Effect.provide(layer), Effect.flip) as Effect.Effect<
42
        unknown,
43
        unknown
44
      >,
45
    );
46
  return { run, fail, written };
47
};
48
49
const DIGEST = "b".repeat(64);
50
const PROVIDER = "npub-provider-0000000000000000000000000000";
51
52
const workspace = () => {
53
  const root = mkdtempSync(join(tmpdir(), "openagents-provider-"));
54
  const write = (name: string, value: unknown) => {
55
    const path = join(root, name);
56
    writeFileSync(path, JSON.stringify(value, undefined, 2), "utf8");
57
    return path;
58
  };
59
  const leasePath = write("lease.json", {
60
    job_id: "job-9f2c",
61
    lane: "validator_replay",
62
    provider: PROVIDER,
63
    price_msats: 1_000,
64
    expires_at: "2026-08-25T18:00:00.000Z",
65
  });
66
  const closeoutPath = write("closeout.json", {
67
    receiptRef: `lbr-closeout:job-9f2c:${DIGEST}`,
68
    requestId: "job-9f2c",
69
    requesterPubkey: "npub-requester-1111111111111111111111111111",
70
    providerPubkey: PROVIDER,
71
    quotedAmountMsats: 1_000,
72
    verificationCommandRef: "verify:pnpm-run-check-fast",
73
    testRef: "test:run-4471-passed",
74
    platformCloseoutRef: "platform-closeout:2026-08-25/job-9f2c",
75
    digest: DIGEST,
76
    settled_at: "2026-08-25T17:30:00.000Z",
77
  });
78
  const unverifiedPath = write("unverified.json", {
79
    receiptRef: `lbr-closeout:job-9f2c:${DIGEST}`,
80
    requestId: "job-9f2c",
81
    requesterPubkey: "npub-requester-1111111111111111111111111111",
82
    providerPubkey: PROVIDER,
83
    quotedAmountMsats: 1_000,
84
    verificationCommandRef: "",
85
    testRef: "",
86
    platformCloseoutRef: "platform-closeout:2026-08-25/job-9f2c",
87
    digest: DIGEST,
88
    settled_at: "2026-08-25T17:30:00.000Z",
89
  });
90
  return { root, leasePath, closeoutPath, unverifiedPath, write };
91
};
92
93
describe("openagents provider settle", () => {
94
  it("settles a job whose closeout receipt proves the work", async () => {
95
    const { closeoutPath, leasePath } = workspace();
96
    const { run, written } = harness();
97
98
    await run(["--json", "provider", "settle", "--lease", leasePath, "--closeout", closeoutPath]);
99
100
    const value = written[0]?.document.value as Record<string, unknown>;
101
    expect(value["schema"]).toBe("openagents.provider_settlement.v1");
102
    expect(value["state"]).toBe("settled");
103
    expect(value["earned_msats"]).toBe(1_000);
104
  });
105
106
  it("earns nothing when the run offers no closeout receipt", async () => {
107
    const { leasePath } = workspace();
108
    const { run, written } = harness();
109
110
    await run(["--json", "provider", "settle", "--lease", leasePath]);
111
112
    const value = written[0]?.document.value as Record<string, unknown>;
113
    expect(value["state"]).toBe("unsettled");
114
    expect(value["earned_msats"]).toBe(0);
115
    expect(value["refusal"]).toBe("no_closeout");
116
  });
117
118
  it("earns nothing when the receipt shows nothing verified the work", async () => {
119
    const { leasePath, unverifiedPath } = workspace();
120
    const { run, written } = harness();
121
122
    await run(["--json", "provider", "settle", "--lease", leasePath, "--closeout", unverifiedPath]);
123
124
    const value = written[0]?.document.value as Record<string, unknown>;
125
    expect(value["earned_msats"]).toBe(0);
126
    expect(value["refusal"]).toBe("work_not_verified");
127
  });
128
129
  it("never claims a payout rail or custody", async () => {
130
    const { closeoutPath, leasePath } = workspace();
131
    const { run, written } = harness();
132
133
    await run(["--json", "provider", "settle", "--lease", leasePath, "--closeout", closeoutPath]);
134
135
    const value = written[0]?.document.value as Record<string, unknown>;
136
    expect(value["payout_rail"]).toBe("not_connected");
137
    expect(value["custody"]).toBe("none");
138
  });
139
140
  it("refuses a lease file that is missing the fields the gate needs", async () => {
141
    const { write } = workspace();
142
    const broken = write("broken.json", { job_id: "job-9f2c", price_msats: 1_000 });
143
    const { fail } = harness();
144
145
    const error = await fail(["provider", "settle", "--lease", broken]);
146
147
    expect(String((error as { message?: string }).message)).toContain("missing");
148
  });
149
150
  it("refuses a lease path that does not parse as JSON", async () => {
151
    const { fail } = harness();
152
153
    const error = await fail([
154
      "provider",
155
      "settle",
156
      "--lease",
157
      join(tmpdir(), "openagents-provider-absent.json"),
158
    ]);
159
160
    expect(String((error as { message?: string }).message)).toContain("could not be read as JSON");
161
  });
162
});
packages/openagents-cli/test/provider-settlement.test.ts added +160

@@ -0,0 +1,160 @@

1
import { describe, expect, it } from "vitest";
2
3
import {
4
  settleLease,
5
  type LaborCloseoutReceipt,
6
  type ProviderLease,
7
} from "../src/provider-settlement.js";
8
9
const PROVIDER = "npub-provider-0000000000000000000000000000";
10
const REQUESTER = "npub-requester-1111111111111111111111111111";
11
const DIGEST = "a".repeat(64);
12
13
const lease = (overrides: Partial<ProviderLease> = {}): ProviderLease => ({
14
  job_id: "job-9f2c",
15
  lane: "validator_replay",
16
  provider: PROVIDER,
17
  price_msats: 1_000,
18
  expires_at: "2026-08-25T18:00:00.000Z",
19
  ...overrides,
20
});
21
22
/** A receipt that clears every gate, so each test can break exactly one. */
23
const closeout = (overrides: Partial<LaborCloseoutReceipt> = {}): LaborCloseoutReceipt => ({
24
  receiptRef: `lbr-closeout:job-9f2c:${DIGEST}`,
25
  requestId: "job-9f2c",
26
  requesterPubkey: REQUESTER,
27
  providerPubkey: PROVIDER,
28
  quotedAmountMsats: 1_000,
29
  verificationCommandRef: "verify:pnpm-run-check-fast",
30
  testRef: "test:run-4471-passed",
31
  platformCloseoutRef: "platform-closeout:2026-08-25/job-9f2c",
32
  digest: DIGEST,
33
  settled_at: "2026-08-25T17:30:00.000Z",
34
  ...overrides,
35
});
36
37
describe("provider settlement: payment follows a proof", () => {
38
  it("pays a job whose closeout receipt carries verification, evidence, and platform closeout", () => {
39
    const decision = settleLease(lease(), closeout());
40
41
    expect(decision.state).toBe("settled");
42
    expect(decision.earned_msats).toBe(1_000);
43
    expect(decision.refusal).toBeUndefined();
44
    expect(decision.receipt_ref).toBe(`lbr-closeout:job-9f2c:${DIGEST}`);
45
  });
46
47
  // The headline gate. Everything else in this file is a way of getting here
48
  // by a different road: without a receipt that proves the work, there is no
49
  // amount to be owed.
50
  it("earns nothing when no closeout receipt covers the job", () => {
51
    const decision = settleLease(lease());
52
53
    expect(decision.state).toBe("unsettled");
54
    expect(decision.earned_msats).toBe(0);
55
    expect(decision.refusal).toBe("no_closeout");
56
  });
57
58
  it("earns nothing when the receipt names no verification command", () => {
59
    const decision = settleLease(lease(), closeout({ verificationCommandRef: "" }));
60
61
    expect(decision.earned_msats).toBe(0);
62
    expect(decision.refusal).toBe("work_not_verified");
63
  });
64
65
  it("earns nothing when the verification produced no evidence", () => {
66
    const decision = settleLease(lease(), closeout({ testRef: "   " }));
67
68
    expect(decision.earned_msats).toBe(0);
69
    expect(decision.refusal).toBe("work_not_verified");
70
  });
71
72
  it("earns nothing when no platform closeout backs the receipt", () => {
73
    const decision = settleLease(lease(), closeout({ platformCloseoutRef: "" }));
74
75
    expect(decision.earned_msats).toBe(0);
76
    expect(decision.refusal).toBe("no_settlement_authority");
77
  });
78
79
  it("earns nothing when the provider is also the requester", () => {
80
    const decision = settleLease(lease(), closeout({ requesterPubkey: PROVIDER }));
81
82
    expect(decision.earned_msats).toBe(0);
83
    expect(decision.refusal).toBe("self_dealt");
84
  });
85
86
  it("earns nothing when the receipt closes out a different job", () => {
87
    const decision = settleLease(lease(), closeout({ requestId: "job-other" }));
88
89
    expect(decision.earned_msats).toBe(0);
90
    expect(decision.refusal).toBe("closeout_job_mismatch");
91
  });
92
93
  it("earns nothing when the receipt credits a different provider", () => {
94
    const decision = settleLease(lease(), closeout({ providerPubkey: "npub-somebody-else" }));
95
96
    expect(decision.earned_msats).toBe(0);
97
    expect(decision.refusal).toBe("closeout_provider_mismatch");
98
  });
99
100
  it("earns nothing when the receipt is not content-addressable", () => {
101
    const decision = settleLease(lease(), closeout({ digest: "not-a-sha256" }));
102
103
    expect(decision.earned_msats).toBe(0);
104
    expect(decision.refusal).toBe("receipt_not_addressable");
105
  });
106
107
  it("earns nothing when the work closed out after the lease expired", () => {
108
    const decision = settleLease(lease(), closeout({ settled_at: "2026-08-25T18:00:01.000Z" }));
109
110
    expect(decision.earned_msats).toBe(0);
111
    expect(decision.refusal).toBe("lease_expired");
112
  });
113
114
  it("cannot be inflated by a receipt that quotes more than the lease priced", () => {
115
    const decision = settleLease(lease(), closeout({ quotedAmountMsats: 5_000_000 }));
116
117
    expect(decision.earned_msats).toBe(0);
118
    expect(decision.refusal).toBe("price_mismatch");
119
  });
120
121
  it("earns nothing on an unpriced lease even with a clean receipt", () => {
122
    const decision = settleLease(lease({ price_msats: 0 }), closeout({ quotedAmountMsats: 0 }));
123
124
    expect(decision.earned_msats).toBe(0);
125
    expect(decision.refusal).toBe("price_not_payable");
126
  });
127
});
128
129
describe("provider settlement: presence is never paid", () => {
130
  // The lesson VP-1's deleted loop and the do-not-build register both carry:
131
  // being online is not work. There is no argument to `settleLease` that
132
  // uptime, advertised capacity, or a live lease could reach, and this pins
133
  // that shape so a later refactor cannot quietly add one.
134
  it("takes only a lease and a receipt, so uptime has nowhere to enter", () => {
135
    expect(settleLease.length).toBe(2);
136
  });
137
138
  it("pays a provider that held a live lease all day but never closed out nothing", () => {
139
    const allDay = lease({ expires_at: "2026-12-31T23:59:59.000Z" });
140
141
    expect(settleLease(allDay).earned_msats).toBe(0);
142
    expect(settleLease(allDay).refusal).toBe("no_closeout");
143
  });
144
});
145
146
describe("provider settlement: accrual, not custody", () => {
147
  it("reports no connected payout rail and no custody on a settled job", () => {
148
    const decision = settleLease(lease(), closeout());
149
150
    expect(decision.payout_rail).toBe("not_connected");
151
    expect(decision.custody).toBe("none");
152
  });
153
154
  it("reports no connected payout rail and no custody on a refusal too", () => {
155
    const decision = settleLease(lease());
156
157
    expect(decision.payout_rail).toBe("not_connected");
158
    expect(decision.custody).toBe("none");
159
  });
160
});

This page updates live while a promote is in flight · changelog