Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions skills/list-hygiene-judge/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
---
name: list-hygiene-judge
description: Decide whether a contact should be verified, suppressed, or re-permissioned from read engagement and bounce evidence, record the consent transition through data-store, and leave the send to a separate governed run.
runx:
category: ops
---

# List Hygiene Judge

List hygiene is the judgment that sits between engagement decay and suppression,
and the dangerous part is the durable consent-state transition. This skill is a
graph runner: it reads a contact through the hosted data-store keyed by the
contact as the domain entity, decides whether to verify, suppress, or
re-permission, and records the transition by appending exactly one event to that
contact's stream.

The decision is a thin `act{form: review}` over content-keyed memory: the seal
proves the read and the verdict. The state write is an ungated compare-and-set
`append_event` under `idempotency_key` plus `expected_version` — not a proposal
and not a mint.

**The skill never sends.** The recorded consent state is honored later by
`send-as`, which is a separate governed run dispatched by naming: `send-as` reads
the recorded state at send time and gates delivery, so a suppressed contact
cannot receive a campaign. `send-as` is the downstream enforcer of the recorded
state, never a consumer of this skill's output.

## Inputs

- `data_source_ref` and `store_id`: the pinned data-store binding.
- `resource`: the data-store resource holding the contact consent stream
(default `contacts`).
- `aggregate_id`: the contact entity id, used as the data-store aggregate id.
- `expected_version`: the stream version the caller read. A mismatch against the
projection is a stale-evidence stop, never a write.
- `idempotency_key`: stable append key, so a retry with the same key returns the
recorded version instead of double-applying the transition.
- `engagement_history`: `opens_count`, `clicks_count`, `hard_bounces`,
`recency_days`.
- `bounce_policy`: `hard_bounce_action`, `decay_threshold_days`.
- `current_consent_state`: the consent state the caller believes is current. An
active unsubscribe marker in the store outranks it.

## Output Contract

The output packet is `runx.list.hygiene_judge.v1` data:

- `decision{state,reason}` always appears. `state` is one of `re_permission`,
`suppress`, `no_change`, or `stop`.
- `recorded_transition` and `contact_event` appear only when a transition is
warranted, and carry the `aggregate_id` / `expected_version` /
`idempotency_key` binding of the append.
- `stop_state` appears instead whenever no append is emitted.
- There is no `operational_proposal` envelope and no minted grant.

## The Judgment

Decided in this order, and every branch is grounded in state that was read:

1. **Evidence gate.** A missing `opens_count`, `clicks_count`, `hard_bounces`,
or `recency_days` counter is a stop, not a zero. An unreadable contact
projection is a stop. A missing `bounce_policy` is a stop.
2. **Stale `expected_version`.** Checked *before* deciding: if the projection is
at a different version than the caller passed, our read is behind the stream
and any append would apply against state we never saw. Stop, escalate.
3. **Ambiguous bounce recovery.** The contact hard-bounced *and* has since
engaged inside the decay window. Suppressing would drop a live human;
re-permissioning would ignore a real delivery failure. Neither is decidable
from the evidence, so neither is taken — escalate to a human approval lane.
4. **Suppress.** `hard_bounces > 0` read from the store. This is the only path to
suppression: the judgment refuses to suppress without hard-bounce evidence.
5. **Active unsubscribe marker.** Refuse to re-permission a contact carrying one,
and escalate instead.
6. **Re-permission.** `recency_days` exceeds `bounce_policy.decay_threshold_days`
with no unsubscribe marker and no hard bounces.
7. Otherwise `no_change`, and no event is appended.

## State Write

`read_projection` on the contact entity, decide, then `append_event` with
`idempotency_key` + `expected_version` compare-and-set against the pinned
`store_id`. The append and the read-back are both guarded on
`decision.writes == true`, so every stop path provably emits no append.

## Harness

Three inline cases, run with `runx harness ./skills/list-hygiene-judge`:

- `sealed_decay_re_permission` — `recency_days` 400 over a 180-day threshold, no
unsubscribe marker, no bounces → `re_permission`, one append.
- `sealed_hard_bounce_suppress` — `hard_bounces` 2 → `suppress`, one append.
- `stop_missing_or_stale_evidence` — `engagement_history` absent → stop, and the
guards block both the append and the read-back.

## Install and Run

