diff --git a/skills/escalation-judge/SKILL.md b/skills/escalation-judge/SKILL.md new file mode 100644 index 000000000..4ac7a1c47 --- /dev/null +++ b/skills/escalation-judge/SKILL.md @@ -0,0 +1,77 @@ +--- +name: escalation-judge +version: 0.1.0 +description: Judge whether a support thread crosses named escalation thresholds, append one durable case record, and emit a typed packet for a separate governed dispatcher. +links: + source: https://github.com/runxhq/runx/tree/main/skills/escalation-judge +runx: + category: ops + input_resolution: + required: + - triage_packet + - thread_body + - policy_rules + - aggregate_id + - expected_version + - idempotency_key +--- + +## What this skill does + +`escalation-judge` reads a support triage packet, the original thread, named +escalation policy, and prior case projection. It grounds every decision in a +declared severity threshold or churn signal. When escalation is warranted it +appends one case event through `data-store@0.1.2` and emits a typed packet that +names, but never invokes, the downstream rail. + +The skill performs no Slack post, email send, paging action, legal notification, +or other egress. A separate governed `slack-notify` or `send-as` run is required. + +## Decision procedure + +1. Read the prior projection for `aggregate_id`, the stable support thread id. +2. Refuse automatic escalation when policy thresholds or lane mappings are absent. +3. Stop with `already_escalated` when the prior projection records an existing case. +4. Compare the supplied severity only with named `severity_thresholds`. +5. Match churn risk only against literal strings in `churn_risk_signals`. +6. Reject any route not declared by `escalation_lanes`. +7. On a match, append an idempotent `support.escalation.opened` event using the + caller's `expected_version` and `idempotency_key`. +8. Emit a typed escalation packet naming the target rail. Do not dispatch it. + +## Inputs + +- `triage_packet`: `{classification,severity,confidence}` from a bounded triage run. +- `thread_body`: source text used only to ground declared churn signals. +- `policy_rules`: `{severity_thresholds,churn_risk_signals,escalation_lanes}`. +- `aggregate_id`: support thread id and data-store aggregate key. +- `expected_version`: compare-and-swap version for the case append. +- `idempotency_key`: stable retry key for the event. +- `data_source_ref`: logical data source binding used by `data-store`. + +## Outputs and stops + +The decision packet contains `decision`, `case_id`, `case_event`, +`escalation_packet`, `stop_state`, and evidence. A normal non-match seals with +`decision.escalate=false`, no case event, no escalation packet, and reason +`no_change`. Missing policy or an undeclared lane returns `needs_human` without +opening a case. Ambiguous or invented severity is never promoted. + +## Example + +```bash +runx skill ./skills/escalation-judge \ + --input-json triage_packet='{"classification":"account_access","severity":"critical","confidence":0.96}' \ + --input thread_body='Enterprise renewal is at risk after a production lockout.' \ + --input-json policy_rules='{"severity_thresholds":{"executive_review":"critical"},"churn_risk_signals":["renewal is at risk"],"escalation_lanes":{"executive_review":"slack-notify"}}' \ + --input data_source_ref='local://escalation-judge/example' \ + --input aggregate_id='thread-123' \ + --input expected_version=0 \ + --input idempotency_key='thread-123:escalate:v1' \ + --json +``` + +Verify the resulting receipt with `runx verify --receipt --json`. +The packet's `target_rail` is an instruction for a separately authorized driver, +not proof that a message was sent. + diff --git a/skills/escalation-judge/X.yaml b/skills/escalation-judge/X.yaml new file mode 100644 index 000000000..a2fed38cb --- /dev/null +++ b/skills/escalation-judge/X.yaml @@ -0,0 +1,143 @@ +skill: escalation-judge +version: "0.1.0" + +catalog: + kind: skill + audience: public + visibility: public + role: canonical + +harness: + cases: + - name: escalate-critical-churn-risk + inputs: + data_source_ref: local://escalation-judge/harness + aggregate_id: thread-critical-001 + expected_version: 0 + idempotency_key: thread-critical-001:escalate:v1 + triage_packet: + classification: account_access + severity: critical + confidence: 0.96 + thread_body: Enterprise customer reports a production lockout and says renewal is at risk today. + policy_rules: + severity_thresholds: + priority_support: high + executive_review: critical + churn_risk_signals: + - renewal is at risk + escalation_lanes: + executive_review: slack-notify + priority_support: slack-notify + legal_review: send-as + expect: + status: sealed + - name: stop-no-policy-threshold + inputs: + data_source_ref: local://escalation-judge/harness + aggregate_id: thread-routine-002 + expected_version: 0 + idempotency_key: thread-routine-002:no-change:v1 + triage_packet: + classification: how_to + severity: low + confidence: 0.92 + thread_body: Customer asks where to find the setup guide. + policy_rules: + severity_thresholds: + priority_support: high + executive_review: critical + churn_risk_signals: + - renewal is at risk + escalation_lanes: + executive_review: slack-notify + priority_support: slack-notify + expect: + status: sealed + - name: refuse-missing-policy + inputs: + data_source_ref: local://escalation-judge/harness + aggregate_id: thread-missing-policy-003 + expected_version: 0 + idempotency_key: thread-missing-policy-003:refuse:v1 + triage_packet: + classification: bug + severity: high + confidence: 0.88 + thread_body: Production is unavailable. + policy_rules: {} + expect: + status: sealed + +emits: + - name: escalation_decision + packet: runx.decision.v1 + - name: escalation_packet + packet: runx.decision.v1 + +runners: + judge: + default: true + type: graph + inputs: + data_source_ref: + type: string + required: true + aggregate_id: + type: string + required: true + description: Stable support thread id. + expected_version: + type: number + required: true + idempotency_key: + type: string + required: true + triage_packet: + type: json + required: true + thread_body: + type: string + required: true + policy_rules: + type: json + required: true + graph: + name: escalation-judge + steps: + - id: read-prior-case + skill: ../data-store + runner: read_projection + inputs: + data_source_ref: "$input.data_source_ref" + store_id: escalation-judge-cases-v1 + resource: support_escalation_cases + aggregate_id: "$input.aggregate_id" + - id: decide + run: + type: cli-tool + command: node + args: + - graph/judge/run.mjs + context: + prior_case_projection: read-prior-case.data_operation_result.data.projection + artifacts: + named_emits: + escalation_decision: escalation_decision + packets: + escalation_decision: runx.decision.v1 + - id: append-case + when: + field: decide.escalation_decision.data.decision.escalate + equals: true + skill: ../data-store + runner: append_event + inputs: + data_source_ref: "$input.data_source_ref" + store_id: escalation-judge-cases-v1 + resource: support_escalation_cases + aggregate_id: "$input.aggregate_id" + expected_version: "$input.expected_version" + idempotency_key: "$input.idempotency_key" + context: + event: decide.escalation_decision.data.case_event diff --git a/skills/escalation-judge/fixtures/escalate-critical.json b/skills/escalation-judge/fixtures/escalate-critical.json new file mode 100644 index 000000000..5b0a54243 --- /dev/null +++ b/skills/escalation-judge/fixtures/escalate-critical.json @@ -0,0 +1,13 @@ +{ + "triage_packet": {"classification": "account_access", "severity": "critical", "confidence": 0.96}, + "thread_body": "Enterprise customer reports a production lockout and says renewal is at risk today.", + "policy_rules": { + "severity_thresholds": {"priority_support": "high", "executive_review": "critical"}, + "churn_risk_signals": ["renewal is at risk"], + "escalation_lanes": {"executive_review": "slack-notify", "priority_support": "slack-notify"} + }, + "aggregate_id": "thread-critical-001", + "expected_version": 0, + "idempotency_key": "thread-critical-001:escalate:v1" +} + diff --git a/skills/escalation-judge/fixtures/stop-no-change.json b/skills/escalation-judge/fixtures/stop-no-change.json new file mode 100644 index 000000000..32186eb40 --- /dev/null +++ b/skills/escalation-judge/fixtures/stop-no-change.json @@ -0,0 +1,12 @@ +{ + "triage_packet": {"classification": "how_to", "severity": "low", "confidence": 0.92}, + "thread_body": "Customer asks where to find the setup guide.", + "policy_rules": { + "severity_thresholds": {"priority_support": "high", "executive_review": "critical"}, + "churn_risk_signals": ["renewal is at risk"], + "escalation_lanes": {"executive_review": "slack-notify", "priority_support": "slack-notify"} + }, + "aggregate_id": "thread-routine-002", + "expected_version": 0, + "idempotency_key": "thread-routine-002:no-change:v1" +} diff --git a/skills/escalation-judge/graph/judge/SKILL.md b/skills/escalation-judge/graph/judge/SKILL.md new file mode 100644 index 000000000..556b84257 --- /dev/null +++ b/skills/escalation-judge/graph/judge/SKILL.md @@ -0,0 +1,14 @@ +--- +name: escalation-judge-decision +version: 0.1.0 +description: Deterministically evaluate a support escalation policy without sending or posting anything. +source: + type: cli-tool + command: node + args: + - run.mjs +--- + +Internal deterministic decision runner for `escalation-judge`. Use the public +parent skill rather than invoking this package directly. + diff --git a/skills/escalation-judge/graph/judge/X.yaml b/skills/escalation-judge/graph/judge/X.yaml new file mode 100644 index 000000000..cd74a6877 --- /dev/null +++ b/skills/escalation-judge/graph/judge/X.yaml @@ -0,0 +1,41 @@ +skill: escalation-judge-decision +version: "0.1.0" + +catalog: + kind: skill + audience: public + visibility: public + role: context + +runners: + decide: + default: true + type: cli-tool + command: node + args: + - run.mjs + inputs: + triage_packet: + type: json + required: true + thread_body: + type: string + required: true + policy_rules: + type: json + required: true + aggregate_id: + type: string + required: true + expected_version: + type: number + required: true + idempotency_key: + type: string + required: true + prior_case_projection: + type: json + required: false + artifacts: + named_emits: + escalation_decision: runx.business.lane.v1 diff --git a/skills/escalation-judge/graph/judge/run.mjs b/skills/escalation-judge/graph/judge/run.mjs new file mode 100644 index 000000000..57b764951 --- /dev/null +++ b/skills/escalation-judge/graph/judge/run.mjs @@ -0,0 +1,113 @@ +import fs from "node:fs"; +import crypto from "node:crypto"; + +const input = readInputs(); +const triage = object(input.triage_packet); +const policy = object(input.policy_rules); +const threadBody = text(input.thread_body); +const aggregateId = text(input.aggregate_id); +const lanes = object(policy.escalation_lanes); +const thresholds = object(policy.severity_thresholds); +const churnSignals = array(policy.churn_risk_signals).map((value) => text(value).toLowerCase()); +const prior = object(input.prior_case_projection); + +const evidence = { + severity: text(triage.severity).toLowerCase() || null, + confidence: number(triage.confidence), + matched_policy_threshold: null, + matched_churn_signals: [], + prior_case_projection: prior, + named_target_rail: null, +}; + +let decision = { escalate: false, lane: null, reason: "no_change" }; +let stopState = { state: "no_change", reason: "No named policy threshold was crossed." }; + +if (!Object.keys(thresholds).length || !Object.keys(lanes).length) { + stopState = { state: "needs_human", reason: "policy_rules must declare severity_thresholds and escalation_lanes" }; + decision.reason = "missing_policy_rules"; +} else if (prior.case_id || prior.escalated === true) { + stopState = { state: "no_change", reason: "A prior escalation case already exists for this thread." }; + decision.reason = "already_escalated"; +} else { + const severityMatch = strongestSeverityMatch(evidence.severity, thresholds); + evidence.matched_churn_signals = churnSignals.filter((signal) => signal && threadBody.toLowerCase().includes(signal)); + const requestedLane = severityMatch?.lane || (evidence.matched_churn_signals.length ? chooseChurnLane(lanes) : null); + if (requestedLane && Object.hasOwn(lanes, requestedLane)) { + evidence.matched_policy_threshold = severityMatch?.threshold || `churn:${evidence.matched_churn_signals[0]}`; + evidence.named_target_rail = text(lanes[requestedLane]); + decision = { + escalate: true, + lane: requestedLane, + reason: severityMatch ? "named_severity_threshold_crossed" : "named_churn_signal_matched", + }; + stopState = { state: "continue", reason: "Escalation packet is ready for a separate governed dispatcher." }; + } else if (requestedLane) { + stopState = { state: "needs_human", reason: "Matched policy lane is not declared in escalation_lanes." }; + decision.reason = "undeclared_lane"; + } +} + +const caseId = decision.escalate ? stableCaseId(aggregateId, input.idempotency_key) : null; +const escalationPacket = decision.escalate ? { + type: "runx.support.escalation_packet.v1", + case_id: caseId, + aggregate_id: aggregateId, + lane: decision.lane, + reason: decision.reason, + target_rail: evidence.named_target_rail, + dispatch: "named_only", + consequence: "A downstream operator issues a separate governed run; this skill performs no post or send.", +} : null; +const caseEvent = decision.escalate ? { + type: "support.escalation.opened", + payload: { + case_id: caseId, + aggregate_id: aggregateId, + decision, + target_rail: evidence.named_target_rail, + expected_version: number(input.expected_version), + idempotency_key: text(input.idempotency_key), + }, +} : null; + +const escalationDecision = { + decision, + case_id: caseId, + case_event: caseEvent, + escalation_packet: escalationPacket, + stop_state: stopState, + evidence, +}; + +process.stdout.write(`${JSON.stringify({ escalation_decision: escalationDecision }, null, 2)}\n`); + +function strongestSeverityMatch(severity, configured) { + const rank = { low: 1, medium: 2, high: 3, critical: 4 }; + const actual = rank[severity] || 0; + return Object.entries(configured) + .map(([lane, threshold]) => ({ lane, threshold: text(threshold).toLowerCase(), rank: rank[text(threshold).toLowerCase()] || 99 })) + .filter((candidate) => actual >= candidate.rank) + .sort((a, b) => b.rank - a.rank)[0] || null; +} + +function chooseChurnLane(configured) { + if (Object.hasOwn(configured, "priority_support")) return "priority_support"; + return Object.keys(configured)[0] || null; +} + +function stableCaseId(aggregateIdValue, key) { + const digest = crypto.createHash("sha256").update(`${aggregateIdValue}:${text(key)}`).digest("hex").slice(0, 12); + return `case-${digest}`; +} + +function readInputs() { + if (process.env.RUNX_INPUTS_PATH) return JSON.parse(fs.readFileSync(process.env.RUNX_INPUTS_PATH, "utf8")); + if (process.env.RUNX_INPUTS_JSON) return JSON.parse(process.env.RUNX_INPUTS_JSON); + return {}; +} + +function object(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } +function array(value) { return Array.isArray(value) ? value : []; } +function text(value) { return typeof value === "string" ? value.trim() : ""; } +function number(value) { return Number.isFinite(Number(value)) ? Number(value) : null; } diff --git a/skills/escalation-judge/harness-evidence/local-harness.json b/skills/escalation-judge/harness-evidence/local-harness.json new file mode 100644 index 000000000..d275c196c --- /dev/null +++ b/skills/escalation-judge/harness-evidence/local-harness.json @@ -0,0 +1,20 @@ +{ + "captured_at": "2026-06-30T00:00:00Z", + "runx_version": "runx-cli 0.6.14", + "command": "runx harness ./skills/escalation-judge --json", + "status": "passed", + "case_count": 3, + "assertion_error_count": 0, + "case_names": [ + "escalate-critical-churn-risk", + "stop-no-policy-threshold", + "refuse-missing-policy" + ], + "receipt_ids": [ + "sha256:7ecf19582e08f907c8b1aeab49df09e6239c62d07dcdba29fb6788c43bdde77e", + "sha256:f76f94d071685e24ce3be7b77952a718b824d544bf2928a47745033a422e4a84", + "sha256:b407147429bd887c828e3bd18f9bafac3fd31a213e9bb6e634f0b1d09c368dec" + ], + "scope": "Local pre-publish harness only; hosted harness and post-publish dogfood receipt are intentionally not claimed here." +} + diff --git a/skills/escalation-judge/harness-evidence/report.md b/skills/escalation-judge/harness-evidence/report.md new file mode 100644 index 000000000..a808f910d --- /dev/null +++ b/skills/escalation-judge/harness-evidence/report.md @@ -0,0 +1,12 @@ +# Escalation Judge Local Verification + +- Package: `escalation-judge@0.1.0` +- CLI: `runx-cli 0.6.14` +- Inspect: `runx skill inspect ./skills/escalation-judge --json` returned `status: ok`. +- Harness: `runx harness ./skills/escalation-judge --json` passed all three cases. +- Escalation case: critical severity matched the named `executive_review` threshold, read the prior projection, appended a deterministic case id, and named `slack-notify` without dispatching it. +- Stop case: low severity matched no threshold, emitted no packet, opened no case, and returned `no_change`. +- Refusal case: missing policy rules returned `needs_human` without opening a case. +- State: the graph uses `data-store@0.1.2` in `read_projection -> decide -> append_event` order with a pinned `store_id`. +- Safety: the package never posts, sends, pages, or invokes the named target rail. +- Remaining publish proof: registry listing, hosted harness, clean install, dogfood receipt, and public receipt verification are produced after publish authorization.