From b5f6b49fd79f9dd62e1fece53713059f5887e42f Mon Sep 17 00:00:00 2001 From: Timothy Robert Peterson <312591046+ArgonautWorks@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:42:09 +0200 Subject: [PATCH 1/4] feat: add list hygiene judge skill Signed-off-by: Timothy Robert Peterson <312591046+ArgonautWorks@users.noreply.github.com> --- skills/list-hygiene-judge/SKILL.md | 101 ++++++++ skills/list-hygiene-judge/X.yaml | 229 ++++++++++++++++++ .../fixtures/decay-re-permission.yaml | 25 ++ .../fixtures/hard-bounce-suppress.yaml | 25 ++ .../fixtures/stale-evidence-stop.yaml | 25 ++ skills/list-hygiene-judge/list-hygiene.mjs | 225 +++++++++++++++++ 6 files changed, 630 insertions(+) create mode 100644 skills/list-hygiene-judge/SKILL.md create mode 100644 skills/list-hygiene-judge/X.yaml create mode 100644 skills/list-hygiene-judge/fixtures/decay-re-permission.yaml create mode 100644 skills/list-hygiene-judge/fixtures/hard-bounce-suppress.yaml create mode 100644 skills/list-hygiene-judge/fixtures/stale-evidence-stop.yaml create mode 100644 skills/list-hygiene-judge/list-hygiene.mjs diff --git a/skills/list-hygiene-judge/SKILL.md b/skills/list-hygiene-judge/SKILL.md new file mode 100644 index 00000000..d882d3ef --- /dev/null +++ b/skills/list-hygiene-judge/SKILL.md @@ -0,0 +1,101 @@ +--- +name: list-hygiene-judge +description: Decide and durably record evidence-bound contact consent transitions for engagement decay and hard bounces. Use when an operator needs to re-permission, suppress, or stop for human review before any campaign send. +registry_owner: ArgonautWorks +--- + +# List Hygiene Judge + +Use this skill between contact evidence collection and outbound delivery. It +turns fresh engagement, bounce, and consent evidence into one conservative +consent-state decision, records an allowed transition with compare-and-set +semantics, and reads the contact projection back before reporting success. + +This is not a sender. A later `send-as` run must independently read the recorded +consent state at send time and gate delivery. Never treat this skill's output as +send authority, a campaign proposal, or proof that a message was delivered. + +## Operating model + +1. Read the contact projection through the provider-neutral `data-store` skill. + Bind `data_source_ref`, `resource`, and `aggregate_id` to the contact's event + stream. The read establishes the durable version used by the decision. +2. Admit the supplied engagement and consent evidence only when its status is + `read`, its `evidence_version` equals `expected_version`, and the durable + projection has that same version. Missing, unreadable, ambiguous, or stale + evidence stops without an append. +3. Honor an active unsubscribe marker as terminal for automation. Route the + case to a human list-hygiene reviewer; never re-permission it automatically. +4. Suppress when verified `hard_bounces` is greater than zero and policy names + `suppress`. Otherwise, re-permission only when `recency_days` exceeds the + declared decay threshold and no unsubscribe marker exists. +5. Append exactly one `list_hygiene.consent_transitioned` event with the + caller's stable `idempotency_key` and `expected_version`. The data-store owns + compare-and-set enforcement and idempotent replay. +6. Read the projection back. Report a recorded transition only when the new + version and event type match the plan. A stop returns `human_review` with no + event and confirms that the projection version did not change. + +## Evidence, authority, and finality + +The evidence inputs are bounded observations, not permission to send. Do not +invent opens, clicks, bounces, recency, consent markers, or versions. A caller +that cannot supply them must repair the upstream evidence read rather than fill +defaults. + +The runner requests only `runx:data:read` and `runx:data:append` through the +canonical `data-store` capability. The allowed append is intentionally ungated: +it records contact policy state, not external delivery. The stable +`idempotency_key` makes an unchanged retry return the already-recorded version; +a competing write produces a version conflict and must be retried only after a +fresh read and new decision. + +A sealed result proves the governed read, decision, optional append, and +readback. It does not prove that a campaign was sent. `downstream_send.status` +therefore remains `not_run` on every result. + +## Decisions and recovery + +- `re_permission`: engagement is older than the declared threshold, there is + no hard bounce, and no active unsubscribe marker exists. One transition is + recorded. +- `suppress`: at least one hard bounce is present and policy explicitly selects + suppression. One transition is recorded. +- `human_review`: evidence is missing, unreadable, ambiguous, stale, versioned + differently from the contact projection, protected by an unsubscribe marker, + or does not justify an automated transition. No event is appended. + +On a version conflict, read the contact again and make a new decision. Reuse an +idempotency key only for the identical intended transition. If bounce recovery +or unsubscribe history is ambiguous, keep the case in the human review lane; +do not weaken the stop condition to make the run pass. + +## Inputs and result + +Provide the logical data source, resource, contact `aggregate_id`, current +`expected_version`, stable `idempotency_key`, engagement counts and recency, +the bounce policy, and the current consent state with evidence status/version. + +The result contains: + +- `decision`: `re_permission`, `suppress`, or `human_review`, with a reason; +- `recorded_transition`: readback-bound contact, version, event, projection, + and idempotency evidence, plus whether a write occurred; +- `escalation`: the human lane when automation stopped; +- `downstream_send`: an explicit `not_run` handoff reminder for `send-as`. + +For example, a subscribed contact at version 3 with no hard bounces and +`recency_days: 121` under a 90-day threshold may be moved to `re_permission` +with `expected_version: 3`. The result is final only after readback reports +version 4 and the consent-transition event. + +## Agent rules + +- Read the projection before deciding and read it again after any append. +- Prefer suppression over engagement recency when verified hard-bounce evidence + exists. +- Never re-permission an active unsubscribe marker. +- Never append on missing, unreadable, ambiguous, or stale evidence. +- Never dispatch, send, mint authority, or claim provider delivery. +- Return the sealed receipt with the result so the next operator can verify the + decision without private context. diff --git a/skills/list-hygiene-judge/X.yaml b/skills/list-hygiene-judge/X.yaml new file mode 100644 index 00000000..53a23211 --- /dev/null +++ b/skills/list-hygiene-judge/X.yaml @@ -0,0 +1,229 @@ +skill: list-hygiene-judge +version: "0.1.0" + +catalog: + kind: graph + audience: public + visibility: public + role: context + execution: execute + completion: runtime_receipt + requires_adapter: true + approval: none + +policy: + side_effects: data_state_transition + sends_messages: denied + mints_authority: denied + persists_state: conditional + secrets_required: false + +harness: + cases: + - name: sealed_decay_re_permission + runner: judge + inputs: + data_source_ref: local://list-hygiene-judge/decay + resource: contact_consent_events + aggregate_id: contact:decay + expected_version: 0 + idempotency_key: contact:decay:consent:v1 + engagement_history: { opens_count: 0, clicks_count: 0, hard_bounces: 0, recency_days: 121 } + bounce_policy: { hard_bounce_action: suppress, decay_threshold_days: 90 } + current_consent_state: { state: subscribed, active_unsubscribe_marker: false, evidence_status: read, evidence_version: 0 } + expect: + status: sealed + steps: [read_contact, decide, append_transition, readback, finalize] + step_outputs: + append_transition: + subset: + data_operation_result: + data: { operation: append_event, status: committed, aggregate_id: "contact:decay" } + finalize: + subset: + list_hygiene_result: + data: + decision: { state: re_permission } + recorded_transition: { recorded: true, state: re_permission, aggregate_id: "contact:decay", after_version: 1 } + downstream_send: { skill: send-as, status: not_run } + receipt: { schema: runx.receipt.v1 } + + - name: sealed_hard_bounce_suppress + runner: judge + inputs: + data_source_ref: local://list-hygiene-judge/hard-bounce + resource: contact_consent_events + aggregate_id: contact:hard-bounce + expected_version: 0 + idempotency_key: contact:hard-bounce:consent:v1 + engagement_history: { opens_count: 2, clicks_count: 1, hard_bounces: 1, recency_days: 12 } + bounce_policy: { hard_bounce_action: suppress, decay_threshold_days: 90 } + current_consent_state: { state: subscribed, active_unsubscribe_marker: false, evidence_status: read, evidence_version: 0 } + expect: + status: sealed + steps: [read_contact, decide, append_transition, readback, finalize] + step_outputs: + append_transition: + subset: + data_operation_result: + data: { operation: append_event, status: committed, aggregate_id: "contact:hard-bounce" } + finalize: + subset: + list_hygiene_result: + data: + decision: { state: suppress } + recorded_transition: { recorded: true, state: suppress, aggregate_id: "contact:hard-bounce", after_version: 1 } + downstream_send: { skill: send-as, status: not_run } + receipt: { schema: runx.receipt.v1 } + + - name: stop_missing_or_stale_evidence + runner: judge + inputs: + data_source_ref: local://list-hygiene-judge/stale + resource: contact_consent_events + aggregate_id: contact:stale + expected_version: 1 + idempotency_key: contact:stale:consent:v2 + engagement_history: { opens_count: 0, clicks_count: 0, hard_bounces: 0, recency_days: 180 } + bounce_policy: { hard_bounce_action: suppress, decay_threshold_days: 90 } + current_consent_state: { state: subscribed, active_unsubscribe_marker: false, evidence_status: stale, evidence_version: 0 } + expect: + status: sealed + steps: [read_contact, decide, readback, finalize] + step_outputs: + finalize: + subset: + list_hygiene_result: + data: + decision: { state: human_review } + recorded_transition: { recorded: false, state: human_review, aggregate_id: "contact:stale", after_version: 0 } + escalation: { lane: "human:list-hygiene-reviewer", status: required_before_any_write } + receipt: { schema: runx.receipt.v1 } + + - name: stop_active_unsubscribe_marker + runner: judge + inputs: + data_source_ref: local://list-hygiene-judge/unsubscribed + resource: contact_consent_events + aggregate_id: contact:unsubscribed + expected_version: 0 + idempotency_key: contact:unsubscribed:consent:v1 + engagement_history: { opens_count: 0, clicks_count: 0, hard_bounces: 0, recency_days: 180 } + bounce_policy: { hard_bounce_action: suppress, decay_threshold_days: 90 } + current_consent_state: { state: unsubscribed, active_unsubscribe_marker: true, evidence_status: read, evidence_version: 0 } + expect: + status: sealed + steps: [read_contact, decide, readback, finalize] + step_outputs: + finalize: + subset: + list_hygiene_result: + data: + decision: { state: human_review } + recorded_transition: { recorded: false, state: human_review, aggregate_id: "contact:unsubscribed", after_version: 0 } + receipt: { schema: runx.receipt.v1 } + +runners: + judge: + default: true + type: graph + inputs: + data_source_ref: + type: string + required: true + description: Logical provider-neutral data source containing the contact stream. + resource: + type: string + required: true + description: Contact consent event-stream resource. + aggregate_id: + type: string + required: true + description: Contact entity and event-stream key. + expected_version: + type: number + required: true + description: Current contact projection version required for compare-and-set. + idempotency_key: + type: string + required: true + description: Stable retry identity for this exact intended consent transition. + engagement_history: + type: json + required: true + description: Fresh bounded engagement and bounce evidence for the contact. + bounce_policy: + type: json + required: true + description: Hard-bounce action and engagement-decay threshold. + current_consent_state: + type: json + required: true + description: Current consent and freshness evidence aligned to the durable version. + graph: + name: list-hygiene-judge + result_from: [finalize] + steps: + - id: read_contact + skill: ../data-store + runner: read_projection + inputs: + data_source_ref: $input.data_source_ref + resource: $input.resource + aggregate_id: $input.aggregate_id + + - id: 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_readback: read_contact.data_operation_result.data + run: + type: javascript + module: list-hygiene.mjs + export: decideListHygiene + outputs: + decision_plan: object + + - id: append_transition + when: + field: decide.decision_plan.append_allowed + equals: true + skill: ../data-store + runner: append_event + 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 + context: + event: decide.decision_plan.event + + - id: readback + skill: ../data-store + runner: read_projection + inputs: + data_source_ref: $input.data_source_ref + resource: $input.resource + aggregate_id: $input.aggregate_id + + - id: finalize + context: + decision_plan: decide.decision_plan + recorded_readback: readback.data_operation_result.data + run: + type: javascript + module: list-hygiene.mjs + export: finalizeListHygiene + outputs: + list_hygiene_result: object + artifacts: + named_emits: + list_hygiene_result: list_hygiene_result diff --git a/skills/list-hygiene-judge/fixtures/decay-re-permission.yaml b/skills/list-hygiene-judge/fixtures/decay-re-permission.yaml new file mode 100644 index 00000000..fd1a59b0 --- /dev/null +++ b/skills/list-hygiene-judge/fixtures/decay-re-permission.yaml @@ -0,0 +1,25 @@ +name: list-hygiene-decay-re-permission +kind: skill +target: .. +runner: judge +inputs: + data_source_ref: local://list-hygiene-judge/fixture-decay + resource: contact_consent_events + aggregate_id: contact:fixture-decay + expected_version: 0 + idempotency_key: contact:fixture-decay:consent:v1 + engagement_history: { opens_count: 1, clicks_count: 0, hard_bounces: 0, recency_days: 121 } + bounce_policy: { hard_bounce_action: suppress, decay_threshold_days: 90 } + current_consent_state: { state: subscribed, active_unsubscribe_marker: false, evidence_status: read, evidence_version: 0 } +expect: + status: sealed + steps: [read_contact, decide, append_transition, readback, finalize] + step_outputs: + finalize: + subset: + list_hygiene_result: + data: + decision: { state: re_permission } + recorded_transition: { recorded: true, after_version: 1 } + receipt: { schema: runx.receipt.v1 } +metadata: { public_skill: list-hygiene-judge, source_case: decay-re-permission, source: skills-fixture } diff --git a/skills/list-hygiene-judge/fixtures/hard-bounce-suppress.yaml b/skills/list-hygiene-judge/fixtures/hard-bounce-suppress.yaml new file mode 100644 index 00000000..4bf3e878 --- /dev/null +++ b/skills/list-hygiene-judge/fixtures/hard-bounce-suppress.yaml @@ -0,0 +1,25 @@ +name: list-hygiene-hard-bounce-suppress +kind: skill +target: .. +runner: judge +inputs: + data_source_ref: local://list-hygiene-judge/fixture-hard-bounce + resource: contact_consent_events + aggregate_id: contact:fixture-hard-bounce + expected_version: 0 + idempotency_key: contact:fixture-hard-bounce:consent:v1 + engagement_history: { opens_count: 3, clicks_count: 1, hard_bounces: 2, recency_days: 4 } + bounce_policy: { hard_bounce_action: suppress, decay_threshold_days: 90 } + current_consent_state: { state: subscribed, active_unsubscribe_marker: false, evidence_status: read, evidence_version: 0 } +expect: + status: sealed + steps: [read_contact, decide, append_transition, readback, finalize] + step_outputs: + finalize: + subset: + list_hygiene_result: + data: + decision: { state: suppress } + recorded_transition: { recorded: true, after_version: 1 } + receipt: { schema: runx.receipt.v1 } +metadata: { public_skill: list-hygiene-judge, source_case: hard-bounce-suppress, source: skills-fixture } diff --git a/skills/list-hygiene-judge/fixtures/stale-evidence-stop.yaml b/skills/list-hygiene-judge/fixtures/stale-evidence-stop.yaml new file mode 100644 index 00000000..a4cfcb84 --- /dev/null +++ b/skills/list-hygiene-judge/fixtures/stale-evidence-stop.yaml @@ -0,0 +1,25 @@ +name: list-hygiene-stale-evidence-stops-without-append +kind: skill +target: .. +runner: judge +inputs: + data_source_ref: local://list-hygiene-judge/fixture-stale + resource: contact_consent_events + aggregate_id: contact:fixture-stale + expected_version: 0 + idempotency_key: contact:fixture-stale:consent:v1 + engagement_history: { opens_count: 0, clicks_count: 0, hard_bounces: 0, recency_days: 180 } + bounce_policy: { hard_bounce_action: suppress, decay_threshold_days: 90 } + current_consent_state: { state: subscribed, active_unsubscribe_marker: false, evidence_status: stale, evidence_version: 0 } +expect: + status: sealed + steps: [read_contact, decide, readback, finalize] + step_outputs: + finalize: + subset: + list_hygiene_result: + data: + decision: { state: human_review } + recorded_transition: { recorded: false, after_version: 0 } + receipt: { schema: runx.receipt.v1 } +metadata: { public_skill: list-hygiene-judge, source_case: stale-evidence-stop, source: skills-fixture } diff --git a/skills/list-hygiene-judge/list-hygiene.mjs b/skills/list-hygiene-judge/list-hygiene.mjs new file mode 100644 index 00000000..9c40d3e1 --- /dev/null +++ b/skills/list-hygiene-judge/list-hygiene.mjs @@ -0,0 +1,225 @@ +const WRITABLE_STATES = new Set(["re_permission", "suppress"]); + +export function decideListHygiene(inputs) { + const aggregateId = nonempty(inputs.aggregate_id, "aggregate_id"); + const expectedVersion = nonnegativeInteger(inputs.expected_version, "expected_version"); + const idempotencyKey = nonempty(inputs.idempotency_key, "idempotency_key"); + const dataSourceRef = nonempty(inputs.data_source_ref, "data_source_ref"); + const resource = nonempty(inputs.resource, "resource"); + const engagement = requiredObject(inputs.engagement_history, "engagement_history"); + const policy = requiredObject(inputs.bounce_policy, "bounce_policy"); + const consent = requiredObject(inputs.current_consent_state, "current_consent_state"); + nonnegativeInteger(engagement.opens_count, "engagement_history.opens_count"); + nonnegativeInteger(engagement.clicks_count, "engagement_history.clicks_count"); + nonnegativeInteger(engagement.hard_bounces, "engagement_history.hard_bounces"); + nonnegativeInteger(engagement.recency_days, "engagement_history.recency_days"); + if (!["suppress", "human_review"].includes(policy.hard_bounce_action)) { + throw new Error("bounce_policy.hard_bounce_action must be suppress or human_review"); + } + const decayThreshold = nonnegativeInteger( + policy.decay_threshold_days, + "bounce_policy.decay_threshold_days", + ); + if (decayThreshold < 1 || decayThreshold > 3650) { + throw new Error("bounce_policy.decay_threshold_days must be between 1 and 3650"); + } + if (!["subscribed", "re_permission", "suppress", "unsubscribed"].includes(consent.state)) { + throw new Error("current_consent_state.state is unsupported"); + } + if (typeof consent.active_unsubscribe_marker !== "boolean") { + throw new Error("current_consent_state.active_unsubscribe_marker must be boolean"); + } + if (!["read", "missing", "unreadable", "stale", "ambiguous"].includes(consent.evidence_status)) { + throw new Error("current_consent_state.evidence_status is unsupported"); + } + nonnegativeInteger(consent.evidence_version, "current_consent_state.evidence_version"); + const contactReadback = object(inputs.contact_readback); + const projection = object(contactReadback.projection); + const projectionVersion = integer(projection.version, 0); + + const base = { + aggregate_id: aggregateId, + data_source_ref: dataSourceRef, + resource, + expected_version: expectedVersion, + idempotency_key: idempotencyKey, + projection_version: projectionVersion, + projection_digest: text(contactReadback.projection_digest), + }; + + if (projectionVersion !== expectedVersion) { + return planStop( + base, + `contact projection version ${projectionVersion} does not match expected_version ${expectedVersion}`, + "stale_version", + ); + } + if (consent.evidence_status !== "read") { + return planStop( + base, + `engagement evidence is ${consent.evidence_status}; fresh readable evidence is required`, + consent.evidence_status === "ambiguous" ? "ambiguous_bounce_recovery" : "missing_or_unreadable_evidence", + ); + } + if (consent.evidence_version !== expectedVersion) { + return planStop( + base, + `evidence_version ${consent.evidence_version} does not match expected_version ${expectedVersion}`, + "stale_evidence", + ); + } + if (consent.active_unsubscribe_marker || consent.state === "unsubscribed") { + return planStop( + base, + "an active unsubscribe marker forbids automated re-permission", + "active_unsubscribe", + ); + } + if (engagement.hard_bounces > 0) { + if (policy.hard_bounce_action !== "suppress") { + return planStop( + base, + "hard-bounce recovery is ambiguous under the supplied policy", + "ambiguous_bounce_recovery", + ); + } + return planWrite(base, consent.state, "suppress", "verified hard-bounce evidence requires suppression", { + hard_bounces: engagement.hard_bounces, + hard_bounce_action: policy.hard_bounce_action, + }); + } + if (engagement.recency_days > policy.decay_threshold_days) { + return planWrite( + base, + consent.state, + "re_permission", + `recency_days ${engagement.recency_days} exceeds decay_threshold_days ${policy.decay_threshold_days}`, + { + recency_days: engagement.recency_days, + decay_threshold_days: policy.decay_threshold_days, + hard_bounces: engagement.hard_bounces, + }, + ); + } + return planStop(base, "no safe automated consent transition is required", "no_transition_required"); +} + +export function finalizeListHygiene(inputs) { + const plan = object(inputs.decision_plan); + const readback = object(inputs.recorded_readback); + const projection = object(readback.projection); + const projectionDigest = text(readback.projection_digest); + const projectionVersion = integer(projection.version, 0); + const appendAllowed = plan.append_allowed === true; + const expectedProjectionVersion = appendAllowed + ? plan.expected_version + 1 + : plan.projection_version; + + if (projectionVersion !== expectedProjectionVersion) { + throw new Error( + `readback projection version ${projectionVersion} does not match expected ${expectedProjectionVersion}`, + ); + } + if (appendAllowed && projection.last_event_type !== plan.event.type) { + throw new Error("readback did not observe the planned consent transition event"); + } + if (appendAllowed && !WRITABLE_STATES.has(plan.decision.state)) { + throw new Error("only re_permission or suppress may be recorded automatically"); + } + + return { + list_hygiene_result: { + decision: plan.decision, + recorded_transition: { + recorded: appendAllowed, + state: appendAllowed ? plan.decision.state : "human_review", + aggregate_id: plan.aggregate_id, + data_source_ref: plan.data_source_ref, + resource: plan.resource, + idempotency_key: plan.idempotency_key, + before_version: plan.projection_version, + after_version: projectionVersion, + event_type: appendAllowed ? plan.event.type : null, + projection_digest: projectionDigest, + }, + escalation: appendAllowed + ? null + : { + lane: "human:list-hygiene-reviewer", + reason_code: plan.reason_code, + status: "required_before_any_write", + }, + downstream_send: { + skill: "send-as", + status: "not_run", + requirement: "read the recorded consent state at send time and refuse suppressed contacts", + }, + }, + }; +} + +function planWrite(base, fromState, state, reason, evidence) { + return { + decision_plan: { + ...base, + decision: { state, reason }, + reason_code: state === "suppress" ? "hard_bounce" : "engagement_decay", + append_allowed: true, + event: { + type: "list_hygiene.consent_transitioned", + aggregate_id: base.aggregate_id, + from_state: fromState, + new_state: state, + reason, + evidence, + evidence_version: base.expected_version, + idempotency_key: base.idempotency_key, + }, + }, + }; +} + +function planStop(base, reason, reasonCode) { + return { + decision_plan: { + ...base, + decision: { state: "human_review", reason }, + reason_code: reasonCode, + append_allowed: false, + event: null, + }, + }; +} + +function object(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +} + +function integer(value, fallback) { + return Number.isInteger(value) && value >= 0 ? value : fallback; +} + +function text(value) { + return typeof value === "string" ? value : ""; +} + +function requiredObject(value, name) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${name} must be an object`); + } + return value; +} + +function nonempty(value, name) { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`${name} must be a non-empty string`); + } + return value; +} + +function nonnegativeInteger(value, name) { + if (!Number.isInteger(value) || value < 0) { + throw new Error(`${name} must be a non-negative integer`); + } + return value; +} From 0740bff30080cf101e4db2b0b610ef3119b0ee04 Mon Sep 17 00:00:00 2001 From: Timothy Robert Peterson <312591046+ArgonautWorks@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:45:25 +0200 Subject: [PATCH 2/4] fix: make list hygiene package portable Signed-off-by: Timothy Robert Peterson <312591046+ArgonautWorks@users.noreply.github.com> --- skills/list-hygiene-judge/SKILL.md | 12 +++++++----- skills/list-hygiene-judge/X.yaml | 12 ++++++------ 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/skills/list-hygiene-judge/SKILL.md b/skills/list-hygiene-judge/SKILL.md index d882d3ef..3afa864d 100644 --- a/skills/list-hygiene-judge/SKILL.md +++ b/skills/list-hygiene-judge/SKILL.md @@ -17,9 +17,11 @@ send authority, a campaign proposal, or proof that a message was delivered. ## Operating model -1. Read the contact projection through the provider-neutral `data-store` skill. - Bind `data_source_ref`, `resource`, and `aggregate_id` to the contact's event - stream. The read establishes the durable version used by the decision. +1. Read the contact projection through the exact provider-neutral + `data.read_projection` operation owned by the canonical `data-store` + contract. Bind `data_source_ref`, `resource`, and `aggregate_id` to the + contact's event stream. The read establishes the durable version used by the + decision. 2. Admit the supplied engagement and consent evidence only when its status is `read`, its `evidence_version` equals `expected_version`, and the durable projection has that same version. Missing, unreadable, ambiguous, or stale @@ -44,8 +46,8 @@ that cannot supply them must repair the upstream evidence read rather than fill defaults. The runner requests only `runx:data:read` and `runx:data:append` through the -canonical `data-store` capability. The allowed append is intentionally ungated: -it records contact policy state, not external delivery. The stable +canonical data operations used by `data-store`. The allowed append is +intentionally ungated: it records contact policy state, not external delivery. The stable `idempotency_key` makes an unchanged retry return the already-recorded version; a competing write produces a version conflict and must be retried only after a fresh read and new decision. diff --git a/skills/list-hygiene-judge/X.yaml b/skills/list-hygiene-judge/X.yaml index 53a23211..63205e67 100644 --- a/skills/list-hygiene-judge/X.yaml +++ b/skills/list-hygiene-judge/X.yaml @@ -165,8 +165,8 @@ runners: result_from: [finalize] steps: - id: read_contact - skill: ../data-store - runner: read_projection + tool: data.read_projection + scopes: [runx:data:read] inputs: data_source_ref: $input.data_source_ref resource: $input.resource @@ -195,8 +195,8 @@ runners: when: field: decide.decision_plan.append_allowed equals: true - skill: ../data-store - runner: append_event + tool: data.append_event + scopes: [runx:data:append] inputs: data_source_ref: $input.data_source_ref resource: $input.resource @@ -207,8 +207,8 @@ runners: event: decide.decision_plan.event - id: readback - skill: ../data-store - runner: read_projection + tool: data.read_projection + scopes: [runx:data:read] inputs: data_source_ref: $input.data_source_ref resource: $input.resource From de0062a8d424eb522440ea546a81bd49d33bded7 Mon Sep 17 00:00:00 2001 From: Timothy Robert Peterson <312591046+ArgonautWorks@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:46:51 +0200 Subject: [PATCH 3/4] test: add malformed evidence refusal Signed-off-by: Timothy Robert Peterson <312591046+ArgonautWorks@users.noreply.github.com> --- skills/list-hygiene-judge/X.yaml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/skills/list-hygiene-judge/X.yaml b/skills/list-hygiene-judge/X.yaml index 63205e67..5dadb15e 100644 --- a/skills/list-hygiene-judge/X.yaml +++ b/skills/list-hygiene-judge/X.yaml @@ -123,6 +123,20 @@ harness: recorded_transition: { recorded: false, state: human_review, aggregate_id: "contact:unsubscribed", after_version: 0 } receipt: { schema: runx.receipt.v1 } + - name: reject_negative_engagement_metrics + runner: judge + inputs: + data_source_ref: local://list-hygiene-judge/invalid-metrics + resource: contact_consent_events + aggregate_id: contact:invalid-metrics + expected_version: 0 + idempotency_key: contact:invalid-metrics:consent:v1 + engagement_history: { opens_count: 0, clicks_count: 0, hard_bounces: -1, recency_days: 10 } + bounce_policy: { hard_bounce_action: suppress, decay_threshold_days: 90 } + current_consent_state: { state: subscribed, active_unsubscribe_marker: false, evidence_status: read, evidence_version: 0 } + expect: + status: failure + runners: judge: default: true From a92103ee690beb068a4cb4b0af0d919f439ba06c Mon Sep 17 00:00:00 2001 From: Timothy Robert Peterson <312591046+ArgonautWorks@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:51:05 +0200 Subject: [PATCH 4/4] fix: use hosted-compatible skill runners Signed-off-by: Timothy Robert Peterson <312591046+ArgonautWorks@users.noreply.github.com> --- skills/list-hygiene-judge/X.yaml | 48 +++++++++++++------ .../{list-hygiene.mjs => run.mjs} | 20 ++++++-- 2 files changed, 50 insertions(+), 18 deletions(-) rename skills/list-hygiene-judge/{list-hygiene.mjs => run.mjs} (93%) diff --git a/skills/list-hygiene-judge/X.yaml b/skills/list-hygiene-judge/X.yaml index 5dadb15e..fd4ce100 100644 --- a/skills/list-hygiene-judge/X.yaml +++ b/skills/list-hygiene-judge/X.yaml @@ -138,6 +138,35 @@ harness: status: failure runners: + decide: + type: cli-tool + command: /usr/bin/env + args: [node, run.mjs, decide] + outputs: + decision_plan: object + inputs: + data_source_ref: { type: string, required: true, description: Logical contact data source. } + resource: { type: string, required: true, description: Contact consent resource. } + aggregate_id: { type: string, required: true, description: Contact stream key. } + expected_version: { type: number, required: true, description: Current compare-and-set version. } + idempotency_key: { type: string, required: true, description: Stable transition retry key. } + engagement_history: { type: json, required: true, description: Fresh engagement and bounce evidence. } + bounce_policy: { type: json, required: true, description: Hard-bounce and decay policy. } + current_consent_state: { type: json, required: true, description: Current consent and evidence freshness. } + + finalize: + type: cli-tool + command: /usr/bin/env + args: [node, run.mjs, finalize] + outputs: + list_hygiene_result: object + artifacts: + named_emits: + list_hygiene_result: list_hygiene_result + inputs: + decision_plan: { type: json, required: true, description: Admitted list-hygiene decision plan. } + recorded_readback: { type: json, required: true, description: Data-store projection read after the optional append. } + judge: default: true type: graph @@ -198,12 +227,8 @@ runners: current_consent_state: $input.current_consent_state context: contact_readback: read_contact.data_operation_result.data - run: - type: javascript - module: list-hygiene.mjs - export: decideListHygiene - outputs: - decision_plan: object + skill: . + runner: decide - id: append_transition when: @@ -232,12 +257,5 @@ runners: context: decision_plan: decide.decision_plan recorded_readback: readback.data_operation_result.data - run: - type: javascript - module: list-hygiene.mjs - export: finalizeListHygiene - outputs: - list_hygiene_result: object - artifacts: - named_emits: - list_hygiene_result: list_hygiene_result + skill: . + runner: finalize diff --git a/skills/list-hygiene-judge/list-hygiene.mjs b/skills/list-hygiene-judge/run.mjs similarity index 93% rename from skills/list-hygiene-judge/list-hygiene.mjs rename to skills/list-hygiene-judge/run.mjs index 9c40d3e1..1687829a 100644 --- a/skills/list-hygiene-judge/list-hygiene.mjs +++ b/skills/list-hygiene-judge/run.mjs @@ -1,6 +1,21 @@ +import fs from "node:fs"; + const WRITABLE_STATES = new Set(["re_permission", "suppress"]); -export function decideListHygiene(inputs) { +const operation = process.argv[2]; +const raw = process.env.RUNX_INPUTS_PATH + ? fs.readFileSync(process.env.RUNX_INPUTS_PATH, "utf8") + : process.env.RUNX_INPUTS_JSON || "{}"; +const inputs = JSON.parse(raw); + +let result; +if (operation === "decide") result = decideListHygiene(inputs); +else if (operation === "finalize") result = finalizeListHygiene(inputs); +else throw new Error("expected decide or finalize operation"); + +process.stdout.write(`${JSON.stringify(result)}\n`); + +function decideListHygiene(inputs) { const aggregateId = nonempty(inputs.aggregate_id, "aggregate_id"); const expectedVersion = nonnegativeInteger(inputs.expected_version, "expected_version"); const idempotencyKey = nonempty(inputs.idempotency_key, "idempotency_key"); @@ -36,7 +51,6 @@ export function decideListHygiene(inputs) { const contactReadback = object(inputs.contact_readback); const projection = object(contactReadback.projection); const projectionVersion = integer(projection.version, 0); - const base = { aggregate_id: aggregateId, data_source_ref: dataSourceRef, @@ -104,7 +118,7 @@ export function decideListHygiene(inputs) { return planStop(base, "no safe automated consent transition is required", "no_transition_required"); } -export function finalizeListHygiene(inputs) { +function finalizeListHygiene(inputs) { const plan = object(inputs.decision_plan); const readback = object(inputs.recorded_readback); const projection = object(readback.projection);