diff --git a/skills/reply-router/REPORT.md b/skills/reply-router/REPORT.md new file mode 100644 index 000000000..d88c2c57b --- /dev/null +++ b/skills/reply-router/REPORT.md @@ -0,0 +1,22 @@ +# Reply Router Bounty Report + +## What was built + +- Added `skills/reply-router` as a public runx skill with a default `route_reply` agent-task runner. +- Added typed inputs for inbound reply content, original send receipt, suppression policy, recipient stream coordinates, optimistic concurrency, and idempotency. +- Added typed outputs for `classification`, `suppression_result`, `routing_decision`, and `escalation_lane`. +- Added inline harness coverage for sealed unsubscribe suppression, sealed interested routing, and ambiguous/unsealed stop behavior. + +## Behavioral boundaries + +- Unsubscribe replies emit a ready-to-append suppression packet with the recipient aggregate, expected version, idempotency key, and `reply.unsubscribe_suppressed` event payload. +- Interested replies emit a bounded `runx.reply.routing.v1` packet naming a later governed `send-as` run. +- The skill never sends a message or mutates a provider directly. +- Ambiguous replies or unsealed original send receipts stop with `needs_agent`. + +## Verification + +- `runx-cli 0.6.14` is available through `npx.cmd -y @runxhq/cli@0.6.14`. +- `runx skill inspect ./skills/reply-router route_reply --json` passes with status `ok`. +- Local Windows harness and registry publish are blocked by the native sealed receipt store error `os error 87` while syncing the receipt directory. +- The blocker is environmental to this Windows host; the skill package itself parses and inspects successfully. diff --git a/skills/reply-router/SKILL.md b/skills/reply-router/SKILL.md new file mode 100644 index 000000000..54f55a7cd --- /dev/null +++ b/skills/reply-router/SKILL.md @@ -0,0 +1,166 @@ +--- +name: reply-router +description: Classify inbound replies against a suppression policy, emit durable suppression packets, and produce bounded routing decisions without sending. +runx: + category: ops +--- + +# Reply Router + +Reply Router handles inbound replies to previously governed outbound sends. It +protects the compliance boundary: an unsubscribe reply becomes a ready-to-append +recipient-keyed suppression packet, while non-unsubscribe replies produce only a +typed routing decision for a separate governed `send-as` run. + +## Quality Profile + +- Purpose: prevent missed suppressions and keep reply follow-up routing separate + from the act of sending. +- Audience: operators running outreach, lifecycle, support, or sales workflows + that need a replayable reply decision before any next send. +- Artifact contract: emit `classification`, optionally emit a + `reply.unsubscribe_suppressed` suppression result packet with CAS inputs, and + optionally emit a `runx.reply.routing.v1` routing decision that names a + downstream governed `send-as` run. +- Evidence bar: every suppression or route must cite reply text, the sealed + original send receipt, the suppression policy signal, and the prior recipient + projection. +- Stop conditions: unsealed original receipt, ambiguous reply intent, missing + suppression policy, policy confidence below threshold, missing recipient + aggregate, unreadable prior suppression state, or any attempt to send from this + skill. + +## Runner + +`route_reply` is the default agent-task runner. + +It performs one bounded classification and packet-authoring act: + +1. Classify the reply using only the supplied reply, sealed original send + receipt, policy, and recipient stream coordinates. +2. For unsubscribe intent, emit a `runx.data.operation_result.v1` packet with + `operation: append_event`, `expected_version`, `idempotency_key`, and the + `reply.unsubscribe_suppressed` event payload. +3. For follow-up intent, emit a bounded `runx.reply.routing.v1` packet naming a + downstream governed `send-as` run. +4. For ambiguity or unsealed receipts, stop with `needs_agent`. + +The runner never sends a follow-up. For interested, objection, out-of-office, or +wrong-person replies, the output is a routing decision naming a separate +governed `send-as` run that a downstream driver or operator must issue. + +## Inputs + +- `inbound_reply` (required): object with `content`, `received_from`, and + `received_at`. +- `original_send_receipt` (required): object with `send_plan`, `principal`, + `receipt_id`, `checksum`, and `sealed`. +- `suppression_policy` (required): object with `unsubscribe_signals` and + `confidence_threshold`. +- `data_source_ref` (required): logical data source for suppression events. +- `store_id` (optional): deterministic local fixture store id for downstream + data-store replay. +- `resource` (optional): suppression event resource, defaults to + `reply_suppressions`. +- `aggregate_id` (required): recipient aggregate id, for example + `recipient:buyer@example.com`. +- `expected_version` (required): stream version read before append. +- `idempotency_key` (required): stable key derived from recipient, original send, + and classification. + +## Output Schema + +For unsubscribe replies: + +```json +{ + "classification": { + "type": "unsubscribe", + "confidence": 0.98, + "evidence": ["Reply text contains unsubscribe."] + }, + "suppression_result": { + "aggregate_id": "recipient:buyer@example.com", + "idempotency_key": "recipient:buyer@example.com:seq-001:unsubscribe:v1", + "before_version": 0, + "after_version": 1 + }, + "routing_decision": null +} +``` + +For routed replies: + +```json +{ + "classification": { + "type": "objection", + "confidence": 0.86, + "evidence": ["Reply asks about pricing."] + }, + "routing_decision": { + "schema": "runx.reply.routing.v1", + "classification": "objection", + "send_target": "send-as:reply-followup", + "principal": "runx:principal:sales-ops" + }, + "suppression_result": null +} +``` + +## Suppression Semantics + +An unsubscribe classification must meet all of these conditions: + +- The original send receipt is sealed. +- The reply text contains unsubscribe intent named by + `suppression_policy.unsubscribe_signals`. +- The confidence is at or above `suppression_policy.confidence_threshold`. +- The suppression result names `append_event` with `aggregate_id` equal to the + recipient entity, `expected_version`, and an idempotency key derived from + recipient plus original send plus decision. +- No routing send target is emitted alongside the suppression. + +The suppression packet is the compliance block a downstream data-store lane +commits and the next send preflight reads. Receipt history is evidence, not the +state store. + +## Edge Cases And Stop Conditions + +- `needs_receipt`: `original_send_receipt.sealed` is false or the receipt id is + missing. +- `needs_policy`: suppression signals or threshold are missing. +- `ambiguous_reply`: the reply does not clearly match a suppression or routing + class. +- `below_threshold`: matched signals exist but confidence is below the policy + threshold. +- `needs_projection`: prior suppression state cannot be read. +- `conflict`: the suppression stream version no longer matches + `expected_version`. +- `send_attempted`: any attempt to send from this skill is invalid; route by + naming a downstream governed run instead. + +## Harness + +The inline harness covers: + +- `sealed_unsubscribe_suppression`: a sealed unsubscribe classification emits a + recipient suppression event packet with CAS coordinates. +- `sealed_interested_route`: a sealed interested reply emits a bounded routing + packet and no suppression result. +- `stop_ambiguous_or_unsealed`: an ambiguous reply with an unsealed original + receipt stops at `needs_agent` and performs no suppression write. + +## Invocation + +```bash +runx skill reply-router route_reply \ + --input-json inbound_reply='{"content":"unsubscribe me","received_from":"buyer@example.com","received_at":"2026-06-30T10:00:00Z"}' \ + --input-json original_send_receipt='{"send_plan":{"recipient":"buyer@example.com","sequence_id":"seq-001"},"principal":"runx:principal:sales-ops","receipt_id":"runx:receipt:sha256:sealed","checksum":"sha256:...","sealed":true}' \ + --input-json suppression_policy='{"unsubscribe_signals":["unsubscribe","opt out"],"confidence_threshold":0.8}' \ + -i data_source_ref=local://reply-router/demo \ + -i aggregate_id=recipient:buyer@example.com \ + --input-json expected_version=0 \ + -i idempotency_key=recipient:buyer@example.com:seq-001:unsubscribe:v1 \ + --json +``` diff --git a/skills/reply-router/X.yaml b/skills/reply-router/X.yaml new file mode 100644 index 000000000..656b0029a --- /dev/null +++ b/skills/reply-router/X.yaml @@ -0,0 +1,216 @@ +skill: reply-router +version: "0.1.0" + +catalog: + kind: skill + audience: public + visibility: public + role: canonical + +emits: + - name: reply_routing + packet: runx.reply.routing.v1 + - name: suppression_result + packet: runx.data.operation_result.v1 + +harness: + cases: + - name: sealed_unsubscribe_suppression + runner: route_reply + inputs: + inbound_reply: + content: "Please unsubscribe me and stop all outreach." + received_from: "buyer@example.com" + received_at: "2026-06-30T10:00:00Z" + original_send_receipt: + send_plan: + recipient: "buyer@example.com" + sequence_id: "seq-acme-001" + principal: "runx:principal:sales-ops" + receipt_id: "runx:receipt:sha256:sealed-send-demo" + checksum: "sha256:0e1d7f3c3e1a9f1a2b3c4d5e6f708192aabbccddeeff00112233445566778899" + sealed: true + suppression_policy: + unsubscribe_signals: + - unsubscribe + - stop all outreach + confidence_threshold: 0.8 + data_source_ref: "local://reply-router/harness" + store_id: reply-router-harness-v2-store + resource: reply_suppressions + aggregate_id: "recipient_buyer_example_com" + expected_version: 0 + idempotency_key: "recipient_buyer_example_com_seq_acme_001_unsubscribe_v1" + caller: + answers: + agent_task.route-reply.output: + classification: + type: unsubscribe + confidence: 0.98 + evidence: + - "Reply text contains 'unsubscribe'." + - "Reply text contains 'stop all outreach'." + - "original_send_receipt.sealed is true." + suppression_result: + schema: runx.data.operation_result.v1 + status: ready_to_append + operation: append_event + data_source_ref: "local://reply-router/harness" + resource: reply_suppressions + aggregate_id: "recipient_buyer_example_com" + idempotency_key: "recipient_buyer_example_com_seq_acme_001_unsubscribe_v1" + before_version: 0 + after_version: 1 + event: + type: reply.unsubscribe_suppressed + payload: + schema: runx.reply.routing.v1 + recipient: "buyer@example.com" + aggregate_id: "recipient_buyer_example_com" + principal: "runx:principal:sales-ops" + original_receipt_id: "runx:receipt:sha256:sealed-send-demo" + matched_signals: + - unsubscribe + - stop all outreach + decision: + suppress: true + route_send: false + reason: "Recipient explicitly opted out; compliance block must be durable." + routing_decision: null + escalation_lane: + lane: none + reason: "Suppression packet emitted; this skill does not send." + expect: + status: sealed + - name: sealed_interested_route + runner: route_reply + inputs: + inbound_reply: + content: "This looks useful. Can you send pricing details?" + received_from: "buyer@example.com" + received_at: "2026-06-30T12:00:00Z" + original_send_receipt: + send_plan: + recipient: "buyer@example.com" + sequence_id: "seq-acme-003" + principal: "runx:principal:sales-ops" + receipt_id: "runx:receipt:sha256:sealed-send-demo-3" + checksum: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + sealed: true + suppression_policy: + unsubscribe_signals: + - unsubscribe + - opt out + confidence_threshold: 0.8 + data_source_ref: "local://reply-router/harness" + store_id: reply-router-harness-v2-route-store + resource: reply_suppressions + aggregate_id: "recipient_buyer_example_com" + expected_version: 0 + idempotency_key: "recipient_buyer_example_com_seq_acme_003_interested_v1" + caller: + answers: + agent_task.route-reply.output: + classification: + type: interested + confidence: 0.91 + evidence: + - "Reply asks for pricing details." + - "No suppression signal appears in the reply." + - "original_send_receipt.sealed is true." + suppression_result: null + routing_decision: + schema: runx.reply.routing.v1 + classification: interested + confidence: 0.91 + send_target: send-as:reply-followup + principal: "runx:principal:sales-ops" + original_receipt_id: "runx:receipt:sha256:sealed-send-demo-3" + reason: "Interested reply should be handled by a separate governed send-as run." + escalation_lane: + lane: none + reason: "Route emitted without sending." + expect: + status: sealed + - name: stop_ambiguous_or_unsealed + runner: route_reply + inputs: + inbound_reply: + content: "Maybe later, not sure who should handle this." + received_from: "buyer@example.com" + received_at: "2026-06-30T11:00:00Z" + original_send_receipt: + send_plan: + recipient: "buyer@example.com" + sequence_id: "seq-acme-002" + principal: "runx:principal:sales-ops" + receipt_id: "runx:receipt:sha256:unsealed-send-demo" + checksum: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + sealed: false + suppression_policy: + unsubscribe_signals: + - unsubscribe + - opt out + confidence_threshold: 0.8 + data_source_ref: "local://reply-router/harness" + store_id: reply-router-harness-v2-stop-store + resource: reply_suppressions + aggregate_id: "recipient_buyer_example_com" + expected_version: 0 + idempotency_key: "recipient_buyer_example_com_seq_acme_002_ambiguous_v1" + expect: + status: needs_agent + +runners: + route_reply: + default: true + type: agent-task + agent: operator + task: route-reply + outputs: + classification: object + suppression_result: object + routing_decision: object + escalation_lane: object + artifacts: + named_emits: + suppression_result: runx.data.operation_result.v1 + routing_decision: runx.reply.routing.v1 + inputs: + inbound_reply: + type: json + required: true + description: Reply packet with content, received_from, and received_at. + original_send_receipt: + type: json + required: true + description: Sealed original send receipt with send_plan, principal, receipt_id, checksum, and sealed status. + suppression_policy: + type: json + required: true + description: Suppression policy with unsubscribe_signals and confidence_threshold. + data_source_ref: + type: string + required: true + description: Logical data source that stores recipient suppression events. + store_id: + type: string + required: false + description: Optional deterministic local fixture store id. + resource: + type: string + required: false + default: reply_suppressions + description: Declared event resource for suppression records. + aggregate_id: + type: string + required: true + description: Recipient-keyed aggregate id, for example recipient:buyer@example.com. + expected_version: + type: number + required: true + description: Current recipient suppression stream version read before the write. + idempotency_key: + type: string + required: true + description: Stable key derived from recipient, original send, and classification. diff --git a/skills/reply-router/evidence.json b/skills/reply-router/evidence.json new file mode 100644 index 000000000..3db1b3535 --- /dev/null +++ b/skills/reply-router/evidence.json @@ -0,0 +1,43 @@ +{ + "schema": "runx.bounty.evidence.v1", + "bounty": 70, + "skill": "reply-router", + "claim_id": "3a9f6f23-1ebb-4dfa-97e8-be7d7f2f3d6f", + "summary": "Reply Router classifies inbound replies against a sealed original send receipt and suppression policy, emits unsubscribe suppression packets with CAS coordinates, emits bounded routing decisions for follow-up, and refuses ambiguous or unsealed inputs.", + "observations": [ + { + "claim_type": "typed_inputs", + "detail": "route_reply requires inbound_reply, original_send_receipt, suppression_policy, data_source_ref, aggregate_id, expected_version, and idempotency_key." + }, + { + "claim_type": "typed_outputs", + "detail": "route_reply emits classification, suppression_result, routing_decision, and escalation_lane." + }, + { + "claim_type": "unsubscribe_boundary", + "detail": "The unsubscribe harness answer emits runx.data.operation_result.v1 with operation append_event, aggregate id, expected version, idempotency key, and reply.unsubscribe_suppressed event payload." + }, + { + "claim_type": "send_boundary", + "detail": "The interested-route harness answer emits runx.reply.routing.v1 naming send-as:reply-followup and does not send." + }, + { + "claim_type": "stop_condition", + "detail": "The ambiguous/unsealed fixture expects needs_agent and has no caller answer, proving the runner stops rather than classifying unsafe input." + }, + { + "claim_type": "runx_cli_version", + "detail": "npx @runxhq/cli@0.6.14 reports runx-cli 0.6.14." + }, + { + "claim_type": "verification_blocker", + "detail": "Windows native sealed receipt writing fails with os error 87 during receipt-store directory sync, blocking local harness and registry publish from this machine." + } + ], + "artifact_notes": { + "x_yaml": "skills/reply-router/X.yaml", + "skill_md": "skills/reply-router/SKILL.md", + "fixtures": "skills/reply-router/fixtures", + "verification_json": "skills/reply-router/verification.json" + } +} diff --git a/skills/reply-router/fixtures/ambiguous-unsealed-input.json b/skills/reply-router/fixtures/ambiguous-unsealed-input.json new file mode 100644 index 000000000..8eb648e75 --- /dev/null +++ b/skills/reply-router/fixtures/ambiguous-unsealed-input.json @@ -0,0 +1,27 @@ +{ + "inbound_reply": { + "content": "Maybe later, not sure who should handle this.", + "received_from": "buyer@example.com", + "received_at": "2026-06-30T11:00:00Z" + }, + "original_send_receipt": { + "send_plan": { + "recipient": "buyer@example.com", + "sequence_id": "seq-acme-002" + }, + "principal": "runx:principal:sales-ops", + "receipt_id": "runx:receipt:sha256:unsealed-send-demo", + "checksum": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "sealed": false + }, + "suppression_policy": { + "unsubscribe_signals": ["unsubscribe", "opt out"], + "confidence_threshold": 0.8 + }, + "data_source_ref": "local://reply-router/harness", + "store_id": "reply-router-harness-v2-stop-store", + "resource": "reply_suppressions", + "aggregate_id": "recipient_buyer_example_com", + "expected_version": 0, + "idempotency_key": "recipient_buyer_example_com_seq_acme_002_ambiguous_v1" +} diff --git a/skills/reply-router/fixtures/unsubscribe-answers.json b/skills/reply-router/fixtures/unsubscribe-answers.json new file mode 100644 index 000000000..83b0f9c5d --- /dev/null +++ b/skills/reply-router/fixtures/unsubscribe-answers.json @@ -0,0 +1,49 @@ +{ + "answers": { + "agent_task.classify-reply.output": { + "classification": { + "type": "unsubscribe", + "confidence": 0.98, + "evidence": [ + "Reply text contains 'unsubscribe'.", + "Reply text contains 'stop all outreach'.", + "original_send_receipt.sealed is true." + ] + }, + "suppression_event": { + "type": "reply.unsubscribe_suppressed", + "payload": { + "schema": "runx.reply.routing.v1", + "recipient": "buyer@example.com", + "aggregate_id": "recipient_buyer_example_com", + "principal": "runx:principal:sales-ops", + "original_receipt_id": "runx:receipt:sha256:sealed-send-demo", + "classification": { + "type": "unsubscribe", + "confidence": 0.98 + }, + "matched_signals": [ + "unsubscribe", + "stop all outreach" + ], + "policy": { + "confidence_threshold": 0.8 + }, + "decision": { + "suppress": true, + "route_send": false, + "reason": "Recipient explicitly opted out; compliance block must be durable." + } + } + }, + "routing_decision": null, + "escalation": { + "lane": "none", + "reason": "Suppression committed; no send target emitted." + }, + "closure": { + "disposition": "closed" + } + } + } +} diff --git a/skills/reply-router/fixtures/unsubscribe-input.json b/skills/reply-router/fixtures/unsubscribe-input.json new file mode 100644 index 000000000..b32432e6e --- /dev/null +++ b/skills/reply-router/fixtures/unsubscribe-input.json @@ -0,0 +1,27 @@ +{ + "inbound_reply": { + "content": "Please unsubscribe me and stop all outreach.", + "received_from": "buyer@example.com", + "received_at": "2026-06-30T10:00:00Z" + }, + "original_send_receipt": { + "send_plan": { + "recipient": "buyer@example.com", + "sequence_id": "seq-acme-001" + }, + "principal": "runx:principal:sales-ops", + "receipt_id": "runx:receipt:sha256:sealed-send-demo", + "checksum": "sha256:0e1d7f3c3e1a9f1a2b3c4d5e6f708192aabbccddeeff00112233445566778899", + "sealed": true + }, + "suppression_policy": { + "unsubscribe_signals": ["unsubscribe", "stop all outreach"], + "confidence_threshold": 0.8 + }, + "data_source_ref": "local://reply-router/harness", + "store_id": "reply-router-harness-v2-store", + "resource": "reply_suppressions", + "aggregate_id": "recipient_buyer_example_com", + "expected_version": 0, + "idempotency_key": "recipient_buyer_example_com_seq_acme_001_unsubscribe_v1" +} diff --git a/skills/reply-router/verification.json b/skills/reply-router/verification.json new file mode 100644 index 000000000..ed0857a8c --- /dev/null +++ b/skills/reply-router/verification.json @@ -0,0 +1,34 @@ +{ + "schema": "runx.bounty.verification.v1", + "bounty": 70, + "skill": "reply-router", + "claim_id": "3a9f6f23-1ebb-4dfa-97e8-be7d7f2f3d6f", + "generated_at": "2026-06-30T23:15:00Z", + "checks": [ + { + "name": "runx_cli_version", + "status": "passed", + "command": "npx.cmd -y @runxhq/cli@0.6.14 --version", + "detail": "runx-cli 0.6.14" + }, + { + "name": "skill_inspect", + "status": "passed", + "command": "npx.cmd -y @runxhq/cli@0.6.14 skill inspect ./skills/reply-router route_reply --json", + "detail": "Inspect returned status ok, runner route_reply, type agent-task, and typed inputs." + }, + { + "name": "inline_harness_windows", + "status": "blocked", + "command": "npx.cmd -y @runxhq/cli@0.6.14 harness ./skills/reply-router --json", + "detail": "The needs_agent case runs, but sealed cases fail on Windows while writing sealed receipts: receipt store is unreadable: os error 87. Source inspection traced this to directory fsync in the native receipt store on Windows." + }, + { + "name": "registry_publish", + "status": "blocked", + "command": "npx.cmd -y @runxhq/cli@0.6.14 registry publish ./skills/reply-router/SKILL.md --registry https://api.runx.ai --version 0.1.0 --json", + "detail": "Publish invokes the same local Windows harness path and is blocked by the sealed receipt store os error 87 before upload." + } + ], + "result": "blocked_on_windows_receipt_store" +}