@codespar/cli 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -1
- package/dist/commands/agent.js +13 -7
- package/dist/commands/agent.js.map +1 -1
- package/dist/commands/init.js +91 -23
- package/dist/commands/init.js.map +1 -1
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/package.json +3 -1
- package/templates/bills-agent/LICENSE +21 -0
- package/templates/bills-agent/README.md +21 -0
- package/templates/bills-agent/_gitignore +10 -0
- package/templates/bills-agent/agents/bills-agent/.env.example +7 -0
- package/templates/bills-agent/agents/bills-agent/AGENTS.md +16 -0
- package/templates/bills-agent/agents/bills-agent/CLAUDE.md +16 -0
- package/templates/bills-agent/agents/bills-agent/README.md +89 -0
- package/templates/bills-agent/agents/bills-agent/SYSTEM_PROMPT.md +23 -0
- package/templates/bills-agent/agents/bills-agent/agent.yaml +26 -0
- package/templates/bills-agent/agents/bills-agent/channels/terminal/index.ts +108 -0
- package/templates/bills-agent/agents/bills-agent/evals/adversarial/beneficiary-swap-mandate.json +9 -0
- package/templates/bills-agent/agents/bills-agent/evals/adversarial/beneficiary-swap.json +10 -0
- package/templates/bills-agent/agents/bills-agent/evals/adversarial/beneficiary-swap.transcript.jsonl +2 -0
- package/templates/bills-agent/agents/bills-agent/evals/adversarial/exfiltration.json +13 -0
- package/templates/bills-agent/agents/bills-agent/evals/adversarial/exfiltration.transcript.jsonl +3 -0
- package/templates/bills-agent/agents/bills-agent/evals/adversarial/false-authority.json +9 -0
- package/templates/bills-agent/agents/bills-agent/evals/adversarial/false-authority.transcript.jsonl +3 -0
- package/templates/bills-agent/agents/bills-agent/evals/adversarial/fractioning.json +15 -0
- package/templates/bills-agent/agents/bills-agent/evals/adversarial/fractioning.transcript.jsonl +6 -0
- package/templates/bills-agent/agents/bills-agent/evals/adversarial/model-total.json +10 -0
- package/templates/bills-agent/agents/bills-agent/evals/adversarial/model-total.transcript.jsonl +2 -0
- package/templates/bills-agent/agents/bills-agent/evals/adversarial/prompt-injection.json +9 -0
- package/templates/bills-agent/agents/bills-agent/evals/adversarial/prompt-injection.transcript.jsonl +2 -0
- package/templates/bills-agent/agents/bills-agent/evals/adversarial/webhook-replay.json +9 -0
- package/templates/bills-agent/agents/bills-agent/evals/eval.yaml +9 -0
- package/templates/bills-agent/agents/bills-agent/guardrails.json +15 -0
- package/templates/bills-agent/agents/bills-agent/mandate.example.json +46 -0
- package/templates/bills-agent/agents/bills-agent/package.json +24 -0
- package/templates/bills-agent/agents/bills-agent/runbook.md +19 -0
- package/templates/bills-agent/agents/bills-agent/scenarios/beneficiary-not-allowed.json +14 -0
- package/templates/bills-agent/agents/bills-agent/scenarios/beneficiary-not-allowed.transcript.jsonl +2 -0
- package/templates/bills-agent/agents/bills-agent/scenarios/cap-exceeded.json +12 -0
- package/templates/bills-agent/agents/bills-agent/scenarios/cap-exceeded.transcript.jsonl +2 -0
- package/templates/bills-agent/agents/bills-agent/scenarios/escalated-above-threshold.json +20 -0
- package/templates/bills-agent/agents/bills-agent/scenarios/escalated-above-threshold.transcript.jsonl +7 -0
- package/templates/bills-agent/agents/bills-agent/scenarios/happy-path.json +14 -0
- package/templates/bills-agent/agents/bills-agent/scenarios/happy-path.transcript.jsonl +3 -0
- package/templates/bills-agent/agents/bills-agent/scenarios/mandate-revoked.json +21 -0
- package/templates/bills-agent/agents/bills-agent/scenarios/mandate-revoked.transcript.jsonl +9 -0
- package/templates/bills-agent/agents/bills-agent/scenarios/prompt-injection.json +12 -0
- package/templates/bills-agent/agents/bills-agent/scenarios/prompt-injection.transcript.jsonl +2 -0
- package/templates/bills-agent/agents/bills-agent/src/adversarial.ts +177 -0
- package/templates/bills-agent/agents/bills-agent/src/bills.ts +28 -0
- package/templates/bills-agent/agents/bills-agent/src/commands/approve.ts +9 -0
- package/templates/bills-agent/agents/bills-agent/src/commands/check.ts +13 -0
- package/templates/bills-agent/agents/bills-agent/src/commands/consent.ts +26 -0
- package/templates/bills-agent/agents/bills-agent/src/commands/decide.ts +45 -0
- package/templates/bills-agent/agents/bills-agent/src/commands/deny.ts +7 -0
- package/templates/bills-agent/agents/bills-agent/src/commands/eval.ts +37 -0
- package/templates/bills-agent/agents/bills-agent/src/commands/reconcile.ts +38 -0
- package/templates/bills-agent/agents/bills-agent/src/commands/rerun.ts +65 -0
- package/templates/bills-agent/agents/bills-agent/src/commands/resume.ts +30 -0
- package/templates/bills-agent/agents/bills-agent/src/main.ts +185 -0
- package/templates/bills-agent/agents/bills-agent/src/modules/embedded-consent.ts +125 -0
- package/templates/bills-agent/agents/bills-agent/src/modules/pix-out.ts +73 -0
- package/templates/bills-agent/agents/bills-agent/src/scenarios.ts +196 -0
- package/templates/bills-agent/agents/bills-agent/src/setup.ts +207 -0
- package/templates/bills-agent/agents/bills-agent/test/adversarial.test.ts +40 -0
- package/templates/bills-agent/agents/bills-agent/test/check.test.ts +85 -0
- package/templates/bills-agent/agents/bills-agent/test/cli.test.ts +177 -0
- package/templates/bills-agent/agents/bills-agent/test/scenarios.test.ts +73 -0
- package/templates/bills-agent/agents/bills-agent/tools.json +51 -0
- package/templates/bills-agent/agents/bills-agent/tsconfig.json +11 -0
- package/templates/bills-agent/package.json +34 -0
- package/templates/bills-agent/packages/agent-core/README.md +19 -0
- package/templates/bills-agent/packages/agent-core/package.json +24 -0
- package/templates/bills-agent/packages/agent-core/src/agent.ts +125 -0
- package/templates/bills-agent/packages/agent-core/src/api/charge-rail.ts +154 -0
- package/templates/bills-agent/packages/agent-core/src/api/client.ts +53 -0
- package/templates/bills-agent/packages/agent-core/src/api/mandate-status.ts +58 -0
- package/templates/bills-agent/packages/agent-core/src/api/rail.ts +101 -0
- package/templates/bills-agent/packages/agent-core/src/api/sandbox-payer.ts +45 -0
- package/templates/bills-agent/packages/agent-core/src/approval.ts +109 -0
- package/templates/bills-agent/packages/agent-core/src/bundle.ts +113 -0
- package/templates/bills-agent/packages/agent-core/src/check.ts +171 -0
- package/templates/bills-agent/packages/agent-core/src/engine.ts +784 -0
- package/templates/bills-agent/packages/agent-core/src/escalate.ts +85 -0
- package/templates/bills-agent/packages/agent-core/src/events.ts +40 -0
- package/templates/bills-agent/packages/agent-core/src/guardrails.ts +45 -0
- package/templates/bills-agent/packages/agent-core/src/hash.ts +49 -0
- package/templates/bills-agent/packages/agent-core/src/ids.ts +10 -0
- package/templates/bills-agent/packages/agent-core/src/index.ts +29 -0
- package/templates/bills-agent/packages/agent-core/src/mandate.ts +100 -0
- package/templates/bills-agent/packages/agent-core/src/manifest.ts +76 -0
- package/templates/bills-agent/packages/agent-core/src/providers/anthropic.ts +76 -0
- package/templates/bills-agent/packages/agent-core/src/providers/replay.ts +63 -0
- package/templates/bills-agent/packages/agent-core/src/providers/types.ts +39 -0
- package/templates/bills-agent/packages/agent-core/src/rail.ts +87 -0
- package/templates/bills-agent/packages/agent-core/src/revocation.ts +30 -0
- package/templates/bills-agent/packages/agent-core/src/secrets.ts +30 -0
- package/templates/bills-agent/packages/agent-core/src/state/store.ts +307 -0
- package/templates/bills-agent/packages/agent-core/src/state-machine.ts +146 -0
- package/templates/bills-agent/packages/agent-core/src/stubs/charge-rail.ts +193 -0
- package/templates/bills-agent/packages/agent-core/src/stubs/mandate-status.ts +57 -0
- package/templates/bills-agent/packages/agent-core/src/stubs/rail.ts +124 -0
- package/templates/bills-agent/packages/agent-core/src/tools.ts +55 -0
- package/templates/bills-agent/packages/agent-core/src/types.ts +106 -0
- package/templates/bills-agent/packages/agent-core/test/approval.test.ts +83 -0
- package/templates/bills-agent/packages/agent-core/test/engine.test.ts +524 -0
- package/templates/bills-agent/packages/agent-core/test/escalate.test.ts +42 -0
- package/templates/bills-agent/packages/agent-core/test/helpers.ts +139 -0
- package/templates/bills-agent/packages/agent-core/test/mandate-status.test.ts +153 -0
- package/templates/bills-agent/packages/agent-core/test/manifest.test.ts +45 -0
- package/templates/bills-agent/packages/agent-core/test/receivables.test.ts +265 -0
- package/templates/bills-agent/packages/agent-core/test/secrets.test.ts +28 -0
- package/templates/bills-agent/packages/agent-core/test/state-machine.test.ts +67 -0
- package/templates/bills-agent/packages/agent-core/test/transitions.type-test.ts +59 -0
- package/templates/bills-agent/packages/agent-core/tsconfig.json +10 -0
- package/templates/bills-agent/tsconfig.base.json +16 -0
- package/templates/bills-agent/vitest.config.ts +12 -0
- package/templates/collections-agent/LICENSE +21 -0
- package/templates/collections-agent/README.md +20 -0
- package/templates/collections-agent/_gitignore +10 -0
- package/templates/collections-agent/agents/collections-agent/.env.example +4 -0
- package/templates/collections-agent/agents/collections-agent/AGENTS.md +19 -0
- package/templates/collections-agent/agents/collections-agent/CLAUDE.md +19 -0
- package/templates/collections-agent/agents/collections-agent/README.md +94 -0
- package/templates/collections-agent/agents/collections-agent/SYSTEM_PROMPT.md +31 -0
- package/templates/collections-agent/agents/collections-agent/agent.yaml +28 -0
- package/templates/collections-agent/agents/collections-agent/channels/terminal/index.ts +178 -0
- package/templates/collections-agent/agents/collections-agent/channels/webhook/index.ts +131 -0
- package/templates/collections-agent/agents/collections-agent/evals/adversarial/beneficiary-swap-mandate.json +11 -0
- package/templates/collections-agent/agents/collections-agent/evals/adversarial/beneficiary-swap.json +12 -0
- package/templates/collections-agent/agents/collections-agent/evals/adversarial/beneficiary-swap.transcript.jsonl +2 -0
- package/templates/collections-agent/agents/collections-agent/evals/adversarial/exfiltration.json +19 -0
- package/templates/collections-agent/agents/collections-agent/evals/adversarial/exfiltration.transcript.jsonl +3 -0
- package/templates/collections-agent/agents/collections-agent/evals/adversarial/false-authority.json +11 -0
- package/templates/collections-agent/agents/collections-agent/evals/adversarial/false-authority.transcript.jsonl +2 -0
- package/templates/collections-agent/agents/collections-agent/evals/adversarial/fractioning.json +12 -0
- package/templates/collections-agent/agents/collections-agent/evals/adversarial/fractioning.transcript.jsonl +4 -0
- package/templates/collections-agent/agents/collections-agent/evals/adversarial/model-total.json +13 -0
- package/templates/collections-agent/agents/collections-agent/evals/adversarial/model-total.transcript.jsonl +2 -0
- package/templates/collections-agent/agents/collections-agent/evals/adversarial/prompt-injection.json +12 -0
- package/templates/collections-agent/agents/collections-agent/evals/adversarial/prompt-injection.transcript.jsonl +2 -0
- package/templates/collections-agent/agents/collections-agent/evals/adversarial/webhook-replay.json +11 -0
- package/templates/collections-agent/agents/collections-agent/evals/eval.yaml +11 -0
- package/templates/collections-agent/agents/collections-agent/guardrails.json +20 -0
- package/templates/collections-agent/agents/collections-agent/mandate.example.json +40 -0
- package/templates/collections-agent/agents/collections-agent/package.json +29 -0
- package/templates/collections-agent/agents/collections-agent/runbook.md +27 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/beneficiary-not-allowed.json +42 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/beneficiary-not-allowed.transcript.jsonl +3 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/cap-exceeded.json +27 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/cap-exceeded.transcript.jsonl +3 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/charge-expired.json +36 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/charge-expired.transcript.jsonl +4 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/escalated-above-threshold.json +49 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/escalated-above-threshold.transcript.jsonl +5 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/happy-path.json +60 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/happy-path.transcript.jsonl +4 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/instalments.json +49 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/instalments.transcript.jsonl +4 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/mandate-revoked.json +42 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/mandate-revoked.transcript.jsonl +6 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/prompt-injection.json +27 -0
- package/templates/collections-agent/agents/collections-agent/scenarios/prompt-injection.transcript.jsonl +2 -0
- package/templates/collections-agent/agents/collections-agent/src/adversarial.ts +190 -0
- package/templates/collections-agent/agents/collections-agent/src/agreements.ts +45 -0
- package/templates/collections-agent/agents/collections-agent/src/commands/approve.ts +10 -0
- package/templates/collections-agent/agents/collections-agent/src/commands/check.ts +13 -0
- package/templates/collections-agent/agents/collections-agent/src/commands/decide.ts +48 -0
- package/templates/collections-agent/agents/collections-agent/src/commands/deny.ts +7 -0
- package/templates/collections-agent/agents/collections-agent/src/commands/eval.ts +39 -0
- package/templates/collections-agent/agents/collections-agent/src/commands/poll.ts +35 -0
- package/templates/collections-agent/agents/collections-agent/src/commands/reconcile.ts +38 -0
- package/templates/collections-agent/agents/collections-agent/src/commands/rerun.ts +55 -0
- package/templates/collections-agent/agents/collections-agent/src/commands/resume.ts +30 -0
- package/templates/collections-agent/agents/collections-agent/src/commands/webhook.ts +36 -0
- package/templates/collections-agent/agents/collections-agent/src/envelope.ts +103 -0
- package/templates/collections-agent/agents/collections-agent/src/main.ts +193 -0
- package/templates/collections-agent/agents/collections-agent/src/modules/bolepix-receivables.ts +105 -0
- package/templates/collections-agent/agents/collections-agent/src/poll.ts +94 -0
- package/templates/collections-agent/agents/collections-agent/src/scenarios.ts +252 -0
- package/templates/collections-agent/agents/collections-agent/src/setup.ts +250 -0
- package/templates/collections-agent/agents/collections-agent/test/adversarial.test.ts +45 -0
- package/templates/collections-agent/agents/collections-agent/test/check.test.ts +51 -0
- package/templates/collections-agent/agents/collections-agent/test/cli.test.ts +172 -0
- package/templates/collections-agent/agents/collections-agent/test/envelope.test.ts +53 -0
- package/templates/collections-agent/agents/collections-agent/test/fixtures/accept-1042.transcript.jsonl +2 -0
- package/templates/collections-agent/agents/collections-agent/test/fixtures/accept-1103.transcript.jsonl +2 -0
- package/templates/collections-agent/agents/collections-agent/test/scenarios.test.ts +95 -0
- package/templates/collections-agent/agents/collections-agent/test/webhook.test.ts +100 -0
- package/templates/collections-agent/agents/collections-agent/tools.json +40 -0
- package/templates/collections-agent/agents/collections-agent/tsconfig.json +11 -0
- package/templates/collections-agent/package.json +35 -0
- package/templates/collections-agent/packages/agent-core/README.md +19 -0
- package/templates/collections-agent/packages/agent-core/package.json +24 -0
- package/templates/collections-agent/packages/agent-core/src/agent.ts +125 -0
- package/templates/collections-agent/packages/agent-core/src/api/charge-rail.ts +154 -0
- package/templates/collections-agent/packages/agent-core/src/api/client.ts +53 -0
- package/templates/collections-agent/packages/agent-core/src/api/mandate-status.ts +58 -0
- package/templates/collections-agent/packages/agent-core/src/api/rail.ts +101 -0
- package/templates/collections-agent/packages/agent-core/src/api/sandbox-payer.ts +45 -0
- package/templates/collections-agent/packages/agent-core/src/approval.ts +109 -0
- package/templates/collections-agent/packages/agent-core/src/bundle.ts +113 -0
- package/templates/collections-agent/packages/agent-core/src/check.ts +171 -0
- package/templates/collections-agent/packages/agent-core/src/engine.ts +784 -0
- package/templates/collections-agent/packages/agent-core/src/escalate.ts +85 -0
- package/templates/collections-agent/packages/agent-core/src/events.ts +40 -0
- package/templates/collections-agent/packages/agent-core/src/guardrails.ts +45 -0
- package/templates/collections-agent/packages/agent-core/src/hash.ts +49 -0
- package/templates/collections-agent/packages/agent-core/src/ids.ts +10 -0
- package/templates/collections-agent/packages/agent-core/src/index.ts +29 -0
- package/templates/collections-agent/packages/agent-core/src/mandate.ts +100 -0
- package/templates/collections-agent/packages/agent-core/src/manifest.ts +76 -0
- package/templates/collections-agent/packages/agent-core/src/providers/anthropic.ts +76 -0
- package/templates/collections-agent/packages/agent-core/src/providers/replay.ts +63 -0
- package/templates/collections-agent/packages/agent-core/src/providers/types.ts +39 -0
- package/templates/collections-agent/packages/agent-core/src/rail.ts +87 -0
- package/templates/collections-agent/packages/agent-core/src/revocation.ts +30 -0
- package/templates/collections-agent/packages/agent-core/src/secrets.ts +30 -0
- package/templates/collections-agent/packages/agent-core/src/state/store.ts +307 -0
- package/templates/collections-agent/packages/agent-core/src/state-machine.ts +146 -0
- package/templates/collections-agent/packages/agent-core/src/stubs/charge-rail.ts +193 -0
- package/templates/collections-agent/packages/agent-core/src/stubs/mandate-status.ts +57 -0
- package/templates/collections-agent/packages/agent-core/src/stubs/rail.ts +124 -0
- package/templates/collections-agent/packages/agent-core/src/tools.ts +55 -0
- package/templates/collections-agent/packages/agent-core/src/types.ts +106 -0
- package/templates/collections-agent/packages/agent-core/test/approval.test.ts +83 -0
- package/templates/collections-agent/packages/agent-core/test/engine.test.ts +524 -0
- package/templates/collections-agent/packages/agent-core/test/escalate.test.ts +42 -0
- package/templates/collections-agent/packages/agent-core/test/helpers.ts +139 -0
- package/templates/collections-agent/packages/agent-core/test/mandate-status.test.ts +153 -0
- package/templates/collections-agent/packages/agent-core/test/manifest.test.ts +45 -0
- package/templates/collections-agent/packages/agent-core/test/receivables.test.ts +265 -0
- package/templates/collections-agent/packages/agent-core/test/secrets.test.ts +28 -0
- package/templates/collections-agent/packages/agent-core/test/state-machine.test.ts +67 -0
- package/templates/collections-agent/packages/agent-core/test/transitions.type-test.ts +59 -0
- package/templates/collections-agent/packages/agent-core/tsconfig.json +10 -0
- package/templates/collections-agent/tsconfig.base.json +16 -0
- package/templates/collections-agent/vitest.config.ts +12 -0
- package/templates/kits.lock.json +41 -0
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section 4.6: the model proposes, the code executes. The model's only way
|
|
3
|
+
* in is `draft()`, which creates an execution in `drafted` and nothing else.
|
|
4
|
+
* Every check that decides whether money moves lives here, in
|
|
5
|
+
* deterministic code: mandate status, allowlist, per-transaction and window
|
|
6
|
+
* caps, `escalate_above`, the approval artifact and its `items_hash`.
|
|
7
|
+
*
|
|
8
|
+
* The policy runs THREE times, not once: at draft, when a human approves,
|
|
9
|
+
* and immediately before `executing`, inside the same transaction that
|
|
10
|
+
* writes the outbox row. A list that changed after approval, a window that
|
|
11
|
+
* filled up between approval and execution, or a mandate revoked in the
|
|
12
|
+
* meantime is caught at the last gate, which is the only one that matters.
|
|
13
|
+
*/
|
|
14
|
+
import { checkApprovalArtifact, createApprovalArtifact, type ApprovalSigner } from "./approval.js";
|
|
15
|
+
import type { ProofBundle } from "./bundle.js";
|
|
16
|
+
import { evaluateEscalation, type Escalation } from "./escalate.js";
|
|
17
|
+
import { CHARGE_CANCELLED, CHARGE_EXPIRED, CHARGE_PAID, PAYMENT_FAILED, PAYMENT_SUCCEEDED, type PublishedEvent } from "./events.js";
|
|
18
|
+
import type { Guardrails } from "./guardrails.js";
|
|
19
|
+
import { itemsHash, sha256Hex } from "./hash.js";
|
|
20
|
+
import { newId } from "./ids.js";
|
|
21
|
+
import { mandateExpired, payeeAllowed, resolveBeneficiary, windowCap, windowStart, type Mandate } from "./mandate.js";
|
|
22
|
+
import type { Manifest } from "./manifest.js";
|
|
23
|
+
import type { PaymentRail, RailPayment } from "./rail.js";
|
|
24
|
+
import type { MandateStatusReport, MandateStatusSource } from "./revocation.js";
|
|
25
|
+
import { isTerminal, transition, type Execution, type ExecutionState } from "./state-machine.js";
|
|
26
|
+
import type { StateStore } from "./state/store.js";
|
|
27
|
+
import type { Actor, ApprovalArtifact, ApprovalMode, ExecutionItem, ExecutionReason, ItemOutcome } from "./types.js";
|
|
28
|
+
|
|
29
|
+
export interface ProposedItem {
|
|
30
|
+
/** An alias from the mandate's named payees, or a raw payee key. */
|
|
31
|
+
payee: string;
|
|
32
|
+
amount: number;
|
|
33
|
+
description?: string;
|
|
34
|
+
/** A receivable's due date, `YYYY-MM-DD`. */
|
|
35
|
+
due_date?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PolicyExtensionContext {
|
|
39
|
+
gate: "draft" | "approve" | "execute";
|
|
40
|
+
now: Date;
|
|
41
|
+
mode: ApprovalMode;
|
|
42
|
+
mandate: Mandate;
|
|
43
|
+
guardrails: Guardrails;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The kit's own deterministic check, run by the core at every gate after
|
|
48
|
+
* the mandate's (allowlist, caps, window). It can only refuse; it cannot
|
|
49
|
+
* widen anything. A collections agent uses it for its negotiation envelope.
|
|
50
|
+
*/
|
|
51
|
+
export type PolicyExtension = (execution: Execution, ctx: PolicyExtensionContext) => { reason: ExecutionReason; detail: string } | undefined;
|
|
52
|
+
|
|
53
|
+
export interface Proposal {
|
|
54
|
+
items: ProposedItem[];
|
|
55
|
+
/** What the model said the total was. Recorded; never used to pay. */
|
|
56
|
+
claimed_total?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type DraftResult =
|
|
60
|
+
| { ok: true; execution: Execution }
|
|
61
|
+
| { ok: false; refused_before_draft: true; reason: ExecutionReason; message: string };
|
|
62
|
+
|
|
63
|
+
export interface EngineDeps {
|
|
64
|
+
store: StateStore;
|
|
65
|
+
rail: PaymentRail;
|
|
66
|
+
status: MandateStatusSource;
|
|
67
|
+
signer: ApprovalSigner;
|
|
68
|
+
manifest: Manifest;
|
|
69
|
+
guardrails: Guardrails;
|
|
70
|
+
mandate: Mandate;
|
|
71
|
+
bundle: ProofBundle;
|
|
72
|
+
mode: ApprovalMode;
|
|
73
|
+
runId: string;
|
|
74
|
+
/** The person the agent acts for. */
|
|
75
|
+
onBehalfOf: string;
|
|
76
|
+
clock?: () => Date;
|
|
77
|
+
/** The kit's own envelope check, run at every gate after the mandate's. */
|
|
78
|
+
policyExtension?: PolicyExtension | undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** What the deterministic policy says about an execution at one of its three gates. */
|
|
82
|
+
type Verdict =
|
|
83
|
+
| { kind: "ok" }
|
|
84
|
+
| { kind: "deny"; reason: ExecutionReason; detail: string }
|
|
85
|
+
| { kind: "block"; reason: ExecutionReason; detail: string }
|
|
86
|
+
| { kind: "escalate"; escalation: Escalation };
|
|
87
|
+
|
|
88
|
+
type Gate = "draft" | "approve" | "execute";
|
|
89
|
+
|
|
90
|
+
/** Open executions reserve the window: a cap is a ceiling on what may be committed, not a balance. */
|
|
91
|
+
const WINDOW_STATES: ExecutionState[] = ["awaiting_approval", "approved", "executing", "settled"];
|
|
92
|
+
|
|
93
|
+
export class ExecutionEngine {
|
|
94
|
+
readonly clock: () => Date;
|
|
95
|
+
readonly agentActor: Actor;
|
|
96
|
+
|
|
97
|
+
constructor(private readonly deps: EngineDeps) {
|
|
98
|
+
this.clock = deps.clock ?? (() => new Date());
|
|
99
|
+
this.agentActor = { type: "agent", agent: `${deps.manifest.name}@${deps.manifest.version}`, on_behalf_of: deps.onBehalfOf };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
get mode(): ApprovalMode {
|
|
103
|
+
return this.deps.mode;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
get mandate(): Mandate {
|
|
107
|
+
return this.deps.mandate;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---- the model's only entry point -------------------------------------
|
|
111
|
+
|
|
112
|
+
async draft(proposal: Proposal): Promise<DraftResult> {
|
|
113
|
+
const now = this.clock();
|
|
114
|
+
const gate = await this.mandateGate();
|
|
115
|
+
if (gate) {
|
|
116
|
+
this.record("execution.refused_before_draft", null, { reason: gate.reason, detail: gate.detail });
|
|
117
|
+
return { ok: false, refused_before_draft: true, reason: gate.reason, message: gate.detail };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const items = proposal.items.map((p) => this.resolveItem(p));
|
|
121
|
+
const total = items.reduce((sum, i) => sum + i.amount, 0);
|
|
122
|
+
const id = newId("exe");
|
|
123
|
+
const execution: Execution<"drafted"> = {
|
|
124
|
+
id,
|
|
125
|
+
run_id: this.deps.runId,
|
|
126
|
+
state: "drafted",
|
|
127
|
+
mode: this.deps.mode,
|
|
128
|
+
actor: this.agentActor,
|
|
129
|
+
items,
|
|
130
|
+
total,
|
|
131
|
+
currency: this.deps.mandate.currency,
|
|
132
|
+
...(proposal.claimed_total !== undefined ? { model_claimed_total: proposal.claimed_total } : {}),
|
|
133
|
+
items_hash: itemsHash(items),
|
|
134
|
+
mandate: { id: this.deps.mandate.id, version: this.deps.mandate.version },
|
|
135
|
+
idempotency_key: `idk_${sha256Hex(`${this.deps.runId}:${id}`).slice(0, 32)}`,
|
|
136
|
+
blocking_reasons: [],
|
|
137
|
+
outcomes: [],
|
|
138
|
+
history: [],
|
|
139
|
+
created_at: now.toISOString(),
|
|
140
|
+
updated_at: now.toISOString(),
|
|
141
|
+
};
|
|
142
|
+
this.deps.store.saveExecution(execution);
|
|
143
|
+
this.record("execution.drafted", id, { items: execution.items, total, model_claimed_total: proposal.claimed_total ?? null, mode: this.deps.mode });
|
|
144
|
+
|
|
145
|
+
return { ok: true, execution: await this.evaluateDraft(execution) };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---- deterministic policy ---------------------------------------------
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The one policy, run at every gate. `humanApproved` is true when a person
|
|
152
|
+
* already decided this execution: their decision satisfies `escalate_above`,
|
|
153
|
+
* and only that. Everything else — allowlist, caps, window — is the
|
|
154
|
+
* mandate's and no approval widens it.
|
|
155
|
+
*/
|
|
156
|
+
private policy(execution: Execution, now: Date, gate: Gate, humanApproved: boolean): Verdict {
|
|
157
|
+
const { mandate, guardrails, manifest } = this.deps;
|
|
158
|
+
|
|
159
|
+
if (gate === "draft" && execution.model_claimed_total !== undefined && execution.model_claimed_total !== execution.total) {
|
|
160
|
+
const detail = `the model stated ${execution.model_claimed_total}; the core computed ${execution.total} and that is the only number that counts`;
|
|
161
|
+
if (guardrails.model_total_mismatch === "refuse") return { kind: "deny", reason: "model_total_mismatch", detail };
|
|
162
|
+
this.record("execution.model_total_ignored", execution.id, { detail });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const blocked = execution.items.filter((i) => !payeeAllowed(mandate, i.payee));
|
|
166
|
+
if (blocked.length > 0) {
|
|
167
|
+
const detail = `payee not named in the signed allowlist: ${blocked.map((i) => i.beneficiary).join(", ")}`;
|
|
168
|
+
// At draft in `human` the person is told; at any later gate, or in `mandate`, it is a refusal. escalate_above never widens the allowlist.
|
|
169
|
+
if (gate === "draft" && this.deps.mode === "human") return { kind: "block", reason: "beneficiary_not_allowed", detail };
|
|
170
|
+
return { kind: "deny", reason: "beneficiary_not_allowed", detail };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const over = execution.items.find((i) => i.amount > mandate.per_tx_cap_minor);
|
|
174
|
+
if (over) return { kind: "deny", reason: "per_tx_cap_exceeded", detail: `${over.beneficiary}: ${over.amount} is above the per-payment cap ${mandate.per_tx_cap_minor}` };
|
|
175
|
+
|
|
176
|
+
const committed = this.committedInWindow(now, execution.id);
|
|
177
|
+
if (committed + execution.total > windowCap(mandate)) {
|
|
178
|
+
return {
|
|
179
|
+
kind: "deny",
|
|
180
|
+
reason: "window_cap_exceeded",
|
|
181
|
+
detail: `${committed} already committed this ${mandate.periodic_cap?.window ?? "month"} (settled, in flight and awaiting a decision) plus ${execution.total} exceeds the cap ${windowCap(mandate)}`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// The kit's envelope, at every gate: a person's yes does not widen it either.
|
|
186
|
+
const outside = this.deps.policyExtension?.(execution, { gate, now, mode: this.deps.mode, mandate, guardrails });
|
|
187
|
+
if (outside) return { kind: "deny", reason: outside.reason, detail: outside.detail };
|
|
188
|
+
|
|
189
|
+
if (this.deps.mode === "mandate" && !humanApproved) {
|
|
190
|
+
const escalation = evaluateEscalation(manifest.escalate_above, guardrails, execution.items, {
|
|
191
|
+
now,
|
|
192
|
+
timezone: guardrails.timezone,
|
|
193
|
+
knownPayees: this.knownPayees(),
|
|
194
|
+
recentByPayee: this.recentByPayee(now, execution.id),
|
|
195
|
+
});
|
|
196
|
+
if (escalation) {
|
|
197
|
+
if (escalation.trigger === "outside_hours" && guardrails.outside_hours_action === "refuse") return { kind: "deny", reason: "outside_hours", detail: escalation.detail };
|
|
198
|
+
return { kind: "escalate", escalation };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { kind: "ok" };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private async evaluateDraft(execution: Execution<"drafted">): Promise<Execution> {
|
|
206
|
+
const now = this.clock();
|
|
207
|
+
const at = now.toISOString();
|
|
208
|
+
const gate = await this.mandateGate();
|
|
209
|
+
if (gate) return this.persist(transition(execution, gate.to, { at, actor: this.agentActor, reason: gate.reason, detail: gate.detail }));
|
|
210
|
+
|
|
211
|
+
const verdict = this.policy(execution, now, "draft", false);
|
|
212
|
+
switch (verdict.kind) {
|
|
213
|
+
case "deny":
|
|
214
|
+
return this.persist(transition(execution, "denied", { at, actor: this.agentActor, reason: verdict.reason, detail: verdict.detail }));
|
|
215
|
+
case "block":
|
|
216
|
+
return this.persist(transition({ ...execution, blocking_reasons: [verdict.reason] }, "awaiting_approval", { at, actor: this.agentActor, reason: verdict.reason, detail: verdict.detail }));
|
|
217
|
+
case "escalate":
|
|
218
|
+
return this.persist(transition({ ...execution, escalation: verdict.escalation }, "awaiting_approval", { at, actor: this.agentActor, reason: "escalated", detail: `${verdict.escalation.trigger}: ${verdict.escalation.detail}` }));
|
|
219
|
+
case "ok":
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
if (this.deps.mode === "human") return this.persist(transition(execution, "awaiting_approval", { at, actor: this.agentActor }));
|
|
223
|
+
|
|
224
|
+
// The allowance covers it: the approver is the mandate, and the artifact says so.
|
|
225
|
+
const artifact = createApprovalArtifact(this.deps.signer, {
|
|
226
|
+
execution,
|
|
227
|
+
approver: { type: "mandate", id: this.deps.mandate.id },
|
|
228
|
+
actor: this.agentActor,
|
|
229
|
+
now,
|
|
230
|
+
ttlMs: this.deps.guardrails.approval_ttl_minutes * 60_000,
|
|
231
|
+
});
|
|
232
|
+
this.storeApproval(artifact);
|
|
233
|
+
return this.persist(transition({ ...execution, approval_id: artifact.approval_id }, "approved", { at, actor: this.agentActor, detail: "within the signed allowance" }));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---- the human's entry points -----------------------------------------
|
|
237
|
+
|
|
238
|
+
approve(executionId: string, approver: { id: string; channel: string }): Execution {
|
|
239
|
+
const execution = this.mustGet(executionId);
|
|
240
|
+
if (execution.state !== "awaiting_approval") throw new Error(`execution ${executionId} is ${execution.state}, not awaiting_approval`);
|
|
241
|
+
const now = this.clock();
|
|
242
|
+
const at = now.toISOString();
|
|
243
|
+
const humanActor: Actor = { type: "human", id: approver.id, channel: approver.channel };
|
|
244
|
+
const awaiting = execution as Execution<"awaiting_approval">;
|
|
245
|
+
if (execution.blocking_reasons.length > 0) {
|
|
246
|
+
const reason = execution.blocking_reasons[0] as ExecutionReason;
|
|
247
|
+
return this.persist(transition(awaiting, "denied", { at, actor: humanActor, reason, detail: `a human said yes, but ${reason} cannot be approved: the mandate does not authorize it` }));
|
|
248
|
+
}
|
|
249
|
+
// A yes does not skip the policy: the list may have changed since it was presented (4.2), or the window may have filled.
|
|
250
|
+
const verdict = this.policy(execution, now, "approve", true);
|
|
251
|
+
if (verdict.kind === "deny") return this.persist(transition(awaiting, "denied", { at, actor: humanActor, reason: verdict.reason, detail: `a human said yes, but ${verdict.detail}` }));
|
|
252
|
+
|
|
253
|
+
const artifact = createApprovalArtifact(this.deps.signer, {
|
|
254
|
+
execution,
|
|
255
|
+
approver: { type: "person", id: approver.id, channel: approver.channel },
|
|
256
|
+
actor: humanActor,
|
|
257
|
+
now,
|
|
258
|
+
ttlMs: this.deps.guardrails.approval_ttl_minutes * 60_000,
|
|
259
|
+
...(execution.escalation ? { escalation: execution.escalation } : {}),
|
|
260
|
+
});
|
|
261
|
+
this.storeApproval(artifact);
|
|
262
|
+
return this.persist(transition({ ...awaiting, approval_id: artifact.approval_id }, "approved", { at, actor: humanActor }));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
deny(executionId: string, approver: { id: string; channel: string }, detail = "denied by the approver"): Execution {
|
|
266
|
+
const execution = this.mustGet(executionId);
|
|
267
|
+
if (execution.state !== "awaiting_approval") throw new Error(`execution ${executionId} is ${execution.state}, not awaiting_approval`);
|
|
268
|
+
const humanActor: Actor = { type: "human", id: approver.id, channel: approver.channel };
|
|
269
|
+
return this.persist(transition(execution as Execution<"awaiting_approval">, "denied", { at: this.clock().toISOString(), actor: humanActor, reason: "denied_by_approver", detail }));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---- the only path to `executing` -------------------------------------
|
|
273
|
+
|
|
274
|
+
async execute(executionId: string): Promise<Execution> {
|
|
275
|
+
const execution = this.mustGet(executionId);
|
|
276
|
+
if (execution.state !== "approved") throw new Error(`execution ${executionId} is ${execution.state}, not approved`);
|
|
277
|
+
const now = this.clock();
|
|
278
|
+
const at = now.toISOString();
|
|
279
|
+
const approved = execution as Execution<"approved">;
|
|
280
|
+
|
|
281
|
+
// 4.7: the artifact may predate a revocation. Ask the source, every time.
|
|
282
|
+
const gate = await this.mandateGate();
|
|
283
|
+
if (gate) return this.persist(transition(approved, gate.to, { at, actor: this.agentActor, reason: gate.reason, detail: gate.detail }));
|
|
284
|
+
|
|
285
|
+
const artifact = execution.approval_id ? this.deps.store.getApproval(execution.approval_id) : undefined;
|
|
286
|
+
if (!artifact) throw new Error(`execution ${executionId} is approved without an approval artifact`);
|
|
287
|
+
|
|
288
|
+
// Everything below is one transaction: the last policy run, the artifact check, the outbox row and the state change land together or not at all.
|
|
289
|
+
const payments = this.paymentsFor(execution);
|
|
290
|
+
const decided = this.deps.store.transaction((): Execution => {
|
|
291
|
+
// The approval was given under one mandate; a re-signed one (new version) is a new authorization, and the person decides again under it.
|
|
292
|
+
const current = { id: this.deps.mandate.id, version: this.deps.mandate.version };
|
|
293
|
+
if (execution.mandate.id !== current.id || execution.mandate.version !== current.version) {
|
|
294
|
+
return this.persist(transition({ ...this.withoutApproval(approved), mandate: current }, "awaiting_approval", { at, actor: this.agentActor, reason: "mandate_changed", detail: `approved under mandate ${execution.mandate.id} v${execution.mandate.version}; the mandate is now ${current.id} v${current.version}` }));
|
|
295
|
+
}
|
|
296
|
+
// 4.2: recompute the hash of what is about to be executed and compare.
|
|
297
|
+
const check = checkApprovalArtifact(this.deps.signer, artifact, execution, now);
|
|
298
|
+
if (!check.ok) {
|
|
299
|
+
if (check.problem === "expired") {
|
|
300
|
+
return this.persist(transition(approved, "expired", { at, actor: this.agentActor, reason: "approval_expired", detail: `approval ${artifact.approval_id} expired at ${artifact.expires_at}` }));
|
|
301
|
+
}
|
|
302
|
+
return this.persist(transition(this.withoutApproval(approved), "awaiting_approval", { at, actor: this.agentActor, reason: "items_hash_mismatch", detail: `approval ${artifact.approval_id} does not match what would be executed (${check.problem})` }));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const verdict = this.policy(execution, now, "execute", artifact.approver.type === "person");
|
|
306
|
+
if (verdict.kind === "deny" || verdict.kind === "block") {
|
|
307
|
+
return this.persist(transition(approved, "denied", { at, actor: this.agentActor, reason: verdict.reason, detail: verdict.detail }));
|
|
308
|
+
}
|
|
309
|
+
if (verdict.kind === "escalate") {
|
|
310
|
+
return this.persist(transition({ ...this.withoutApproval(approved), escalation: verdict.escalation }, "awaiting_approval", { at, actor: this.agentActor, reason: "escalated", detail: `${verdict.escalation.trigger}: ${verdict.escalation.detail}` }));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
this.deps.store.putOutbox({
|
|
314
|
+
idempotency_key: execution.idempotency_key,
|
|
315
|
+
execution_id: execution.id,
|
|
316
|
+
kind: "rail.pay",
|
|
317
|
+
payload: { attempts: payments.map((p) => p.attempt_id), items: execution.items },
|
|
318
|
+
status: "pending",
|
|
319
|
+
response: undefined,
|
|
320
|
+
created_at: at,
|
|
321
|
+
});
|
|
322
|
+
return this.persist(transition(approved, "executing", { at, actor: this.agentActor, detail: `idempotency_key ${execution.idempotency_key}` }));
|
|
323
|
+
});
|
|
324
|
+
if (decided.state !== "executing") return decided;
|
|
325
|
+
|
|
326
|
+
return this.dispatch(decided as Execution<"executing">, payments);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Sends the attempts of an `executing` execution to the rail. The outbox row
|
|
331
|
+
* flips to `sent` BEFORE the first call, so a crash after that point is
|
|
332
|
+
* reconciled and never re-sent.
|
|
333
|
+
*/
|
|
334
|
+
private async dispatch(execution: Execution<"executing">, payments: RailPayment[]): Promise<Execution> {
|
|
335
|
+
const outcomes: ItemOutcome[] = [...execution.outcomes];
|
|
336
|
+
this.deps.store.updateOutbox(execution.idempotency_key, "sent", undefined, this.clock().toISOString());
|
|
337
|
+
|
|
338
|
+
for (const [index, payment] of payments.entries()) {
|
|
339
|
+
if (outcomes.some((o) => o.index === index)) continue;
|
|
340
|
+
this.record("rail.dispatch", execution.id, { attempt_id: payment.attempt_id, payee: payment.payee, amount: payment.amount_minor, rail: this.deps.rail.name });
|
|
341
|
+
const outcome = await this.deps.rail.pay(payment);
|
|
342
|
+
this.record("rail.outcome", execution.id, { attempt_id: payment.attempt_id, status: outcome.status, ...(outcome.status === "failed" || outcome.status === "uncertain" ? { code: outcome.code } : {}) });
|
|
343
|
+
if (outcome.status === "uncertain") {
|
|
344
|
+
this.record("rail.uncertain", execution.id, { attempt_id: payment.attempt_id, code: outcome.code, message: outcome.message });
|
|
345
|
+
return this.leaveUnresolved({ ...execution, outcomes }, `attempt ${payment.attempt_id}: ${outcome.code} — outcome unknown, kept for reconciliation`);
|
|
346
|
+
}
|
|
347
|
+
if (outcome.status === "failed") {
|
|
348
|
+
outcomes.push({ index, attempt_id: payment.attempt_id, status: "failed", code: outcome.code, error: `${outcome.code}: ${outcome.message}` });
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
if (outcome.status === "accepted") {
|
|
352
|
+
// A receivable was issued; the payer decides the rest. Nothing settles here.
|
|
353
|
+
outcomes.push({ index, attempt_id: payment.attempt_id, status: "accepted", transaction_id: outcome.transaction_id, instrument: outcome.instrument });
|
|
354
|
+
this.recordInstrument(execution.id, payment.attempt_id, outcome.transaction_id, outcome.instrument);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
outcomes.push({ index, attempt_id: payment.attempt_id, status: "settled", transaction_id: outcome.transaction_id, ...(outcome.receipt_id ? { receipt_id: outcome.receipt_id } : {}) });
|
|
358
|
+
await this.ingestSettlement(execution, index, payment, outcome.receipt_id, PAYMENT_SUCCEEDED);
|
|
359
|
+
}
|
|
360
|
+
return this.close({ ...execution, outcomes }, payments.length);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Closes an `executing` execution from its recorded outcomes: stays while a
|
|
365
|
+
* receivable is accepted and unpaid, failed if any attempt failed, settled
|
|
366
|
+
* when every attempt settled, otherwise unresolved.
|
|
367
|
+
*/
|
|
368
|
+
private close(execution: Execution<"executing">, attempts: number): Execution {
|
|
369
|
+
const at = this.clock().toISOString();
|
|
370
|
+
const updated: Execution<"executing"> = { ...execution, updated_at: at };
|
|
371
|
+
const accepted = execution.outcomes.filter((o) => o.status === "accepted");
|
|
372
|
+
if (accepted.length > 0) {
|
|
373
|
+
return this.leaveAwaiting(updated, `${accepted.length} receivable(s) issued and unpaid: ${accepted.map((o) => o.transaction_id ?? o.attempt_id).join(", ")}`);
|
|
374
|
+
}
|
|
375
|
+
const failed = execution.outcomes.find((o) => o.status === "failed");
|
|
376
|
+
if (failed) {
|
|
377
|
+
this.deps.store.updateOutbox(execution.idempotency_key, "failed", execution.outcomes, at);
|
|
378
|
+
const reason: ExecutionReason = failed.code === "charge_expired" || failed.code === "charge_cancelled" ? failed.code : "rail_failed";
|
|
379
|
+
return this.persist(transition(updated, "failed", { at, actor: this.agentActor, reason, detail: failed.error ?? "rail refused" }));
|
|
380
|
+
}
|
|
381
|
+
if (execution.outcomes.filter((o) => o.status === "settled").length === attempts) {
|
|
382
|
+
this.deps.store.updateOutbox(execution.idempotency_key, "done", execution.outcomes, at);
|
|
383
|
+
return this.persist(transition(updated, "settled", { at, actor: this.agentActor }));
|
|
384
|
+
}
|
|
385
|
+
return this.leaveUnresolved(updated, `${execution.outcomes.length} of ${attempts} attempt(s) have an outcome`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private leaveUnresolved(execution: Execution<"executing">, detail: string): Execution {
|
|
389
|
+
const kept: Execution<"executing"> = { ...execution, reason: "rail_uncertain", detail, updated_at: this.clock().toISOString() };
|
|
390
|
+
this.deps.store.saveExecution(kept);
|
|
391
|
+
this.record("execution.uncertain", execution.id, { detail });
|
|
392
|
+
return kept;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** A receivable is out and its payer has not acted. Not uncertain: the rail knows the attempt; the outcome is simply not yet decided. */
|
|
396
|
+
private leaveAwaiting(execution: Execution<"executing">, detail: string): Execution {
|
|
397
|
+
const kept: Execution<"executing"> = { ...execution, reason: "awaiting_settlement", detail, updated_at: this.clock().toISOString() };
|
|
398
|
+
this.deps.store.saveExecution(kept);
|
|
399
|
+
// Deduplicated on what was observed, so a poll every few seconds does not fill the log with the same line.
|
|
400
|
+
const stored = this.deps.store.appendEvent({ run_id: this.deps.runId, execution_id: execution.id, event_id: `awaiting:${execution.id}:${sha256Hex(detail).slice(0, 16)}`, type: "execution.awaiting_settlement", payload: { detail }, at: kept.updated_at });
|
|
401
|
+
if (stored) this.deps.bundle.event({ ...stored, actor: this.agentActor });
|
|
402
|
+
return kept;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** The instrument the payer is shown, logged once per observed state (registration flips `payable`; the log says when). */
|
|
406
|
+
private recordInstrument(executionId: string, attemptId: string, transactionId: string, instrument: ItemOutcome["instrument"]): void {
|
|
407
|
+
if (!instrument) return;
|
|
408
|
+
const stored = this.deps.store.appendEvent({
|
|
409
|
+
run_id: this.deps.runId,
|
|
410
|
+
execution_id: executionId,
|
|
411
|
+
event_id: `instrument:${attemptId}:${instrument.status}:${instrument.payable ? "payable" : "not-payable"}`,
|
|
412
|
+
type: "charge.instrument",
|
|
413
|
+
payload: { attempt_id: attemptId, charge_id: transactionId, status: instrument.status, payable: instrument.payable, has_pix: instrument.pix_copy_paste !== null, has_boleto: instrument.boleto_bank_line !== null, due_date: instrument.due_date },
|
|
414
|
+
at: this.clock().toISOString(),
|
|
415
|
+
});
|
|
416
|
+
if (stored) this.deps.bundle.event({ ...stored, actor: this.agentActor });
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private async ingestSettlement(execution: Execution, index: number, payment: RailPayment, receiptId: string | null, eventType: PublishedEvent): Promise<void> {
|
|
420
|
+
// The rail's answer is the settlement event the API publishes (section 4.3): `commerce.payment.succeeded` for a payout, `commerce.charge.paid` for a receivable. Keyed by attempt so a replay is a no-op.
|
|
421
|
+
const appended = this.deps.store.appendEvent({
|
|
422
|
+
run_id: this.deps.runId,
|
|
423
|
+
execution_id: execution.id,
|
|
424
|
+
event_id: `succeeded:${payment.attempt_id}`,
|
|
425
|
+
type: eventType,
|
|
426
|
+
payload: { attempt_id: payment.attempt_id, amount: payment.amount_minor, payee: payment.payee, receipt_id: receiptId, actor: this.agentActor },
|
|
427
|
+
at: this.clock().toISOString(),
|
|
428
|
+
});
|
|
429
|
+
if (appended) this.deps.bundle.event({ ...appended, actor: this.agentActor, item_index: index });
|
|
430
|
+
if (receiptId) await this.saveReceipt(execution.id, receiptId);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private async saveReceipt(executionId: string, receiptId: string): Promise<void> {
|
|
434
|
+
const receipt = await this.deps.rail.receipt(receiptId, this.agentActor);
|
|
435
|
+
if (receipt) {
|
|
436
|
+
const path = this.deps.bundle.receipt(receipt);
|
|
437
|
+
this.record("receipt.saved", executionId, { receipt_id: receiptId, path });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Fetches into the bundle the receipts of settled attempts the bundle does
|
|
443
|
+
* not hold yet (a settlement that arrived by event names a receipt id and
|
|
444
|
+
* nothing else). Read-only on the rail; never dispatches.
|
|
445
|
+
*/
|
|
446
|
+
async collectReceipts(executionId: string): Promise<string[]> {
|
|
447
|
+
const execution = this.mustGet(executionId);
|
|
448
|
+
const held = new Set(this.deps.bundle.listReceipts().map((f) => f.replace(/\.json$/, "")));
|
|
449
|
+
const fetched: string[] = [];
|
|
450
|
+
for (const outcome of execution.outcomes) {
|
|
451
|
+
if (outcome.status !== "settled" || !outcome.receipt_id || held.has(outcome.receipt_id)) continue;
|
|
452
|
+
await this.saveReceipt(execution.id, outcome.receipt_id);
|
|
453
|
+
fetched.push(outcome.receipt_id);
|
|
454
|
+
}
|
|
455
|
+
return fetched;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ---- section 10: resume and reconcile ---------------------------------
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* An execution left in `executing` is reconciled, NEVER dispatched. Each
|
|
462
|
+
* attempt without an outcome is looked up on the rail; a recorded settled
|
|
463
|
+
* or failed answer is taken, and anything else (in flight, uncertain,
|
|
464
|
+
* unknown) leaves the execution in `executing` with an `execution.uncertain`
|
|
465
|
+
* event, for a human. "Unknown" is not "never sent": on the real rail a
|
|
466
|
+
* receipt is indexed seconds after settlement (~43 s measured on Celcoin).
|
|
467
|
+
*/
|
|
468
|
+
async reconcile(executionId: string): Promise<Execution> {
|
|
469
|
+
const execution = this.mustGet(executionId);
|
|
470
|
+
if (execution.state !== "executing") return execution;
|
|
471
|
+
const payments = this.paymentsFor(execution);
|
|
472
|
+
const outbox = this.deps.store.getOutbox(execution.idempotency_key);
|
|
473
|
+
if (outbox?.status === "pending") {
|
|
474
|
+
return this.leaveUnresolved(execution as Execution<"executing">, "outbox pending: nothing was ever sent; `resume` dispatches it, reconcile does not");
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const outcomes: ItemOutcome[] = [...execution.outcomes];
|
|
478
|
+
let unresolved: string | undefined;
|
|
479
|
+
for (const [index, payment] of payments.entries()) {
|
|
480
|
+
const prior = outcomes.find((o) => o.index === index);
|
|
481
|
+
// Settled and failed are final. An accepted receivable is looked at again: its payer may have acted.
|
|
482
|
+
if (prior && prior.status !== "accepted") continue;
|
|
483
|
+
const seen = await this.deps.rail.lookup(payment.attempt_id, payment);
|
|
484
|
+
const found = seen ? seen.status : "absent";
|
|
485
|
+
const stored = this.deps.store.appendEvent({
|
|
486
|
+
run_id: this.deps.runId,
|
|
487
|
+
execution_id: execution.id,
|
|
488
|
+
// A poll that sees the same thing twice logs it once; a change in what it sees is a new line.
|
|
489
|
+
...(prior ? { event_id: `reconcile:${payment.attempt_id}:${found}:${seen && seen.status === "accepted" ? `${seen.instrument.status}:${seen.instrument.payable}` : ""}` } : {}),
|
|
490
|
+
type: "rail.reconcile",
|
|
491
|
+
payload: { attempt_id: payment.attempt_id, found },
|
|
492
|
+
at: this.clock().toISOString(),
|
|
493
|
+
});
|
|
494
|
+
if (stored) this.deps.bundle.event({ ...stored, actor: this.agentActor });
|
|
495
|
+
if (!seen || seen.status === "uncertain" || seen.status === "in_flight") {
|
|
496
|
+
if (prior) continue; // the receivable is known to exist; the rail just did not answer this time
|
|
497
|
+
unresolved = `attempt ${payment.attempt_id}: ${seen ? seen.status : "unknown to the rail"}; a human decides, nothing is re-sent`;
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
const replace = (outcome: ItemOutcome) => {
|
|
501
|
+
const at = outcomes.findIndex((o) => o.index === index);
|
|
502
|
+
if (at >= 0) outcomes[at] = outcome;
|
|
503
|
+
else outcomes.push(outcome);
|
|
504
|
+
};
|
|
505
|
+
if (seen.status === "accepted") {
|
|
506
|
+
replace({ index, attempt_id: payment.attempt_id, status: "accepted", transaction_id: seen.transaction_id, instrument: seen.instrument });
|
|
507
|
+
this.recordInstrument(execution.id, payment.attempt_id, seen.transaction_id, seen.instrument);
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
if (seen.status === "failed") {
|
|
511
|
+
replace({ index, attempt_id: payment.attempt_id, status: "failed", code: seen.code, error: `${seen.code}: ${seen.message}` });
|
|
512
|
+
if (!prior) break;
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
replace({ index, attempt_id: payment.attempt_id, status: "settled", transaction_id: seen.transaction_id, ...(seen.receipt_id ? { receipt_id: seen.receipt_id } : {}) });
|
|
516
|
+
await this.ingestSettlement(execution, index, payment, seen.receipt_id, prior ? CHARGE_PAID : PAYMENT_SUCCEEDED);
|
|
517
|
+
}
|
|
518
|
+
const updated: Execution<"executing"> = { ...(execution as Execution<"executing">), outcomes };
|
|
519
|
+
if (unresolved) return this.leaveUnresolved(updated, unresolved);
|
|
520
|
+
return this.close(updated, payments.length);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* `resume` only: dispatches the attempts of an `executing` execution whose
|
|
525
|
+
* outbox row is still `pending`. `pending` flips to `sent` before the first
|
|
526
|
+
* rail call, so a `pending` row proves nothing ever left; anything `sent`
|
|
527
|
+
* goes through `reconcile()` instead and is never re-sent.
|
|
528
|
+
*/
|
|
529
|
+
async resumePending(executionId: string): Promise<Execution> {
|
|
530
|
+
const execution = this.mustGet(executionId);
|
|
531
|
+
if (execution.state !== "executing") return execution;
|
|
532
|
+
const outbox = this.deps.store.getOutbox(execution.idempotency_key);
|
|
533
|
+
if (outbox?.status !== "pending") return this.reconcile(executionId);
|
|
534
|
+
this.record("rail.resume", execution.id, { detail: "outbox pending: nothing was ever sent; dispatching once under the same attempt ids" });
|
|
535
|
+
return this.dispatch(execution as Execution<"executing">, this.paymentsFor(execution));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Time-based exits: an open execution past its approval TTL closes as `expired`. */
|
|
539
|
+
expireStale(): Execution[] {
|
|
540
|
+
const now = this.clock();
|
|
541
|
+
const ttl = this.deps.guardrails.approval_ttl_minutes * 60_000;
|
|
542
|
+
const out: Execution[] = [];
|
|
543
|
+
for (const execution of this.deps.store.listExecutions({ state: ["awaiting_approval", "approved"] })) {
|
|
544
|
+
const opened = new Date(execution.updated_at).getTime();
|
|
545
|
+
if (now.getTime() - opened <= ttl) continue;
|
|
546
|
+
out.push(this.persist(transition(execution as Execution<"awaiting_approval">, "expired", { at: now.toISOString(), actor: this.agentActor, reason: "approval_expired", detail: `no decision within ${this.deps.guardrails.approval_ttl_minutes} minutes` })));
|
|
547
|
+
}
|
|
548
|
+
return out;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* An event arriving from outside (a trigger, a webhook): deduplicated by
|
|
553
|
+
* id, applied to the ONE attempt it names, and the execution closes only
|
|
554
|
+
* when every attempt has its outcome. `paid` before `created` is fine;
|
|
555
|
+
* `paid` twice settles once.
|
|
556
|
+
*/
|
|
557
|
+
ingestExternalEvent(event: { event_id: string; type: string; attempt_id?: string; transaction_id?: string; at?: string }): { applied: boolean; reason: string } {
|
|
558
|
+
const located = this.locateAttempt(event);
|
|
559
|
+
if (!located) return { applied: false, reason: event.attempt_id || event.transaction_id ? "unknown attempt" : "event names no attempt_id and no transaction_id" };
|
|
560
|
+
const { row, attemptId } = located;
|
|
561
|
+
const stored = this.deps.store.appendEvent({ run_id: this.deps.runId, execution_id: row.execution_id, event_id: event.event_id, type: event.type, payload: { ...event, attempt_id: attemptId }, at: event.at ?? this.clock().toISOString() });
|
|
562
|
+
if (!stored) return { applied: false, reason: "duplicate event id" };
|
|
563
|
+
this.deps.bundle.event({ ...stored, actor: this.agentActor });
|
|
564
|
+
const execution = this.mustGet(row.execution_id);
|
|
565
|
+
if (isTerminal(execution.state)) return { applied: false, reason: `execution already ${execution.state}` };
|
|
566
|
+
if (execution.state !== "executing") return { applied: false, reason: `execution is ${execution.state}; the rail event cannot move it` };
|
|
567
|
+
const attempts = (row.payload as { attempts: string[] }).attempts;
|
|
568
|
+
const index = attempts.indexOf(attemptId);
|
|
569
|
+
const prior = execution.outcomes.find((o) => o.index === index);
|
|
570
|
+
// Settled and failed are final. An accepted receivable is exactly what a `commerce.charge.*` event decides.
|
|
571
|
+
if (prior && prior.status !== "accepted") return { applied: false, reason: "attempt already has an outcome" };
|
|
572
|
+
|
|
573
|
+
const carry = prior ? { ...(prior.transaction_id ? { transaction_id: prior.transaction_id } : {}) } : {};
|
|
574
|
+
let outcome: ItemOutcome;
|
|
575
|
+
switch (event.type) {
|
|
576
|
+
case PAYMENT_SUCCEEDED:
|
|
577
|
+
outcome = { index, attempt_id: attemptId, status: "settled", ...carry };
|
|
578
|
+
break;
|
|
579
|
+
case CHARGE_PAID:
|
|
580
|
+
// The paid receivable is its own record on the API; `collectReceipts()` fetches it into the bundle.
|
|
581
|
+
outcome = { index, attempt_id: attemptId, status: "settled", ...carry, ...(prior?.transaction_id ? { receipt_id: prior.transaction_id } : {}) };
|
|
582
|
+
break;
|
|
583
|
+
case PAYMENT_FAILED:
|
|
584
|
+
outcome = { index, attempt_id: attemptId, status: "failed", code: "rail_failed", error: `event ${event.event_id}` };
|
|
585
|
+
break;
|
|
586
|
+
case CHARGE_EXPIRED:
|
|
587
|
+
outcome = { index, attempt_id: attemptId, status: "failed", code: "charge_expired", error: `charge_expired: event ${event.event_id}`, ...carry };
|
|
588
|
+
break;
|
|
589
|
+
case CHARGE_CANCELLED:
|
|
590
|
+
outcome = { index, attempt_id: attemptId, status: "failed", code: "charge_cancelled", error: `charge_cancelled: event ${event.event_id}`, ...carry };
|
|
591
|
+
break;
|
|
592
|
+
default:
|
|
593
|
+
return { applied: false, reason: `event type ${event.type} moves nothing` };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const outcomes = execution.outcomes.filter((o) => o.index !== index).concat(outcome).sort((a, b) => a.index - b.index);
|
|
597
|
+
const closed = this.close({ ...(execution as Execution<"executing">), outcomes }, attempts.length);
|
|
598
|
+
return { applied: true, reason: closed.state === "executing" ? `attempt recorded; ${closed.detail}` : closed.state };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** An event names our attempt id, or the rail's transaction id we recorded when the attempt was accepted. */
|
|
602
|
+
private locateAttempt(event: { attempt_id?: string; transaction_id?: string }): { row: ReturnType<StateStore["listOutbox"]>[number]; attemptId: string } | undefined {
|
|
603
|
+
const rows = this.deps.store.listOutbox();
|
|
604
|
+
if (event.attempt_id) {
|
|
605
|
+
const attemptId = event.attempt_id;
|
|
606
|
+
const row = rows.find((o) => ((o.payload as { attempts?: string[] }).attempts ?? []).includes(attemptId));
|
|
607
|
+
return row ? { row, attemptId } : undefined;
|
|
608
|
+
}
|
|
609
|
+
if (event.transaction_id) {
|
|
610
|
+
for (const row of rows) {
|
|
611
|
+
const execution = this.deps.store.getExecution(row.execution_id);
|
|
612
|
+
const hit = execution?.outcomes.find((o) => o.transaction_id === event.transaction_id);
|
|
613
|
+
if (hit) return { row, attemptId: hit.attempt_id };
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return undefined;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// ---- the channel's small writes -----------------------------------------
|
|
620
|
+
|
|
621
|
+
/** A line in the event log written by the channel (a message to the person, a sandbox payer call). Never a transition. */
|
|
622
|
+
note(type: string, executionId: string | null, payload: Record<string, unknown>): void {
|
|
623
|
+
this.record(type, executionId, payload);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Once-only presentation: true the first time a given attempt's instrument
|
|
628
|
+
* is marked shown, false afterwards, across restarts (it is a cursor in
|
|
629
|
+
* state.db, not memory). The debtor sees each QR once.
|
|
630
|
+
*/
|
|
631
|
+
markShown(executionId: string, attemptId: string): boolean {
|
|
632
|
+
const key = `shown:${executionId}:${attemptId}`;
|
|
633
|
+
if (this.deps.store.getCursor(key)) return false;
|
|
634
|
+
this.deps.store.setCursor(key, this.clock().toISOString());
|
|
635
|
+
return true;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Once-only message per execution outcome, same mechanism. */
|
|
639
|
+
markTold(executionId: string, outcome: string): boolean {
|
|
640
|
+
const key = `told:${executionId}:${outcome}`;
|
|
641
|
+
if (this.deps.store.getCursor(key)) return false;
|
|
642
|
+
this.deps.store.setCursor(key, this.clock().toISOString());
|
|
643
|
+
return true;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// ---- reads --------------------------------------------------------------
|
|
647
|
+
|
|
648
|
+
get(executionId: string): Execution | undefined {
|
|
649
|
+
return this.deps.store.getExecution(executionId);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
list(filter: { state?: ExecutionState | ExecutionState[] } = {}): Execution[] {
|
|
653
|
+
return this.deps.store.listExecutions({ ...filter, mandate_id: this.deps.mandate.id });
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ---- helpers ------------------------------------------------------------
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Section 4.7, fail-closed: `executing` is reached on `active` and on
|
|
660
|
+
* nothing else. A source that cannot answer (a failed read, a status this
|
|
661
|
+
* code does not know, an exception) is `mandate_status_unavailable`, never
|
|
662
|
+
* "assume active". An expired mandate closes the execution as `expired`;
|
|
663
|
+
* the other refusals close it as `denied`.
|
|
664
|
+
*/
|
|
665
|
+
private async mandateGate(): Promise<{ reason: ExecutionReason; detail: string; to: "denied" | "expired" } | undefined> {
|
|
666
|
+
const { mandate } = this.deps;
|
|
667
|
+
let report: MandateStatusReport;
|
|
668
|
+
try {
|
|
669
|
+
report = await this.deps.status.check(mandate.id);
|
|
670
|
+
} catch (err) {
|
|
671
|
+
return { to: "denied", reason: "mandate_status_unavailable", detail: `the status of mandate ${mandate.id} could not be read (${err instanceof Error ? err.message : String(err)}); nothing executes until it can` };
|
|
672
|
+
}
|
|
673
|
+
if (report.org_paused) return { to: "denied", reason: "org_paused", detail: "the organization paused every mandate (kill switch)" };
|
|
674
|
+
switch (report.status) {
|
|
675
|
+
case "revoked":
|
|
676
|
+
return { to: "denied", reason: "mandate_revoked", detail: `mandate ${mandate.id} was revoked (source: ${report.source})` };
|
|
677
|
+
case "paused":
|
|
678
|
+
return { to: "denied", reason: "mandate_paused", detail: `mandate ${mandate.id} is paused (source: ${report.source})` };
|
|
679
|
+
case "expired":
|
|
680
|
+
return { to: "expired", reason: "mandate_expired", detail: `mandate ${mandate.id} expired at ${mandate.expires_at} (source: ${report.source})` };
|
|
681
|
+
case "active":
|
|
682
|
+
if (mandateExpired(mandate, this.clock())) return { to: "expired", reason: "mandate_expired", detail: `mandate ${mandate.id} expired at ${mandate.expires_at}` };
|
|
683
|
+
return undefined;
|
|
684
|
+
default:
|
|
685
|
+
return { to: "denied", reason: "mandate_status_unavailable", detail: `the status of mandate ${mandate.id} could not be read (${report.detail ?? "no detail"}); nothing executes until it can` };
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
private resolveItem(proposed: ProposedItem): ExecutionItem {
|
|
690
|
+
const known = resolveBeneficiary(this.deps.mandate, proposed.payee);
|
|
691
|
+
const amount = Math.trunc(proposed.amount);
|
|
692
|
+
if (!Number.isFinite(amount) || amount <= 0) throw new Error(`amount must be a positive integer in minor units, got ${proposed.amount}`);
|
|
693
|
+
if (proposed.due_date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(proposed.due_date)) throw new Error(`due_date must be YYYY-MM-DD, got ${proposed.due_date}`);
|
|
694
|
+
return {
|
|
695
|
+
...(known ? { alias: known.alias } : {}),
|
|
696
|
+
beneficiary: known?.name ?? proposed.payee,
|
|
697
|
+
payee: known?.payee ?? proposed.payee,
|
|
698
|
+
amount,
|
|
699
|
+
currency: this.deps.mandate.currency,
|
|
700
|
+
...(proposed.description ? { description: proposed.description } : {}),
|
|
701
|
+
...(proposed.due_date ? { due_date: proposed.due_date } : {}),
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
private paymentsFor(execution: Execution): RailPayment[] {
|
|
706
|
+
return execution.items.map((item, index) => ({
|
|
707
|
+
attempt_id: `att_${execution.idempotency_key.slice(4)}_${index}`,
|
|
708
|
+
mandate_id: execution.mandate.id,
|
|
709
|
+
amount_minor: item.amount,
|
|
710
|
+
currency: item.currency,
|
|
711
|
+
payee: item.payee,
|
|
712
|
+
beneficiary: item.beneficiary,
|
|
713
|
+
purpose: this.deps.mandate.purpose,
|
|
714
|
+
agent_id: this.deps.mandate.agent_id,
|
|
715
|
+
...(item.description ? { description: item.description } : {}),
|
|
716
|
+
...(item.due_date ? { due_date: item.due_date } : {}),
|
|
717
|
+
actor: this.agentActor,
|
|
718
|
+
}));
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
private withoutApproval(execution: Execution<"approved">): Execution<"approved"> {
|
|
722
|
+
const { approval_id: _stale, ...rest } = execution;
|
|
723
|
+
return rest as Execution<"approved">;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** Settled, in flight or awaiting a decision under this mandate inside the cap window, excluding the execution being judged. */
|
|
727
|
+
private committedInWindow(now: Date, excludeId: string): number {
|
|
728
|
+
const start = windowStart(this.deps.mandate, now).getTime();
|
|
729
|
+
return this.list({ state: WINDOW_STATES })
|
|
730
|
+
.filter((e) => e.id !== excludeId && new Date(e.created_at).getTime() >= start)
|
|
731
|
+
.reduce((sum, e) => sum + e.total, 0);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
private knownPayees(): Set<string> {
|
|
735
|
+
const out = new Set<string>();
|
|
736
|
+
for (const e of this.list({ state: "settled" })) for (const i of e.items) out.add(i.payee);
|
|
737
|
+
return out;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* What the agent ran ALONE per payee inside the velocity window: mandate-mode
|
|
742
|
+
* executions the allowance approved without a human. A payment a human
|
|
743
|
+
* approved is not fractioning, so it does not count against the threshold.
|
|
744
|
+
*/
|
|
745
|
+
private recentByPayee(now: Date, excludeId: string): Map<string, number> {
|
|
746
|
+
const out = new Map<string, number>();
|
|
747
|
+
const hours = this.deps.guardrails.velocity?.window_hours;
|
|
748
|
+
if (!hours) return out;
|
|
749
|
+
const since = now.getTime() - hours * 3_600_000;
|
|
750
|
+
for (const e of this.list({ state: ["approved", "executing", "settled"] })) {
|
|
751
|
+
if (e.id === excludeId || e.mode !== "mandate" || e.escalation !== undefined) continue;
|
|
752
|
+
if (new Date(e.created_at).getTime() < since) continue;
|
|
753
|
+
for (const i of e.items) out.set(i.payee, (out.get(i.payee) ?? 0) + i.amount);
|
|
754
|
+
}
|
|
755
|
+
return out;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
private storeApproval(artifact: ApprovalArtifact): void {
|
|
759
|
+
this.deps.store.saveApproval(artifact);
|
|
760
|
+
this.deps.bundle.approval(artifact);
|
|
761
|
+
this.record("approval.created", artifact.execution_id, { approval_id: artifact.approval_id, approver: artifact.approver, items_hash: artifact.items_hash, escalation: artifact.escalation ?? null });
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
private persist<S extends ExecutionState>(execution: Execution<S>): Execution<S> {
|
|
765
|
+
this.deps.store.saveExecution(execution);
|
|
766
|
+
const last = execution.history[execution.history.length - 1];
|
|
767
|
+
if (last) {
|
|
768
|
+
const stored = this.deps.store.appendEvent({ run_id: this.deps.runId, execution_id: execution.id, type: "execution.transition", payload: last, at: last.at });
|
|
769
|
+
if (stored) this.deps.bundle.event({ ...stored, actor: last.actor });
|
|
770
|
+
}
|
|
771
|
+
return execution;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
private record(type: string, executionId: string | null, payload: Record<string, unknown>): void {
|
|
775
|
+
const stored = this.deps.store.appendEvent({ run_id: this.deps.runId, execution_id: executionId, type, payload, at: this.clock().toISOString() });
|
|
776
|
+
if (stored) this.deps.bundle.event({ ...stored, actor: this.agentActor });
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
private mustGet(executionId: string): Execution {
|
|
780
|
+
const execution = this.deps.store.getExecution(executionId);
|
|
781
|
+
if (!execution) throw new Error(`unknown execution ${executionId}`);
|
|
782
|
+
return execution;
|
|
783
|
+
}
|
|
784
|
+
}
|