@codespar/cli 0.12.1 → 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 +54 -1
- package/dist/api.js +1 -3
- package/dist/api.js.map +1 -1
- package/dist/commands/agent.js +260 -0
- package/dist/commands/agent.js.map +1 -0
- package/dist/commands/init.js +91 -23
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/mandate-revoke.js +30 -0
- package/dist/commands/mandate-revoke.js.map +1 -0
- package/dist/config.js +8 -1
- package/dist/config.js.map +1 -1
- package/dist/index.js +36 -3
- package/dist/index.js.map +1 -1
- package/dist/surface.js +1 -1
- package/dist/surface.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,524 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { ESCOLA, harness, MERCADO } from "./helpers.js";
|
|
5
|
+
|
|
6
|
+
const approver = { id: "usr_demo", channel: "terminal" };
|
|
7
|
+
|
|
8
|
+
describe("section 4.6: the model proposes, the code executes", () => {
|
|
9
|
+
it("human mode: drafted -> awaiting_approval -> approved -> executing -> settled, with an artifact and a receipt", async () => {
|
|
10
|
+
const h = harness({ mode: "human" });
|
|
11
|
+
const draft = await h.engine.draft({ items: [{ payee: "escola", amount: 185000, description: "outubro" }] });
|
|
12
|
+
expect(draft.ok).toBe(true);
|
|
13
|
+
if (!draft.ok) return;
|
|
14
|
+
expect(draft.execution.state).toBe("awaiting_approval");
|
|
15
|
+
expect(draft.execution.items[0]).toMatchObject({ alias: "escola", beneficiary: "Escola Aurora", payee: ESCOLA, amount: 185000 });
|
|
16
|
+
expect(draft.execution.total).toBe(185000);
|
|
17
|
+
|
|
18
|
+
const approved = h.engine.approve(draft.execution.id, approver);
|
|
19
|
+
expect(approved.state).toBe("approved");
|
|
20
|
+
expect(approved.approval_id).toMatch(/^apr_/);
|
|
21
|
+
const artifact = h.store.getApproval(approved.approval_id!)!;
|
|
22
|
+
expect(artifact.approver).toEqual({ type: "person", id: "usr_demo", channel: "terminal" });
|
|
23
|
+
expect(artifact.items_hash).toBe(approved.items_hash);
|
|
24
|
+
|
|
25
|
+
const settled = await h.engine.execute(approved.id);
|
|
26
|
+
expect(settled.state).toBe("settled");
|
|
27
|
+
expect(settled.history.map((t) => t.to)).toEqual(["awaiting_approval", "approved", "executing", "settled"]);
|
|
28
|
+
expect(settled.outcomes[0]?.receipt_id).toMatch(/^rcpt_stub_/);
|
|
29
|
+
const receipts = readdirSync(join(h.bundle.dir, "receipts"));
|
|
30
|
+
expect(receipts).toHaveLength(1);
|
|
31
|
+
expect(h.bundle.readApprovals()).toHaveLength(1);
|
|
32
|
+
expect(existsSync(join(h.bundle.dir, "events.jsonl"))).toBe(true);
|
|
33
|
+
for (const event of h.bundle.readEvents()) expect(event["actor"]).toBeDefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("mandate mode: drafted -> approved by the mandate -> settled, same trail shape", async () => {
|
|
37
|
+
const h = harness({ mode: "mandate", manifest: { escalate_above: {} }, guardrails: { escalate_above: {} } });
|
|
38
|
+
const draft = await h.engine.draft({ items: [{ payee: "escola", amount: 185000 }] });
|
|
39
|
+
if (!draft.ok) throw new Error("refused");
|
|
40
|
+
expect(draft.execution.state).toBe("approved");
|
|
41
|
+
const artifact = h.store.getApproval(draft.execution.approval_id!)!;
|
|
42
|
+
expect(artifact.approver).toEqual({ type: "mandate", id: "mdt_test_0001" });
|
|
43
|
+
const settled = await h.engine.execute(draft.execution.id);
|
|
44
|
+
expect(settled.state).toBe("settled");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("the core computes the total; the model's number is recorded and ignored", async () => {
|
|
48
|
+
const h = harness({ mode: "human" });
|
|
49
|
+
const draft = await h.engine.draft({ items: [{ payee: "escola", amount: 10000 }, { payee: "mercado", amount: 5000 }], claimed_total: 12000 });
|
|
50
|
+
if (!draft.ok) throw new Error("refused");
|
|
51
|
+
expect(draft.execution.total).toBe(15000);
|
|
52
|
+
expect(draft.execution.model_claimed_total).toBe(12000);
|
|
53
|
+
const approved = h.engine.approve(draft.execution.id, approver);
|
|
54
|
+
expect(h.store.getApproval(approved.approval_id!)!.items.reduce((s, i) => s + i.amount, 0)).toBe(15000);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("with model_total_mismatch: refuse, the execution is denied instead", async () => {
|
|
58
|
+
const h = harness({ mode: "human", guardrails: { model_total_mismatch: "refuse" } });
|
|
59
|
+
const draft = await h.engine.draft({ items: [{ payee: "escola", amount: 15000 }], claimed_total: 12000 });
|
|
60
|
+
if (!draft.ok) throw new Error("refused");
|
|
61
|
+
expect(draft.execution.state).toBe("denied");
|
|
62
|
+
expect(draft.execution.reason).toBe("model_total_mismatch");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("mandate limits are the core's, in both modes", () => {
|
|
67
|
+
it("per-transaction cap: denied, readable", async () => {
|
|
68
|
+
for (const mode of ["human", "mandate"] as const) {
|
|
69
|
+
const h = harness({ mode });
|
|
70
|
+
const draft = await h.engine.draft({ items: [{ payee: "escola", amount: 250001 }] });
|
|
71
|
+
if (!draft.ok) throw new Error("refused");
|
|
72
|
+
expect(draft.execution.state).toBe("denied");
|
|
73
|
+
expect(draft.execution.reason).toBe("per_tx_cap_exceeded");
|
|
74
|
+
expect(draft.execution.detail).toContain("per-payment cap");
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("window cap counts what is settled and in flight", async () => {
|
|
79
|
+
const h = harness({ mode: "mandate", manifest: { escalate_above: {} }, guardrails: { escalate_above: {} } });
|
|
80
|
+
for (let i = 0; i < 2; i += 1) {
|
|
81
|
+
const d = await h.engine.draft({ items: [{ payee: "escola", amount: 250000 }] });
|
|
82
|
+
if (!d.ok) throw new Error("refused");
|
|
83
|
+
await h.engine.execute(d.execution.id);
|
|
84
|
+
}
|
|
85
|
+
const third = await h.engine.draft({ items: [{ payee: "mercado", amount: 100001 }] });
|
|
86
|
+
if (!third.ok) throw new Error("refused");
|
|
87
|
+
expect(third.execution.state).toBe("denied");
|
|
88
|
+
expect(third.execution.reason).toBe("window_cap_exceeded");
|
|
89
|
+
const fits = await h.engine.draft({ items: [{ payee: "mercado", amount: 100000 }] });
|
|
90
|
+
if (!fits.ok) throw new Error("refused");
|
|
91
|
+
expect(fits.execution.state).toBe("approved");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("payee outside the allowlist: denied in mandate, escalated in human, and never approvable", async () => {
|
|
95
|
+
const m = harness({ mode: "mandate" });
|
|
96
|
+
const denied = await m.engine.draft({ items: [{ payee: "chave-nova@banco.com", amount: 1000 }] });
|
|
97
|
+
if (!denied.ok) throw new Error("refused");
|
|
98
|
+
expect(denied.execution.state).toBe("denied");
|
|
99
|
+
expect(denied.execution.reason).toBe("beneficiary_not_allowed");
|
|
100
|
+
|
|
101
|
+
const hh = harness({ mode: "human" });
|
|
102
|
+
const escalated = await hh.engine.draft({ items: [{ payee: "chave-nova@banco.com", amount: 1000 }] });
|
|
103
|
+
if (!escalated.ok) throw new Error("refused");
|
|
104
|
+
expect(escalated.execution.state).toBe("awaiting_approval");
|
|
105
|
+
expect(escalated.execution.blocking_reasons).toEqual(["beneficiary_not_allowed"]);
|
|
106
|
+
const afterYes = hh.engine.approve(escalated.execution.id, approver);
|
|
107
|
+
expect(afterYes.state).toBe("denied");
|
|
108
|
+
expect(afterYes.reason).toBe("beneficiary_not_allowed");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("section 4.4 in the engine", () => {
|
|
113
|
+
it("amount above threshold in mandate mode goes to a human; the artifact records the trigger", async () => {
|
|
114
|
+
const h = harness({ mode: "mandate" });
|
|
115
|
+
// Make escola a known payee first, so only `amount` can fire.
|
|
116
|
+
const warm = await h.engine.draft({ items: [{ payee: "escola", amount: 1000 }] });
|
|
117
|
+
if (!warm.ok) throw new Error("refused");
|
|
118
|
+
expect(warm.execution.escalation?.trigger).toBe("new_beneficiary");
|
|
119
|
+
await h.engine.execute(h.engine.approve(warm.execution.id, approver).id);
|
|
120
|
+
|
|
121
|
+
const big = await h.engine.draft({ items: [{ payee: "escola", amount: 150001 }] });
|
|
122
|
+
if (!big.ok) throw new Error("refused");
|
|
123
|
+
expect(big.execution.state).toBe("awaiting_approval");
|
|
124
|
+
expect(big.execution.escalation?.trigger).toBe("amount");
|
|
125
|
+
const approved = h.engine.approve(big.execution.id, approver);
|
|
126
|
+
expect(h.store.getApproval(approved.approval_id!)!.escalation?.trigger).toBe("amount");
|
|
127
|
+
|
|
128
|
+
await h.engine.execute(approved.id);
|
|
129
|
+
|
|
130
|
+
// The following one, below the threshold, runs alone: a human-approved payment is not fractioning.
|
|
131
|
+
const small = await h.engine.draft({ items: [{ payee: "escola", amount: 1000 }] });
|
|
132
|
+
if (!small.ok) throw new Error("refused");
|
|
133
|
+
expect(small.execution.state).toBe("approved");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("fractioning: five parts below the threshold escalate by the window", async () => {
|
|
137
|
+
const h = harness({ mode: "mandate", manifest: { escalate_above: { amount: 150000 } }, guardrails: { escalate_above: { amount: 150000 } } });
|
|
138
|
+
const states: string[] = [];
|
|
139
|
+
for (let i = 0; i < 5; i += 1) {
|
|
140
|
+
const d = await h.engine.draft({ items: [{ payee: "escola", amount: 40000 }] });
|
|
141
|
+
if (!d.ok) throw new Error("refused");
|
|
142
|
+
states.push(d.execution.state);
|
|
143
|
+
if (d.execution.state === "approved") await h.engine.execute(d.execution.id);
|
|
144
|
+
}
|
|
145
|
+
expect(states).toEqual(["approved", "approved", "approved", "awaiting_approval", "awaiting_approval"]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("outside hours escalates, or refuses when the guardrail says so", async () => {
|
|
149
|
+
const night = new Date("2026-09-24T02:00:00Z");
|
|
150
|
+
const a = harness({ mode: "mandate", now: night, manifest: { escalate_above: { outside_hours: "22:00-07:00" } }, guardrails: { escalate_above: { outside_hours: "22:00-07:00" } } });
|
|
151
|
+
const d1 = await a.engine.draft({ items: [{ payee: "escola", amount: 1000 }] });
|
|
152
|
+
if (!d1.ok) throw new Error("refused");
|
|
153
|
+
expect(d1.execution.state).toBe("awaiting_approval");
|
|
154
|
+
expect(d1.execution.escalation?.trigger).toBe("outside_hours");
|
|
155
|
+
|
|
156
|
+
const b = harness({ mode: "mandate", now: night, manifest: { escalate_above: { outside_hours: "22:00-07:00" } }, guardrails: { escalate_above: { outside_hours: "22:00-07:00" }, outside_hours_action: "refuse" } });
|
|
157
|
+
const d2 = await b.engine.draft({ items: [{ payee: "escola", amount: 1000 }] });
|
|
158
|
+
if (!d2.ok) throw new Error("refused");
|
|
159
|
+
expect(d2.execution.state).toBe("denied");
|
|
160
|
+
expect(d2.execution.reason).toBe("outside_hours");
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("section 4.7: revocation and kill switch (against the stub)", () => {
|
|
165
|
+
it("a new request after revocation is refused before drafted", async () => {
|
|
166
|
+
const h = harness({ mode: "human" });
|
|
167
|
+
h.gate.revoke("mdt_test_0001");
|
|
168
|
+
const draft = await h.engine.draft({ items: [{ payee: "escola", amount: 1000 }] });
|
|
169
|
+
expect(draft).toMatchObject({ ok: false, refused_before_draft: true, reason: "mandate_revoked" });
|
|
170
|
+
expect(h.store.listExecutions()).toHaveLength(0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("revoked while awaiting approval: approved artifact does not help, execute answers denied", async () => {
|
|
174
|
+
const h = harness({ mode: "human" });
|
|
175
|
+
const d = await h.engine.draft({ items: [{ payee: "escola", amount: 1000 }] });
|
|
176
|
+
if (!d.ok) throw new Error("refused");
|
|
177
|
+
const approved = h.engine.approve(d.execution.id, approver);
|
|
178
|
+
h.gate.revoke("mdt_test_0001");
|
|
179
|
+
const out = await h.engine.execute(approved.id);
|
|
180
|
+
expect(out.state).toBe("denied");
|
|
181
|
+
expect(out.reason).toBe("mandate_revoked");
|
|
182
|
+
expect(h.store.listOutbox()).toHaveLength(0);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("org pauseAll denies with org_paused; paused mandate with mandate_paused", async () => {
|
|
186
|
+
const h = harness({ mode: "mandate", manifest: { escalate_above: {} }, guardrails: { escalate_above: {} } });
|
|
187
|
+
h.gate.pauseAll();
|
|
188
|
+
expect(await h.engine.draft({ items: [{ payee: "escola", amount: 1000 }] })).toMatchObject({ ok: false, reason: "org_paused" });
|
|
189
|
+
h.gate.resumeAll();
|
|
190
|
+
h.gate.pause("mdt_test_0001");
|
|
191
|
+
expect(await h.engine.draft({ items: [{ payee: "escola", amount: 1000 }] })).toMatchObject({ ok: false, reason: "mandate_paused" });
|
|
192
|
+
h.gate.resume("mdt_test_0001");
|
|
193
|
+
const d = await h.engine.draft({ items: [{ payee: "escola", amount: 1000 }] });
|
|
194
|
+
expect(d.ok && d.execution.state).toBe("approved");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("revoked while executing: reconciled from the rail, not cancelled", async () => {
|
|
198
|
+
const h = harness({ mode: "mandate", manifest: { escalate_above: {} }, guardrails: { escalate_above: {} } });
|
|
199
|
+
const d = await h.engine.draft({ items: [{ payee: "escola", amount: 1000 }] });
|
|
200
|
+
if (!d.ok) throw new Error("refused");
|
|
201
|
+
const attempt = `att_${d.execution.idempotency_key.slice(4)}_0`;
|
|
202
|
+
// The provider took the payment and answered late: uncertain now, in flight on the first lookup, settled on the second.
|
|
203
|
+
const h2 = harness({ mode: "mandate", dir: h.dir, manifest: { escalate_above: {} }, guardrails: { escalate_above: {} }, rail: { uncertainOnce: [attempt], inFlightThenSettled: [attempt] } });
|
|
204
|
+
const stuck = await h2.engine.execute(d.execution.id);
|
|
205
|
+
expect(stuck.state).toBe("executing");
|
|
206
|
+
h2.gate.revoke("mdt_test_0001");
|
|
207
|
+
expect((await h2.engine.reconcile(stuck.id)).state).toBe("executing");
|
|
208
|
+
const closed = await h2.engine.reconcile(stuck.id);
|
|
209
|
+
expect(closed.state).toBe("settled");
|
|
210
|
+
expect(h2.rail.payCount).toBe(0);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("section 10: idempotency, restart, reconcile", () => {
|
|
215
|
+
it("killing after the rail accepted and resuming gives one payment and one receipt", async () => {
|
|
216
|
+
const h = harness({ mode: "mandate", manifest: { escalate_above: {} }, guardrails: { escalate_above: {} }, rail: { afterDispatch: () => { throw new Error("SIGKILL simulated after dispatch"); } } });
|
|
217
|
+
const d = await h.engine.draft({ items: [{ payee: "escola", amount: 1000 }] });
|
|
218
|
+
if (!d.ok) throw new Error("refused");
|
|
219
|
+
await expect(h.engine.execute(d.execution.id)).rejects.toThrow("SIGKILL");
|
|
220
|
+
const stuck = h.store.getExecution(d.execution.id)!;
|
|
221
|
+
expect(stuck.state).toBe("executing");
|
|
222
|
+
expect(h.store.listOutbox({ status: ["sent"] })).toHaveLength(1);
|
|
223
|
+
|
|
224
|
+
// A fresh process over the same state.db.
|
|
225
|
+
const resumed = harness({ mode: "mandate", dir: h.dir, manifest: { escalate_above: {} }, guardrails: { escalate_above: {} } });
|
|
226
|
+
const done = await resumed.engine.reconcile(stuck.id);
|
|
227
|
+
expect(done.state).toBe("settled");
|
|
228
|
+
expect(readdirSync(join(resumed.bundle.dir, "receipts"))).toHaveLength(1);
|
|
229
|
+
// Exactly one attempt reached the rail, ever.
|
|
230
|
+
expect(resumed.store.stubRailGet(`att_${stuck.idempotency_key.slice(4)}_0`)).toBeDefined();
|
|
231
|
+
const events = resumed.store.listEvents({ execution_id: stuck.id }).filter((e) => e.type === "commerce.payment.succeeded");
|
|
232
|
+
expect(events).toHaveLength(1);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("an uncertain outcome is never retried blind: it stays executing, marked unresolved, until the rail answers", async () => {
|
|
236
|
+
const h = harness({ mode: "mandate", manifest: { escalate_above: {} }, guardrails: { escalate_above: {} } });
|
|
237
|
+
const d = await h.engine.draft({ items: [{ payee: "escola", amount: 1000 }] });
|
|
238
|
+
if (!d.ok) throw new Error("refused");
|
|
239
|
+
const attempt = `att_${d.execution.idempotency_key.slice(4)}_0`;
|
|
240
|
+
const flaky = harness({ mode: "mandate", dir: h.dir, manifest: { escalate_above: {} }, guardrails: { escalate_above: {} }, rail: { uncertainOnce: [attempt] } });
|
|
241
|
+
const stuck = await flaky.engine.execute(d.execution.id);
|
|
242
|
+
expect(stuck.state).toBe("executing");
|
|
243
|
+
expect(stuck.reason).toBe("rail_uncertain");
|
|
244
|
+
expect(flaky.store.stubRailGet(attempt)).toBeUndefined();
|
|
245
|
+
const still = await flaky.engine.reconcile(stuck.id);
|
|
246
|
+
expect(still.state).toBe("executing");
|
|
247
|
+
expect(still.outcomes).toHaveLength(0);
|
|
248
|
+
expect(flaky.rail.payCount).toBe(0);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("duplicate and out-of-order rail events settle once", async () => {
|
|
252
|
+
const h = harness({ mode: "mandate", manifest: { escalate_above: {} }, guardrails: { escalate_above: {} } });
|
|
253
|
+
const d = await h.engine.draft({ items: [{ payee: "escola", amount: 1000 }] });
|
|
254
|
+
if (!d.ok) throw new Error("refused");
|
|
255
|
+
const attempt = `att_${d.execution.idempotency_key.slice(4)}_0`;
|
|
256
|
+
const flaky = harness({ mode: "mandate", dir: h.dir, manifest: { escalate_above: {} }, guardrails: { escalate_above: {} }, rail: { uncertainOnce: [attempt] } });
|
|
257
|
+
const stuck = await flaky.engine.execute(d.execution.id);
|
|
258
|
+
expect(stuck.state).toBe("executing");
|
|
259
|
+
const paid = { event_id: "evt_paid_1", type: "commerce.payment.succeeded", attempt_id: attempt };
|
|
260
|
+
expect(flaky.engine.ingestExternalEvent(paid)).toEqual({ applied: true, reason: "settled" });
|
|
261
|
+
expect(flaky.engine.ingestExternalEvent(paid)).toEqual({ applied: false, reason: "duplicate event id" });
|
|
262
|
+
expect(flaky.engine.ingestExternalEvent({ event_id: "evt_created_late", type: "commerce.payment.created", attempt_id: attempt })).toMatchObject({ applied: false });
|
|
263
|
+
expect(flaky.engine.ingestExternalEvent({ event_id: "evt_paid_2", type: "commerce.payment.succeeded", attempt_id: attempt })).toMatchObject({ applied: false, reason: "execution already settled" });
|
|
264
|
+
expect(flaky.store.getExecution(stuck.id)!.state).toBe("settled");
|
|
265
|
+
expect(flaky.store.listEvents({ execution_id: stuck.id }).filter((e) => e.type === "execution.transition" && (e.payload as { to: string }).to === "settled")).toHaveLength(1);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("a failed rail answer closes as failed with the code, and a second draft to the same payee is a new execution", async () => {
|
|
269
|
+
const h = harness({ mode: "mandate", manifest: { escalate_above: {} }, guardrails: { escalate_above: {} }, rail: { refusePayees: [MERCADO] } });
|
|
270
|
+
const d = await h.engine.draft({ items: [{ payee: "mercado", amount: 1000 }] });
|
|
271
|
+
if (!d.ok) throw new Error("refused");
|
|
272
|
+
const failed = await h.engine.execute(d.execution.id);
|
|
273
|
+
expect(failed.state).toBe("failed");
|
|
274
|
+
expect(failed.detail).toContain("psp_dispatch_failed");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("a stale open execution expires", async () => {
|
|
278
|
+
const h = harness({ mode: "human" });
|
|
279
|
+
const d = await h.engine.draft({ items: [{ payee: "escola", amount: 1000 }] });
|
|
280
|
+
if (!d.ok) throw new Error("refused");
|
|
281
|
+
h.setNow(new Date(h.now.getTime() + 16 * 60 * 1000));
|
|
282
|
+
const expired = h.engine.expireStale();
|
|
283
|
+
expect(expired.map((e) => e.state)).toEqual(["expired"]);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("the items_hash is recomputed before executing; a changed list goes back to awaiting_approval", async () => {
|
|
287
|
+
const h = harness({ mode: "human" });
|
|
288
|
+
const d = await h.engine.draft({ items: [{ payee: "escola", amount: 1000 }] });
|
|
289
|
+
if (!d.ok) throw new Error("refused");
|
|
290
|
+
const approved = h.engine.approve(d.execution.id, approver);
|
|
291
|
+
// Tamper with the persisted list after approval, the way a bug or an attacker would.
|
|
292
|
+
h.store.saveExecution({ ...approved, items: [{ ...approved.items[0]!, amount: 999999 }] });
|
|
293
|
+
const back = await h.engine.execute(approved.id);
|
|
294
|
+
expect(back.state).toBe("awaiting_approval");
|
|
295
|
+
expect(back.reason).toBe("items_hash_mismatch");
|
|
296
|
+
expect(back.approval_id).toBeUndefined();
|
|
297
|
+
expect(h.store.listOutbox()).toHaveLength(0);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe("the policy runs at every gate, not only at draft (review of PR #1)", () => {
|
|
302
|
+
it("two drafts each inside the window cap cannot both settle: the second is refused by the reservation, and the last gate refuses what slipped past", async () => {
|
|
303
|
+
const h = harness({ mode: "human" });
|
|
304
|
+
const a = await h.engine.draft({ items: [{ payee: "escola", amount: 240000 }, { payee: "mercado", amount: 160000 }] }); // 400000
|
|
305
|
+
if (!a.ok) throw new Error("refused");
|
|
306
|
+
expect(a.execution.state).toBe("awaiting_approval");
|
|
307
|
+
const b = await h.engine.draft({ items: [{ payee: "funcionaria", amount: 200000 }, { payee: "mercado", amount: 100000 }] }); // 300000
|
|
308
|
+
if (!b.ok) throw new Error("refused");
|
|
309
|
+
expect(b.execution.state).toBe("denied");
|
|
310
|
+
expect(b.execution.reason).toBe("window_cap_exceeded");
|
|
311
|
+
|
|
312
|
+
// What slipped past: an approved execution whose window filled up before it ran (another process settled 300000 meanwhile).
|
|
313
|
+
const approvedA = h.engine.approve(a.execution.id, approver);
|
|
314
|
+
expect(approvedA.state).toBe("approved");
|
|
315
|
+
const foreign = { ...approvedA, id: "exe_other_process", state: "settled" as const, total: 300000, approval_id: undefined, history: [] };
|
|
316
|
+
h.store.saveExecution(foreign as never);
|
|
317
|
+
const last = await h.engine.execute(approvedA.id);
|
|
318
|
+
expect(last.state).toBe("denied");
|
|
319
|
+
expect(last.reason).toBe("window_cap_exceeded");
|
|
320
|
+
expect(h.store.listOutbox()).toHaveLength(0);
|
|
321
|
+
expect(h.engine.list({ state: "settled" }).reduce((s, e) => s + e.total, 0)).toBe(300000);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("a list tampered after approval goes back to awaiting_approval, and a yes on the tampered list is refused by the policy, not re-approved", async () => {
|
|
325
|
+
const h = harness({ mode: "human" });
|
|
326
|
+
const d = await h.engine.draft({ items: [{ payee: "escola", amount: 1000 }] });
|
|
327
|
+
if (!d.ok) throw new Error("refused");
|
|
328
|
+
const approved = h.engine.approve(d.execution.id, approver);
|
|
329
|
+
h.store.saveExecution({ ...approved, items: [{ ...approved.items[0]!, beneficiary: "atacante", payee: "pix@atacante.example.com" }] });
|
|
330
|
+
const back = await h.engine.execute(approved.id);
|
|
331
|
+
expect(back.state).toBe("awaiting_approval");
|
|
332
|
+
expect(back.reason).toBe("items_hash_mismatch");
|
|
333
|
+
const afterYes = h.engine.approve(back.id, approver);
|
|
334
|
+
expect(afterYes.state).toBe("denied");
|
|
335
|
+
expect(afterYes.reason).toBe("beneficiary_not_allowed");
|
|
336
|
+
expect(h.store.listOutbox()).toHaveLength(0);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("a mandate-approved execution re-checks escalate_above at the last gate; a person's approval satisfies it", async () => {
|
|
340
|
+
// Approved by the mandate during the day, executed at night: back to a human.
|
|
341
|
+
const day = new Date("2026-09-23T18:00:00Z");
|
|
342
|
+
const h = harness({ mode: "mandate", now: day, manifest: { escalate_above: { outside_hours: "22:00-07:00" } }, guardrails: { escalate_above: { outside_hours: "22:00-07:00" }, approval_ttl_minutes: 24 * 60 } });
|
|
343
|
+
const d = await h.engine.draft({ items: [{ payee: "escola", amount: 1000 }] });
|
|
344
|
+
if (!d.ok) throw new Error("refused");
|
|
345
|
+
expect(d.execution.state).toBe("approved");
|
|
346
|
+
h.setNow(new Date("2026-09-24T02:00:00Z"));
|
|
347
|
+
const night = await h.engine.execute(d.execution.id);
|
|
348
|
+
expect(night.state).toBe("awaiting_approval");
|
|
349
|
+
expect(night.escalation?.trigger).toBe("outside_hours");
|
|
350
|
+
const settled = await h.engine.execute(h.engine.approve(night.id, approver).id);
|
|
351
|
+
expect(settled.state).toBe("settled");
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
describe("reconcile never re-dispatches (review of PR #1)", () => {
|
|
356
|
+
async function stuckExecution(rail: import("../src/stubs/rail.js").StubRailOptions | undefined) {
|
|
357
|
+
const h = harness({ mode: "mandate", manifest: { escalate_above: {} }, guardrails: { escalate_above: {} } });
|
|
358
|
+
const d = await h.engine.draft({ items: [{ payee: "escola", amount: 1000 }] });
|
|
359
|
+
if (!d.ok) throw new Error("refused");
|
|
360
|
+
const attempt = `att_${d.execution.idempotency_key.slice(4)}_0`;
|
|
361
|
+
const flaky = harness({ mode: "mandate", dir: h.dir, manifest: { escalate_above: {} }, guardrails: { escalate_above: {} }, rail: { uncertainOnce: [attempt], ...(rail ?? {}), inFlightThenSettled: [attempt] } });
|
|
362
|
+
const stuck = await flaky.engine.execute(d.execution.id);
|
|
363
|
+
expect(stuck.state).toBe("executing");
|
|
364
|
+
expect(stuck.reason).toBe("rail_uncertain");
|
|
365
|
+
return { h: flaky, stuck, attempt };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
it("in flight, then settled: two reconciles, zero new payments", async () => {
|
|
369
|
+
const { h, stuck, attempt } = await stuckExecution(undefined);
|
|
370
|
+
const paysBefore = h.rail.payCount;
|
|
371
|
+
const first = await h.engine.reconcile(stuck.id);
|
|
372
|
+
expect(first.state).toBe("executing");
|
|
373
|
+
expect(first.detail).toContain("in_flight");
|
|
374
|
+
expect(h.rail.payCount).toBe(paysBefore);
|
|
375
|
+
expect(h.store.stubRailGet(attempt)).toBeUndefined();
|
|
376
|
+
const second = await h.engine.reconcile(stuck.id);
|
|
377
|
+
expect(second.state).toBe("settled");
|
|
378
|
+
expect(h.rail.payCount).toBe(paysBefore);
|
|
379
|
+
expect(h.store.listOutbox()[0]?.status).toBe("done");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("unknown to the rail after the outbox was sent: stays executing for a human, nothing re-sent", async () => {
|
|
383
|
+
const h = harness({ mode: "mandate", manifest: { escalate_above: {} }, guardrails: { escalate_above: {} } });
|
|
384
|
+
const d = await h.engine.draft({ items: [{ payee: "escola", amount: 1000 }] });
|
|
385
|
+
if (!d.ok) throw new Error("refused");
|
|
386
|
+
const attempt = `att_${d.execution.idempotency_key.slice(4)}_0`;
|
|
387
|
+
const flaky = harness({ mode: "mandate", dir: h.dir, manifest: { escalate_above: {} }, guardrails: { escalate_above: {} }, rail: { uncertainOnce: [attempt] } });
|
|
388
|
+
const stuck = await flaky.engine.execute(d.execution.id);
|
|
389
|
+
expect(stuck.state).toBe("executing");
|
|
390
|
+
const again = await flaky.engine.reconcile(stuck.id);
|
|
391
|
+
expect(again.state).toBe("executing");
|
|
392
|
+
expect(again.reason).toBe("rail_uncertain");
|
|
393
|
+
expect(flaky.rail.payCount).toBe(0);
|
|
394
|
+
expect(flaky.store.stubRailGet(attempt)).toBeUndefined();
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("outbox still pending (crash before any call): the attempts go out once, under the same ids", async () => {
|
|
398
|
+
const h = harness({ mode: "mandate", manifest: { escalate_above: {} }, guardrails: { escalate_above: {} }, rail: { afterDispatch: () => { throw new Error("crash"); } } });
|
|
399
|
+
const d = await h.engine.draft({ items: [{ payee: "escola", amount: 1000 }] });
|
|
400
|
+
if (!d.ok) throw new Error("refused");
|
|
401
|
+
// Simulate a crash between the transaction and the first call: rewind the outbox to pending.
|
|
402
|
+
await expect(h.engine.execute(d.execution.id)).rejects.toThrow("crash");
|
|
403
|
+
const fresh = harness({ mode: "mandate", dir: h.dir, manifest: { escalate_above: {} }, guardrails: { escalate_above: {} } });
|
|
404
|
+
expect(fresh.store.getOutbox(d.execution.idempotency_key)?.status).toBe("sent");
|
|
405
|
+
const closed = await fresh.engine.reconcile(d.execution.id);
|
|
406
|
+
expect(closed.state).toBe("settled");
|
|
407
|
+
expect(fresh.rail.payCount).toBe(0);
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
describe("a multi-item execution closes only when every attempt has its outcome (review of PR #1)", () => {
|
|
412
|
+
it("one rail event settles one attempt; the second closes the execution and the outbox", async () => {
|
|
413
|
+
const h = harness({ mode: "mandate", manifest: { escalate_above: {} }, guardrails: { escalate_above: {} } });
|
|
414
|
+
const d = await h.engine.draft({ items: [{ payee: "escola", amount: 1000 }, { payee: "mercado", amount: 2000 }] });
|
|
415
|
+
if (!d.ok) throw new Error("refused");
|
|
416
|
+
const a0 = `att_${d.execution.idempotency_key.slice(4)}_0`;
|
|
417
|
+
const a1 = `att_${d.execution.idempotency_key.slice(4)}_1`;
|
|
418
|
+
const flaky = harness({ mode: "mandate", dir: h.dir, manifest: { escalate_above: {} }, guardrails: { escalate_above: {} }, rail: { uncertainOnce: [a0, a1] } });
|
|
419
|
+
const stuck = await flaky.engine.execute(d.execution.id);
|
|
420
|
+
expect(stuck.state).toBe("executing");
|
|
421
|
+
expect(flaky.engine.ingestExternalEvent({ event_id: "e0", type: "commerce.payment.succeeded", attempt_id: a0 })).toMatchObject({ applied: true });
|
|
422
|
+
expect(flaky.store.getExecution(stuck.id)!.state).toBe("executing");
|
|
423
|
+
expect(flaky.store.getOutbox(stuck.idempotency_key)!.status).toBe("sent");
|
|
424
|
+
expect(flaky.engine.ingestExternalEvent({ event_id: "e0", type: "commerce.payment.succeeded", attempt_id: a0 })).toMatchObject({ applied: false, reason: "duplicate event id" });
|
|
425
|
+
expect(flaky.engine.ingestExternalEvent({ event_id: "e1", type: "commerce.payment.succeeded", attempt_id: a1 })).toEqual({ applied: true, reason: "settled" });
|
|
426
|
+
expect(flaky.store.getExecution(stuck.id)!.state).toBe("settled");
|
|
427
|
+
expect(flaky.store.getOutbox(stuck.idempotency_key)!.status).toBe("done");
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
describe("probes named in the second review: the mandate can change between approve and execute", () => {
|
|
432
|
+
it("(a) the mandate was tightened to a smaller per-payment cap: the last gate refuses", async () => {
|
|
433
|
+
const h = harness({ mode: "human" });
|
|
434
|
+
const d = await h.engine.draft({ items: [{ payee: "escola", amount: 200000 }] });
|
|
435
|
+
if (!d.ok) throw new Error("refused");
|
|
436
|
+
const approved = h.engine.approve(d.execution.id, approver);
|
|
437
|
+
// A new process reads the tightened mandate (same version: an operator lowered the cap in place).
|
|
438
|
+
const tightened = harness({ mode: "human", dir: h.dir, mandate: { per_tx_cap_minor: 100000 } });
|
|
439
|
+
const out = await tightened.engine.execute(approved.id);
|
|
440
|
+
expect(out.state).toBe("denied");
|
|
441
|
+
expect(out.reason).toBe("per_tx_cap_exceeded");
|
|
442
|
+
expect(tightened.store.listOutbox()).toHaveLength(0);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("(a') the mandate was re-signed (new version): the old artifact no longer matches, and the yes that follows is judged again", async () => {
|
|
446
|
+
const h = harness({ mode: "human" });
|
|
447
|
+
const d = await h.engine.draft({ items: [{ payee: "escola", amount: 200000 }] });
|
|
448
|
+
if (!d.ok) throw new Error("refused");
|
|
449
|
+
const approved = h.engine.approve(d.execution.id, approver);
|
|
450
|
+
const resigned = harness({ mode: "human", dir: h.dir, mandate: { version: 2, per_tx_cap_minor: 100000 } });
|
|
451
|
+
const back = await resigned.engine.execute(approved.id);
|
|
452
|
+
expect(back.state).toBe("awaiting_approval");
|
|
453
|
+
expect(back.reason).toBe("mandate_changed");
|
|
454
|
+
expect(back.mandate).toEqual({ id: "mdt_test_0001", version: 2 });
|
|
455
|
+
const afterYes = resigned.engine.approve(back.id, approver);
|
|
456
|
+
expect(afterYes.state).toBe("denied");
|
|
457
|
+
expect(afterYes.reason).toBe("per_tx_cap_exceeded");
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("(c) the payee left the allowlist between approve and execute: denied, nothing sent", async () => {
|
|
461
|
+
const h = harness({ mode: "human" });
|
|
462
|
+
const d = await h.engine.draft({ items: [{ payee: "escola", amount: 1000 }] });
|
|
463
|
+
if (!d.ok) throw new Error("refused");
|
|
464
|
+
const approved = h.engine.approve(d.execution.id, approver);
|
|
465
|
+
const narrowed = harness({ mode: "human", dir: h.dir, mandate: { merchant_allowlist: [MERCADO], beneficiaries: [{ alias: "mercado", name: "Mercado do Bairro", payee: MERCADO }] } });
|
|
466
|
+
const out = await narrowed.engine.execute(approved.id);
|
|
467
|
+
expect(out.state).toBe("denied");
|
|
468
|
+
expect(out.reason).toBe("beneficiary_not_allowed");
|
|
469
|
+
expect(narrowed.store.listOutbox()).toHaveLength(0);
|
|
470
|
+
expect(narrowed.rail.payCount).toBe(0);
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
describe("reconcile is read-only; only resume dispatches, and only a pending outbox", () => {
|
|
475
|
+
it("outbox sent + lookup absent: reconcile never calls rail.pay and records execution.uncertain", async () => {
|
|
476
|
+
const h = harness({ mode: "mandate", manifest: { escalate_above: {} }, guardrails: { escalate_above: {} } });
|
|
477
|
+
const d = await h.engine.draft({ items: [{ payee: "escola", amount: 1000 }] });
|
|
478
|
+
if (!d.ok) throw new Error("refused");
|
|
479
|
+
const attempt = `att_${d.execution.idempotency_key.slice(4)}_0`;
|
|
480
|
+
const flaky = harness({ mode: "mandate", dir: h.dir, manifest: { escalate_above: {} }, guardrails: { escalate_above: {} }, rail: { uncertainOnce: [attempt] } });
|
|
481
|
+
const stuck = await flaky.engine.execute(d.execution.id);
|
|
482
|
+
expect(flaky.store.getOutbox(stuck.idempotency_key)?.status).toBe("sent");
|
|
483
|
+
const pays = flaky.rail.payCount;
|
|
484
|
+
const still = await flaky.engine.reconcile(stuck.id);
|
|
485
|
+
expect(still.state).toBe("executing");
|
|
486
|
+
expect(flaky.rail.payCount).toBe(pays);
|
|
487
|
+
expect(flaky.store.stubRailGet(attempt)).toBeUndefined();
|
|
488
|
+
expect(flaky.store.listEvents({ execution_id: stuck.id }).some((e) => e.type === "execution.uncertain")).toBe(true);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("outbox pending: reconcile leaves it; resumePending dispatches once", async () => {
|
|
492
|
+
const h = harness({ mode: "mandate", manifest: { escalate_above: {} }, guardrails: { escalate_above: {} } });
|
|
493
|
+
const d = await h.engine.draft({ items: [{ payee: "escola", amount: 1000 }] });
|
|
494
|
+
if (!d.ok) throw new Error("refused");
|
|
495
|
+
// Rewind the row to what a crash between the transaction and the first call leaves behind.
|
|
496
|
+
const armed = harness({ mode: "mandate", dir: h.dir, manifest: { escalate_above: {} }, guardrails: { escalate_above: {} }, rail: { afterDispatch: () => { throw new Error("crash"); } } });
|
|
497
|
+
await expect(armed.engine.execute(d.execution.id)).rejects.toThrow("crash");
|
|
498
|
+
armed.store.updateOutbox(d.execution.idempotency_key, "pending", undefined, "2026-09-23T18:00:00.000Z");
|
|
499
|
+
const fresh = harness({ mode: "mandate", dir: h.dir, manifest: { escalate_above: {} }, guardrails: { escalate_above: {} } });
|
|
500
|
+
const left = await fresh.engine.reconcile(d.execution.id);
|
|
501
|
+
expect(left.state).toBe("executing");
|
|
502
|
+
expect(fresh.rail.payCount).toBe(0);
|
|
503
|
+
const closed = await fresh.engine.resumePending(d.execution.id);
|
|
504
|
+
expect(closed.state).toBe("settled");
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
describe("multi-item partial failure is named item by item", () => {
|
|
509
|
+
it("first item settles with its receipt, second is refused: execution failed, outcomes and approval name both", async () => {
|
|
510
|
+
const h = harness({ mode: "human", rail: { refusePayees: [MERCADO] } });
|
|
511
|
+
const d = await h.engine.draft({ items: [{ payee: "escola", amount: 1000 }, { payee: "mercado", amount: 2000 }] });
|
|
512
|
+
if (!d.ok) throw new Error("refused");
|
|
513
|
+
const out = await h.engine.execute(h.engine.approve(d.execution.id, approver).id);
|
|
514
|
+
expect(out.state).toBe("failed");
|
|
515
|
+
expect(out.outcomes).toEqual([
|
|
516
|
+
expect.objectContaining({ index: 0, status: "settled", receipt_id: expect.stringMatching(/^rcpt_stub_/) }),
|
|
517
|
+
expect.objectContaining({ index: 1, status: "failed", error: expect.stringContaining("psp_dispatch_failed") }),
|
|
518
|
+
]);
|
|
519
|
+
expect(readdirSync(join(h.bundle.dir, "receipts"))).toHaveLength(1);
|
|
520
|
+
expect(h.bundle.readApprovals()[0]?.items.map((i) => i.payee)).toEqual([ESCOLA, MERCADO]);
|
|
521
|
+
expect(h.store.getOutbox(out.idempotency_key)?.status).toBe("failed");
|
|
522
|
+
expect(h.store.getOutbox(out.idempotency_key)?.response).toEqual(out.outcomes);
|
|
523
|
+
});
|
|
524
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { evaluateEscalation, isOutsideHours, localClock } from "../src/escalate.js";
|
|
3
|
+
import { GuardrailsSchema } from "../src/guardrails.js";
|
|
4
|
+
import type { ExecutionItem } from "../src/types.js";
|
|
5
|
+
|
|
6
|
+
const rule = { amount: 150000, new_beneficiary: true, outside_hours: "22:00-07:00" };
|
|
7
|
+
const guardrails = GuardrailsSchema.parse({ approval: "mandate", escalate_above: rule, velocity: { window_hours: 24 } });
|
|
8
|
+
const item = (payee: string, amount: number): ExecutionItem => ({ beneficiary: payee, payee, amount, currency: "BRL" });
|
|
9
|
+
// 15:00 in Sao Paulo (UTC-3) is 18:00Z.
|
|
10
|
+
const afternoon = new Date("2026-09-23T18:00:00Z");
|
|
11
|
+
const ctx = (over: Partial<Parameters<typeof evaluateEscalation>[3]> = {}) => ({ now: afternoon, timezone: "America/Sao_Paulo", knownPayees: new Set(["escola@x"]), recentByPayee: new Map(), ...over });
|
|
12
|
+
|
|
13
|
+
describe("section 4.4: escalate_above only tightens", () => {
|
|
14
|
+
it("amount above the threshold escalates, below it does not", () => {
|
|
15
|
+
expect(evaluateEscalation(rule, guardrails, [item("escola@x", 150001)], ctx())?.trigger).toBe("amount");
|
|
16
|
+
expect(evaluateEscalation(rule, guardrails, [item("escola@x", 150000)], ctx())).toBeUndefined();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("fractioning: parts below the threshold add up inside the velocity window", () => {
|
|
20
|
+
const recent = new Map([["escola@x", 120000]]);
|
|
21
|
+
const out = evaluateEscalation(rule, guardrails, [item("escola@x", 40000)], ctx({ recentByPayee: recent }));
|
|
22
|
+
expect(out?.trigger).toBe("amount");
|
|
23
|
+
expect(out?.detail).toContain("add up");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("first payment to a payee escalates; the second does not", () => {
|
|
27
|
+
expect(evaluateEscalation(rule, guardrails, [item("mercado@x", 1000)], ctx())?.trigger).toBe("new_beneficiary");
|
|
28
|
+
expect(evaluateEscalation(rule, guardrails, [item("escola@x", 1000)], ctx())).toBeUndefined();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("outside hours escalates, and the window crosses midnight", () => {
|
|
32
|
+
const night = new Date("2026-09-24T02:00:00Z"); // 23:00 in Sao Paulo
|
|
33
|
+
expect(localClock(night, "America/Sao_Paulo")).toBe("23:00");
|
|
34
|
+
expect(isOutsideHours("22:00-07:00", night, "America/Sao_Paulo")).toBe(true);
|
|
35
|
+
expect(isOutsideHours("22:00-07:00", afternoon, "America/Sao_Paulo")).toBe(false);
|
|
36
|
+
expect(evaluateEscalation(rule, guardrails, [item("escola@x", 1000)], ctx({ now: night }))?.trigger).toBe("outside_hours");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("no rule, no escalation", () => {
|
|
40
|
+
expect(evaluateEscalation(undefined, guardrails, [item("x", 10_000_000)], ctx())).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
});
|