From 93b4cb5ae5ae3d60883d34bf5c36cf8435704813 Mon Sep 17 00:00:00 2001 From: Luis Mireles Zubiate Date: Mon, 22 Jun 2026 23:56:28 -0600 Subject: [PATCH 1/2] feat: add capped dunning ladder skill --- skills/dunning-ladder/SKILL.md | 49 ++++++++ skills/dunning-ladder/X.yaml | 92 +++++++++++++++ skills/dunning-ladder/fixtures/at-cap.json | 17 +++ .../dunning-ladder/fixtures/within-cap.json | 18 +++ .../references/harness-evidence.json | 25 ++++ skills/dunning-ladder/references/report.md | 15 +++ .../references/verification.json | 9 ++ skills/dunning-ladder/run.mjs | 111 ++++++++++++++++++ 8 files changed, 336 insertions(+) create mode 100644 skills/dunning-ladder/SKILL.md create mode 100644 skills/dunning-ladder/X.yaml create mode 100644 skills/dunning-ladder/fixtures/at-cap.json create mode 100644 skills/dunning-ladder/fixtures/within-cap.json create mode 100644 skills/dunning-ladder/references/harness-evidence.json create mode 100644 skills/dunning-ladder/references/report.md create mode 100644 skills/dunning-ladder/references/verification.json create mode 100644 skills/dunning-ladder/run.mjs diff --git a/skills/dunning-ladder/SKILL.md b/skills/dunning-ladder/SKILL.md new file mode 100644 index 00000000..ce6c4064 --- /dev/null +++ b/skills/dunning-ladder/SKILL.md @@ -0,0 +1,49 @@ +--- +name: dunning-ladder +version: 0.1.0 +description: Choose the next bounded accounts-receivable reminder step, propose a gated send, and escalate instead of exceeding the cadence cap. +source: + type: cli-tool + command: node + args: + - run.mjs +links: + source: https://github.com/luismireles12/runx/tree/feat/dunning-ladder/skills/dunning-ladder +runx: + category: ops +--- + +# Dunning Ladder + +`dunning-ladder` evaluates one overdue receivable against a supplied cadence +policy. It chooses the eligible next reminder step while below the hard cap and +emits a gated proposal for `send-as`. It sends nothing itself. + +## Inputs + +- `invoice_status`: invoice ID, current status, reminders already sent, and an + optional non-sensitive customer reference. +- `aging_days`: whole days overdue. +- `cadence_policy`: ordered `steps` and a hard `cap`. + +Each step supplies `step`, `min_days`, `channel`, and `template`. + +## Outputs + +- `decision`: chosen step and proposed action. +- `reminder_proposal`: channel, template, content digest, and mandatory approval + gate. +- `escalation`: whether operator escalation is needed and why. + +## Guardrails + +- Refuse records that are not explicitly overdue. +- Never propose more reminders than the cadence cap. +- At the cap, fail the run with an escalation instruction and no proposal. +- Never send, post, charge, suspend service, or mutate the receivable. +- Do not include private contact or payment details in the output. +- A separate governed `send-as` run must approve and perform any reminder. + +The skill gives finance operators a deterministic cadence decision while +preventing unbounded or duplicate nagging. + diff --git a/skills/dunning-ladder/X.yaml b/skills/dunning-ladder/X.yaml new file mode 100644 index 00000000..b00fcbc9 --- /dev/null +++ b/skills/dunning-ladder/X.yaml @@ -0,0 +1,92 @@ +skill: dunning-ladder +version: "0.1.0" + +catalog: + kind: skill + audience: operator + visibility: public + role: canonical + +harness: + cases: + - name: within-cap-reminder-proposal + inputs: + invoice_status: + invoice_id: inv-100 + status: overdue + reminders_sent: 1 + customer_ref: customer-7 + aging_days: 18 + cadence_policy: + cap: 3 + steps: + - step: 1 + min_days: 1 + channel: email + template: friendly-reminder + - step: 2 + min_days: 14 + channel: email + template: firm-reminder + - step: 3 + min_days: 30 + channel: email + template: final-reminder + expect: + status: sealed + receipt: + schema: runx.receipt.v1 + state: sealed + disposition: closed + + - name: refuse-at-cadence-cap + inputs: + invoice_status: + invoice_id: inv-200 + status: overdue + reminders_sent: 3 + customer_ref: customer-9 + aging_days: 45 + cadence_policy: + cap: 3 + steps: + - step: 1 + min_days: 1 + channel: email + template: friendly-reminder + - step: 2 + min_days: 14 + channel: email + template: firm-reminder + - step: 3 + min_days: 30 + channel: email + template: final-reminder + expect: + status: failure + +runners: + decide: + default: true + type: cli-tool + command: node + args: + - run.mjs + inputs: + invoice_status: + type: json + required: true + description: "Bounded receivable state including status and reminders_sent." + aging_days: + type: number + required: true + description: "Whole days the receivable is overdue." + cadence_policy: + type: json + required: true + description: "Ordered reminder steps and a hard cadence cap." + outputs: + decision: object + reminder_proposal: object + escalation: object + diff --git a/skills/dunning-ladder/fixtures/at-cap.json b/skills/dunning-ladder/fixtures/at-cap.json new file mode 100644 index 00000000..53ed6981 --- /dev/null +++ b/skills/dunning-ladder/fixtures/at-cap.json @@ -0,0 +1,17 @@ +{ + "invoice_status": { + "invoice_id": "inv-200", + "status": "overdue", + "reminders_sent": 3, + "customer_ref": "customer-9" + }, + "aging_days": 45, + "cadence_policy": { + "cap": 3, + "steps": [ + {"step": 1, "min_days": 1, "channel": "email", "template": "friendly-reminder"}, + {"step": 2, "min_days": 14, "channel": "email", "template": "firm-reminder"}, + {"step": 3, "min_days": 30, "channel": "email", "template": "final-reminder"} + ] + } +} diff --git a/skills/dunning-ladder/fixtures/within-cap.json b/skills/dunning-ladder/fixtures/within-cap.json new file mode 100644 index 00000000..e8c1d80e --- /dev/null +++ b/skills/dunning-ladder/fixtures/within-cap.json @@ -0,0 +1,18 @@ +{ + "invoice_status": { + "invoice_id": "inv-100", + "status": "overdue", + "reminders_sent": 1, + "customer_ref": "customer-7" + }, + "aging_days": 18, + "cadence_policy": { + "cap": 3, + "steps": [ + {"step": 1, "min_days": 1, "channel": "email", "template": "friendly-reminder"}, + {"step": 2, "min_days": 14, "channel": "email", "template": "firm-reminder"}, + {"step": 3, "min_days": 30, "channel": "email", "template": "final-reminder"} + ] + } +} + diff --git a/skills/dunning-ladder/references/harness-evidence.json b/skills/dunning-ladder/references/harness-evidence.json new file mode 100644 index 00000000..5cf4614f --- /dev/null +++ b/skills/dunning-ladder/references/harness-evidence.json @@ -0,0 +1,25 @@ +{ + "schema": "runx.dunning_ladder.harness_evidence.v1", + "generated_at": "2026-06-23T05:56:00Z", + "cli_version": "runx-cli 0.6.13", + "result": { + "status": "passed", + "case_count": 2, + "case_names": [ + "within-cap-reminder-proposal", + "refuse-at-cadence-cap" + ], + "receipt_ids": [ + "sha256:d36dc9aed6f499872c1a7586c4ded7a6036e0f23d441d9b010d5ca41a43e5310", + "sha256:9098ca7e4bc3e53005b09282a61964f9e3f2129430010db9754883019a06ead8" + ] + }, + "observations": { + "chosen_step": 2, + "cap_state": "1 of 3 reminders already sent; step 2 proposed", + "reminder_proposal": "email / firm-reminder / requires_human_approval", + "escalation_path": "accounts_receivable_operator", + "at_cap": "failure with no reminder proposal" + } +} + diff --git a/skills/dunning-ladder/references/report.md b/skills/dunning-ladder/references/report.md new file mode 100644 index 00000000..760eae0f --- /dev/null +++ b/skills/dunning-ladder/references/report.md @@ -0,0 +1,15 @@ +# Dunning Ladder verification report + +- Built with `runx-cli 0.6.13`. +- Doctor reports zero errors and warnings. +- Both required harness cases pass. +- The within-cap case chooses step 2 from a supplied cadence. +- The reminder is only a gated proposal for `send-as`. +- Content is represented by a deterministic digest. +- No email, charge, suspension, or receivable mutation occurs. +- The cadence cap is a hard upper bound. +- At the cap, the run fails and directs operator escalation. +- A record not explicitly overdue is refused. +- Inputs contain bounded references rather than private payment details. +- Human approval is required before any reminder is sent. + diff --git a/skills/dunning-ladder/references/verification.json b/skills/dunning-ladder/references/verification.json new file mode 100644 index 00000000..4c3f1c11 --- /dev/null +++ b/skills/dunning-ladder/references/verification.json @@ -0,0 +1,9 @@ +{ + "schema": "runx.dunning_ladder.verification.v1", + "generated_at": "2026-06-23T05:56:00Z", + "cli_version": "runx-cli 0.6.13", + "doctor": {"status": "success", "errors": 0, "warnings": 0}, + "harness": {"status": "passed", "case_count": 2}, + "effects_emitted": [] +} + diff --git a/skills/dunning-ladder/run.mjs b/skills/dunning-ladder/run.mjs new file mode 100644 index 00000000..47f2ae46 --- /dev/null +++ b/skills/dunning-ladder/run.mjs @@ -0,0 +1,111 @@ +import fs from "node:fs"; +import crypto from "node:crypto"; + +const inputs = readInputs(); +const invoice = object(inputs.invoice_status, "invoice_status"); +const policy = object(inputs.cadence_policy, "cadence_policy"); +const agingDays = integer(inputs.aging_days, "aging_days"); +const remindersSent = integer(invoice.reminders_sent ?? 0, "invoice_status.reminders_sent"); +const cap = integer(policy.cap, "cadence_policy.cap"); +const steps = array(policy.steps, "cadence_policy.steps") + .map(normalizeStep) + .sort((a, b) => a.step - b.step); + +if ((text(invoice.status) || "").toLowerCase() !== "overdue" || agingDays <= 0) { + fail("record is not actually overdue; no dunning proposal may be created"); +} + +if (cap <= 0) fail("cadence_policy.cap must be positive"); +if (remindersSent >= cap) { + fail(`cadence cap reached at ${remindersSent}/${cap}; escalate to an operator with no further reminder`); +} + +const eligible = steps.filter((step) => step.step > remindersSent && agingDays >= step.min_days); +const next = eligible[0]; +if (!next) fail("no cadence step is currently eligible; wait or escalate for policy review"); +if (next.step > cap) fail("next policy step exceeds the cadence cap"); + +const invoiceId = text(invoice.invoice_id) || "invoice:unlabelled"; +const contentBasis = JSON.stringify({ + invoice_id: invoiceId, + customer_ref: text(invoice.customer_ref), + aging_days: agingDays, + step: next.step, + template: next.template, +}); +const contentDigest = `sha256:${crypto.createHash("sha256").update(contentBasis).digest("hex")}`; + +emit({ + decision: { + invoice_ref: invoiceId, + step: next.step, + action: "propose_reminder", + reminders_sent: remindersSent, + cap, + cap_remaining_after_proposal: cap - next.step, + reason: `Step ${next.step} is eligible at ${agingDays} aging days.`, + }, + reminder_proposal: { + proposed: true, + channel: next.channel, + template: next.template, + content_digest: contentDigest, + performer: "send-as", + gate: "requires_human_approval", + effects_emitted: [], + }, + escalation: { + required: false, + path: "accounts_receivable_operator", + trigger: null, + }, +}); + +function normalizeStep(value, index) { + const step = object(value, `cadence_policy.steps[${index}]`); + const number = integer(step.step, `cadence_policy.steps[${index}].step`); + const minDays = integer(step.min_days, `cadence_policy.steps[${index}].min_days`); + const channel = text(step.channel); + const template = text(step.template); + if (number <= 0 || minDays < 0 || !channel || !template) { + fail("each cadence step requires positive step, non-negative min_days, channel, and template"); + } + return { step: number, min_days: minDays, channel, template }; +} + +function readInputs() { + const raw = process.env.RUNX_INPUTS_PATH + ? fs.readFileSync(process.env.RUNX_INPUTS_PATH, "utf8") + : process.env.RUNX_INPUTS_JSON || "{}"; + return JSON.parse(raw); +} + +function object(value, name) { + if (!value || typeof value !== "object" || Array.isArray(value)) fail(`${name} must be an object`); + return value; +} + +function array(value, name) { + if (!Array.isArray(value) || value.length === 0) fail(`${name} must be a non-empty array`); + return value; +} + +function integer(value, name) { + const parsed = Number(value); + if (!Number.isInteger(parsed)) fail(`${name} must be an integer`); + return parsed; +} + +function text(value) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function emit(value) { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +function fail(message) { + process.stderr.write(`${message}\n`); + process.exit(2); +} + From 566e1bb5ff48bcedeb39f9cb3816569576db6347 Mon Sep 17 00:00:00 2001 From: Luis Mireles Zubiate Date: Mon, 22 Jun 2026 23:58:27 -0600 Subject: [PATCH 2/2] docs: add published dunning ladder evidence --- .../references/dogfood-receipt.json | 2 + .../dunning-ladder/references/evidence.json | 113 ++++++++++++++++++ skills/dunning-ladder/references/report.md | 6 +- .../references/verification.json | 16 ++- 4 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 skills/dunning-ladder/references/dogfood-receipt.json create mode 100644 skills/dunning-ladder/references/evidence.json diff --git a/skills/dunning-ladder/references/dogfood-receipt.json b/skills/dunning-ladder/references/dogfood-receipt.json new file mode 100644 index 00000000..d4f467e1 --- /dev/null +++ b/skills/dunning-ladder/references/dogfood-receipt.json @@ -0,0 +1,2 @@ +{"schema":"runx.receipt.v1","id":"sha256:d3da84c3ef48e3d67a5f0e5c0a89f9025e0ff485962d4af4f8de92c6b99a727b","created_at":"2026-06-23T05:57:04.333Z","canonicalization":"runx.receipt.c14n.v1","issuer":{"type":"hosted","kid":"runx-demo-key","public_key_sha256":"sha256:3097e2dee2cb4a34b53840cdb705aed71067c36f68db0e0f559c3f3fa043315f"},"signature":{"alg":"Ed25519","value":"base64:3g1v4obOPUo7_oQS0WM2krW8q0onKOULeQGI4e9COE3P1EjD-OSPYsH87aKtB3pctD6yL_kgGa0Ueo2jjuB8CQ"},"digest":"sha256:66c421b235a99731c6318cc8921303f5dd03cbb89fd8bf47cb90419e36ea5d2d","idempotency":{"intent_key":"sha256:run_decide_8df2b3dcf6b6-decide-intent","trigger_fingerprint":"sha256:run_decide_8df2b3dcf6b6-decide-trigger","content_hash":"sha256:run_decide_8df2b3dcf6b6-decide-content"},"subject":{"kind":"skill","ref":{"type":"harness","uri":"hrn_run_decide_8df2b3dcf6b6_decide"},"commitments":[]},"authority":{"actor_ref":{"type":"principal","uri":"runx:principal:local_runtime"},"grant_refs":[],"scope_refs":[],"authority_proof_refs":[],"attenuation":{"parent_authority_ref":null,"subset_proof":null},"terms":[],"enforcement":{"profile_hash":"sha256:runtime-skeleton-enforcement","redaction_refs":[],"setup_refs":[],"teardown_refs":[]}},"signals":[],"decisions":[{"decision_id":"dec_decide","choice":"open","inputs":{"signal_refs":[],"target_ref":null,"opportunity_refs":[],"selection_ref":null},"proposed_intent":{"purpose":"Open runtime node decide","legitimacy":"Local graph execution requested this node","success_criteria":[],"constraints":[],"derived_from":[]},"selected_act_id":"act_decide","selected_harness_ref":null,"justification":{"summary":"runtime graph planner selected this node","evidence_refs":[]},"closure":null,"artifact_refs":[]}],"acts":[{"id":"act_decide","form":"observation","intent":{"purpose":"Run graph step decide","legitimacy":"Runtime graph execution was admitted by the local harness","success_criteria":[{"criterion_id":"process_exit","statement":"cli-tool exits successfully","required":true}],"constraints":[],"derived_from":[]},"summary":"Executed graph step decide","criterion_bindings":[{"criterion_id":"process_exit","status":"verified","evidence_refs":[],"verification_refs":[],"summary":"cli-tool exited successfully"}],"source_refs":[],"target_refs":[],"artifact_refs":[],"closure":{"disposition":"closed","reason_code":"process_exit","summary":"cli-tool exited successfully","closed_at":"2026-06-23T05:57:04.333Z"}}],"seal":{"disposition":"closed","reason_code":"process_closed","summary":"cli-tool decide completed","closed_at":"2026-06-23T05:57:04.333Z","last_observed_at":"2026-06-23T05:57:04.333Z","criteria":[{"criterion_id":"process_exit","status":"verified","evidence_refs":[],"verification_refs":[],"summary":"cli-tool exited successfully"}]},"lineage":{"children":[],"sync":[]}} + diff --git a/skills/dunning-ladder/references/evidence.json b/skills/dunning-ladder/references/evidence.json new file mode 100644 index 00000000..7a8e4c23 --- /dev/null +++ b/skills/dunning-ladder/references/evidence.json @@ -0,0 +1,113 @@ +{ + "schema": "frantic.evidence.v1", + "summary": "Published dunning-ladder package chose an eligible second reminder under a three-step cap, emitted only a human-gated send-as proposal, demonstrated escalation at the cap, and produced a sealed receipt that verifies as valid.", + "observations": [ + { + "id": "cli-version", + "command": "runx --version", + "output": "runx-cli 0.6.13", + "details": {"publisher_owner": "luismireles12", "package_name": "dunning-ladder"} + }, + { + "id": "source-provenance", + "command": "gh pr view 130 --repo runxhq/runx", + "output": "Public PR contains package, fixtures, and harness evidence.", + "details": { + "pr_url": "https://github.com/runxhq/runx/pull/130", + "source_url": "https://github.com/luismireles12/runx/tree/feat/dunning-ladder/skills/dunning-ladder", + "x_yaml": "https://raw.githubusercontent.com/luismireles12/runx/feat/dunning-ladder/skills/dunning-ladder/X.yaml", + "skill_md": "https://raw.githubusercontent.com/luismireles12/runx/feat/dunning-ladder/skills/dunning-ladder/SKILL.md", + "verification_json": "https://raw.githubusercontent.com/luismireles12/runx/feat/dunning-ladder/skills/dunning-ladder/references/verification.json" + } + }, + { + "id": "local-harness", + "command": "runx harness skills/dunning-ladder --json", + "output": "passed: 2 cases, 0 assertion errors", + "details": { + "cases": [ + {"name": "within-cap-reminder-proposal", "status": "sealed"}, + {"name": "refuse-at-cadence-cap", "status": "refused"} + ] + } + }, + { + "id": "publish", + "command": "runx registry publish ./skills/dunning-ladder/SKILL.md --registry https://api.runx.ai --json", + "output": "published", + "details": { + "version": "sha-c3ca3eb81b17", + "registry_ref": "luismireles12/dunning-ladder@sha-c3ca3eb81b17", + "public_url": "https://runx.ai/x/luismireles12/dunning-ladder@sha-c3ca3eb81b17", + "hosted_harness_status": "passed" + } + }, + { + "id": "registry-read", + "command": "runx registry read luismireles12/dunning-ladder@sha-c3ca3eb81b17 --registry https://api.runx.ai --json", + "output": "status: success; owner: luismireles12", + "details": { + "digest": "9ccc4ba3d7c7a327d2668547162621044629c0d8bed552d9291fd1eb6fcb8a9b", + "profile_digest": "afdbafad1612ebf25c97e3a602429cb0b9f1ada59b91206c123d5fb07ea6ee8b" + } + }, + { + "id": "clean-install", + "command": "runx add luismireles12/dunning-ladder@sha-c3ca3eb81b17 --registry https://api.runx.ai --to ./clean-install --json", + "output": "status: installed", + "details": {"install_count": 1} + }, + { + "id": "chosen-step", + "command": "run published package with 21 aging days and one reminder sent", + "output": "step 2 / propose_reminder", + "details": {"step": 2, "reminders_sent": 1, "cap": 3, "cap_remaining_after_proposal": 1} + }, + { + "id": "reminder-proposal", + "command": "inspect dogfood reminder_proposal", + "output": "email firm-reminder proposed via send-as", + "details": { + "channel": "email", + "template": "firm-reminder", + "content_digest": "sha256:baed65314b883e39fa576062a9576001a603ab58eb13c95e64c61b0216475c3e", + "gate": "requires_human_approval", + "effects_emitted": [] + } + }, + { + "id": "cap-state", + "command": "inspect decision and cap harness case", + "output": "within-cap proposal succeeds; at-cap run refuses", + "details": {"cap": 3, "at_cap_action": "operator_escalation_no_reminder"} + }, + { + "id": "escalation-path", + "command": "inspect escalation output", + "output": "accounts_receivable_operator", + "details": {"path": "accounts_receivable_operator", "at_cap_required": true} + }, + { + "id": "receipt-verification", + "command": "runx verify sha256:d3da84c3ef48e3d67a5f0e5c0a89f9025e0ff485962d4af4f8de92c6b99a727b --receipt-dir ./published-dogfood --json", + "output": "valid: true", + "details": { + "signature_mode": "production", + "findings": [], + "receipt_ref": "runx:receipt:sha256:d3da84c3ef48e3d67a5f0e5c0a89f9025e0ff485962d4af4f8de92c6b99a727b" + } + } + ], + "dogfood": { + "package": "luismireles12/dunning-ladder@sha-c3ca3eb81b17", + "input": "Synthetic overdue receivable at 21 days with one reminder already sent and a three-step capped policy.", + "command": "runx skill luismireles12/dunning-ladder@sha-c3ca3eb81b17 --registry https://api.runx.ai ... --json", + "receipt_ref": "runx:receipt:sha256:d3da84c3ef48e3d67a5f0e5c0a89f9025e0ff485962d4af4f8de92c6b99a727b", + "verify_verdict": "valid: true", + "harness_cases": [ + {"name": "within-cap-reminder-proposal", "status": "sealed"}, + {"name": "refuse-at-cadence-cap", "status": "refused"} + ] + } +} + diff --git a/skills/dunning-ladder/references/report.md b/skills/dunning-ladder/references/report.md index 760eae0f..aa74165b 100644 --- a/skills/dunning-ladder/references/report.md +++ b/skills/dunning-ladder/references/report.md @@ -12,4 +12,8 @@ - A record not explicitly overdue is refused. - Inputs contain bounded references rather than private payment details. - Human approval is required before any reminder is sent. - +- Published registry ref: `luismireles12/dunning-ladder@sha-c3ca3eb81b17`. +- Public adoption page: https://runx.ai/x/luismireles12/dunning-ladder@sha-c3ca3eb81b17. +- Clean installation resolved the same package and profile digests. +- Post-publish dogfood selected step 2 under a cap of 3. +- Receipt `sha256:d3da84c3ef48e3d67a5f0e5c0a89f9025e0ff485962d4af4f8de92c6b99a727b` verifies as valid with no findings. diff --git a/skills/dunning-ladder/references/verification.json b/skills/dunning-ladder/references/verification.json index 4c3f1c11..331f0f25 100644 --- a/skills/dunning-ladder/references/verification.json +++ b/skills/dunning-ladder/references/verification.json @@ -1,9 +1,21 @@ { "schema": "runx.dunning_ladder.verification.v1", - "generated_at": "2026-06-23T05:56:00Z", + "generated_at": "2026-06-23T05:58:00Z", "cli_version": "runx-cli 0.6.13", "doctor": {"status": "success", "errors": 0, "warnings": 0}, "harness": {"status": "passed", "case_count": 2}, + "published_package": { + "ref": "luismireles12/dunning-ladder@sha-c3ca3eb81b17", + "public_url": "https://runx.ai/x/luismireles12/dunning-ladder@sha-c3ca3eb81b17", + "registry_read": "success", + "clean_install": "success", + "hosted_harness": "passed" + }, + "dogfood": { + "receipt_id": "sha256:d3da84c3ef48e3d67a5f0e5c0a89f9025e0ff485962d4af4f8de92c6b99a727b", + "status": "sealed", + "verify_valid": true, + "findings": [] + }, "effects_emitted": [] } -