```bash
runx add <owner>/list-hygiene-judge@<version>
runx skill <owner>/list-hygiene-judge@<version> --json \
-i data_source_ref=<ref> -i store_id=<store> \
-i aggregate_id=<contact-id> -i expected_version=0 \
-i idempotency_key=<key> \
--input-json engagement_history='{"opens_count":0,"clicks_count":0,"hard_bounces":0,"recency_days":400}' \
--input-json bounce_policy='{"hard_bounce_action":"suppress","decay_threshold_days":180}' \
-i current_consent_state=subscribed
runx verify --receipt <receipt.json> --json
```
262 changes: 262 additions & 0 deletions skills/list-hygiene-judge/X.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
skill: list-hygiene-judge
version: "0.1.0"

catalog:
kind: graph
audience: public
visibility: public
role: canonical
runtime_path: data-adapter
execution: plan
completion: runtime_receipt
requires_adapter: false
approval: none

harness:
cases:
- name: sealed_decay_re_permission
runner: judge
operator_journeys:
- mode: standalone
confusors: [reply-router, outreach-sequencer]
request: This contact has not opened or clicked in over a year. Decide whether we should still be mailing them, and record whatever you decide.
expected_outcome: Return a re_permission verdict grounded in the read engagement counters and record it as one compare-and-set append on the contact stream, with no send performed.
- mode: composed
request: Continue from this contact whose consent transition has already been judged and recorded.
expected_outcome: Reuse the recorded transition and its read-back projection version, and leave the gating of any actual delivery to a separate governed send-as run.
prior_evidence:
- The engagement_history, bounce_policy, and contact projection supplied and read in the fixture.
- The recorded consent transition and the version it was appended at.
must_not_repeat:
- Do not re-derive the consent verdict already sealed in the recorded transition.
- Do not append the transition a second time; the idempotency_key replays it.
inputs:
data_source_ref: local://runx-list-hygiene-judge/harness
store_id: list-hygiene-judge-harness-v1
resource: contacts
aggregate_id: contact:acct-4242:c-1001
expected_version: 0
idempotency_key: contact:acct-4242:c-1001:hygiene:v1
engagement_history:
opens_count: 0
clicks_count: 0
hard_bounces: 0
recency_days: 400
bounce_policy:
hard_bounce_action: suppress
decay_threshold_days: 180
current_consent_state: subscribed
expect:
status: sealed
receipt:
schema: runx.receipt.v1

- name: sealed_hard_bounce_suppress
runner: judge
inputs:
data_source_ref: local://runx-list-hygiene-judge/harness
store_id: list-hygiene-judge-harness-v1
resource: contacts
aggregate_id: contact:acct-4242:c-1002
expected_version: 0
idempotency_key: contact:acct-4242:c-1002:hygiene:v1
engagement_history:
opens_count: 0
clicks_count: 0
hard_bounces: 2
recency_days: 30
bounce_policy:
hard_bounce_action: suppress
decay_threshold_days: 180
current_consent_state: subscribed
expect:
status: sealed
receipt:
schema: runx.receipt.v1

- name: stop_missing_or_stale_evidence
runner: judge
inputs:
data_source_ref: local://runx-list-hygiene-judge/harness
store_id: list-hygiene-judge-harness-v1
resource: contacts
aggregate_id: contact:acct-4242:c-1003
expected_version: 0
idempotency_key: contact:acct-4242:c-1003:hygiene:v1
bounce_policy:
hard_bounce_action: suppress
decay_threshold_days: 180
current_consent_state: subscribed
expect:
status: policy_denied
receipt:
schema: runx.receipt.v1
state: sealed
disposition: blocked
reason_code: graph_blocked

policy:
allow:
- provider: data-source
method: READ
scope: runx:data:read
- provider: data-source
method: APPEND
scope: runx:data:append
deny:
- direct_customer_send
- direct_esp_api_call
- invented_engagement_metric
- invented_bounce_count
- suppression_without_bounce_evidence
- re_permission_over_unsubscribe_marker
- operational_proposal
- minted_grant

runners:
decide:
type: cli-tool
command: node
args:
- run.mjs
outputs:
decision: object
recorded_transition: object
contact_event: object
stop_state: object
observations: object
artifacts:
wrap_as: list_hygiene_judgment
inputs:
data_source_ref:
type: string
required: false
description: Logical data-store binding, echoed into the evidence resource ref.
resource:
type: string
required: false
description: data-store resource holding the contact consent stream; defaults to contacts.
aggregate_id:
type: string
required: true
description: The contact entity id used as the data-store aggregate.
expected_version:
type: number
required: true
description: Stream version the caller read; a mismatch against the projection is a stale-evidence stop, never a write.
idempotency_key:
type: string
required: true
description: Stable append key so a retry returns the recorded version instead of double-applying the transition.
engagement_history:
type: json
required: false
description: engagement_history{opens_count,clicks_count,hard_bounces,recency_days}; a missing counter is a stop, not a zero.
bounce_policy:
type: json
required: false
description: bounce_policy{hard_bounce_action,decay_threshold_days} declared by the caller, never inferred.
current_consent_state:
type: string
required: false
description: Consent state the caller believes is current; the store projection's unsubscribe marker outranks it.
contact_read:
type: json
required: false
description: The data-store read_projection result for the contact, passed whole so an empty projection reads as empty-but-read.

judge:
default: true
type: graph
inputs:
data_source_ref:
type: string
required: true
description: Logical data-store binding for the contact consent event stream.
store_id:
type: string
required: true
description: Pinned data-store id so harness and dogfood receipts are deterministic.
resource:
type: string
required: false
description: data-store resource holding the contact consent stream; defaults to contacts.
aggregate_id:
type: string
required: true
description: The contact entity id used as the data-store aggregate id.
expected_version:
type: number
required: true
description: Current stream version required by the CAS append_event that records the consent transition.
idempotency_key:
type: string
required: true
description: Stable append key so retries do not double-apply the consent transition.
engagement_history:
type: json
required: false
description: engagement_history{opens_count,clicks_count,hard_bounces,recency_days} read for this contact.
bounce_policy:
type: json
required: false
description: bounce_policy{hard_bounce_action,decay_threshold_days}; missing policy produces a stop with no append.
current_consent_state:
type: string
required: false
description: Consent state as the caller has it; an active unsubscribe marker in the store escalates instead of re-permissioning.
graph:
name: list-hygiene-judge
result_from: [decide, readback]
steps:
- id: read-contact
tool: data.read_projection
scopes:
- runx:data:read
inputs:
data_source_ref: "$input.data_source_ref"
resource: contacts
aggregate_id: "$input.aggregate_id"
- id: decide
label: judge the consent-state transition without sending anything
skill: .
runner: decide
inputs:
data_source_ref: "$input.data_source_ref"
resource: "$input.resource"
aggregate_id: "$input.aggregate_id"
expected_version: "$input.expected_version"
idempotency_key: "$input.idempotency_key"
engagement_history: "$input.engagement_history"
bounce_policy: "$input.bounce_policy"
current_consent_state: "$input.current_consent_state"
context:
contact_read: read-contact.data_operation_result
- id: append-transition
tool: data.append_event
scopes:
- runx:data:append
inputs:
data_source_ref: "$input.data_source_ref"
resource: contacts
aggregate_id: "$input.aggregate_id"
expected_version: "$input.expected_version"
idempotency_key: "$input.idempotency_key"
context:
event: decide.list_hygiene_judgment.data.contact_event
- id: readback
tool: data.read_projection
scopes:
- runx:data:read
inputs:
data_source_ref: "$input.data_source_ref"
resource: contacts
aggregate_id: "$input.aggregate_id"
policy:
guards:
- step: append-transition
field: decide.list_hygiene_judgment.data.decision.writes
equals: true
- step: readback
field: decide.list_hygiene_judgment.data.decision.writes
equals: true
17 changes: 17 additions & 0 deletions skills/list-hygiene-judge/fixtures/harness-evidence.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"status": "passed",
"case_count": 3,
"assertion_error_count": 0,
"assertion_errors": [],
"case_names": [
"sealed_decay_re_permission",
"sealed_hard_bounce_suppress",
"stop_missing_or_stale_evidence"
],
"receipt_ids": [
"sha256:8c9ef4bb1c05b6ac58e0e384b416ac5483cec48a4bbf1cd31583ab5261935b50",
"sha256:709a9d20519dce91124a02f7c4af8c3511136d3b8aa7260553ba33a5995654d1",
"sha256:21cc64b88d0d3977efcfe0d310f404c0ad3ec015da6a2101aaa494c49d4efac7"
],
"graph_case_count": 3
}
Loading