SKILL.md v2 compliance

Organization adverse-indicators check

Screen one record's organisation for adverse indicators, and say what was not screened.

No reviews yet 0 installs
Maps to a partner-backed resource type and runs against one saved record at a time, on demand. Screens that record's organisation for sanctions, PEP, adverse media, regulatory enforcement, insolvency, disqualification, litigation and corporate actions via the Ploutonion organization-compliance agent — all ten IndicatorType values every time, so a type no source can answer comes back as a coverage gap rather than a silent omission. The agent's response is stored verbatim under adverse-indicators-checks/ and an entry is appended to adverse-indicators-checks.json beside the record: the raw file is what the agent said, the log is what we concluded about it, and the log points at the findings rather than copying them. Screens the named entity only — parent companies and beneficial owners are not screened, and every outcome and every log entry says so. The organisation name must come from identity.json (a name typed on the record is not identification), so an unidentified record is skipped with a reason and nothing is written for it. The same skill bundle carries the analyst's verdict entry point: the screen can never return a FalsePositive, so clearing a finding is a human decision, and a later check suppresses a cleared finding and records that it did. Never invents a finding. Does not run ISO or modern-slavery discovery.
adverse-indicatorssanctionspepscreeningsupplierscustomerscompliance

Versions

  • v2 # Changelog ## v1 (in progress) - Initial release. The per-record, on-demand adverse-indicators check: an organisation maps it to a partner-backed resource type and it screens one saved record's organisation via the Ploutonion `organization-compliance` agent, running the shared `organisation-adverse-indicators-check` skill bundle exactly once. There is no estate-wide half and nothing re-screens on a schedule. - Declares the Ploutonion `organization-compliance` agent in `referencedA2aAgents` with its full catalog shape (`source`, `catalogProvider`, `catalogAgentId`, `cardUrl`, `endpointUrl`) rather than an id alone: those fields are how the agent resolves, and the broker refuses an agent the blueprint does not list (`a2a_agent_not_allowed`) — which the skill re-raises rather than writing an empty screen, since scoring a record clean would hide the misconfiguration behind a plausible answer. - `action.output.path` is `adverse-indicators-checks.json`, equal to the skill's `RECORD_FILENAME`. The seam is pinned from both sides by the bundle's `lib/test_action_contract.py`, because nothing else compares the two: the freshness gate reads the declared path, so a mismatch never satisfies, the record's page stays empty, and the paid check is re-dispatched on every planning pass for ever. - `action.output.evidence` is `latest.checkedAt` and `latest.coverageOutcome`, and the choice is load-bearing. The gate reads an evidence name as a dot path from the file's root and treats `0`, `""`, `false` and `[]` as "wrote this field and learned nothing" — so an undotted name resolves to nothing, and the reader-facing counts (`findingCount`, `openPotentialMatches`) are **zero on a clean screen**, which is the normal outcome. Either mistake makes every clean record read as a non-answer and re-dispatches the paid check for ever. `indicatorTypesUnanswered` is unfit for a second reason: it is `string[]` rather than a list of `IndicatorType` members on purpose, carrying raw coverage prose when the parse finds nothing, so a renderer expecting enum members would show a sentence as a malformed type. `checkedAt` and `coverageOutcome` are present and non-empty whenever a check happened at all, and this skill writes nothing unless the agent answered. - `action.output.freshnessDays` is 30, matching Organisation identity's window rather than ISO's 7. The screen is a paid call per record; sanctions and disqualification registers move on the order of weeks, and only adverse media is faster, so a weekly re-screen would pay four times over for data that has not changed. Nothing re-screens automatically, so this window is the gate on a re-dispatch rather than a promise of freshness — and it does not distinguish a complete check from an incomplete one: a `degraded`, `timed_out` or `unreported` check holds the 30 days exactly as a clean one does, because the gate takes any substantive evidence name as an answer and `coverageOutcome` is non-empty on every outcome. Re-screening after a source outage is the analyst's act, and the record's own `screenCompleted` / `coverageOutcome` are what tell the two apart. - `action.description` states the **subject-only** limitation. It is the sentence an analyst reads before running the check, and a clean result from a check that never looked at the ownership chain is misleading unless the limitation is visible at the point of use — a sanctioned beneficial owner behind a clean-looking company is the primary sanctions-evasion route. - The prompt forbids recording a verdict from this action. The verdict flags are the analyst's entry point into the same bundle, on their own instruction; the screen can never return a `FalsePositive`, so clearing a finding is a human decision. - The prompt and `BLUEPRINT.md` record what this blueprint does **not** run: `iso-certificates.discover`, modern-slavery statement discovery and the UK registry lookup. The same agent answers all of those, and calling one from here would bill a discovery nobody asked for. 11/5/2026
  • v1 # Changelog ## v1 (in progress) - Initial bundle: resolve one record's identity and make a single `adverse-indicators.screen` call to the Ploutonion `organization-compliance` agent, returning the agent's response **verbatim**. - **The action contract, pinned from this side** (`lib/test_action_contract.py`). The `organisation-adverse-indicators-check` blueprint's `action.output.path` must equal `RECORD_FILENAME`, and nothing else compares the two — one is JSON in another bundle, the other a Python constant here. The freshness gate reads the declared path, so a mismatch never satisfies, the record's page stays empty, and the paid check is re-dispatched on every planning pass for ever. The suite skips on the blueprint TREE being absent (a deployed sandbox holds only this bundle) and fails loudly naming the file when the tree is present and the blueprint is not: skipping on the file is how a sibling bundle's pins went green through a rename. It also pins the declared `evidence` — dotted from the file root, and **substantive on a clean check** — the agent entry's full catalog shape, and the subject-only sentence in `action.description`. - **`monitorTimestamp` is owned too — by being dropped.** The gate reads it **before** `timestamp`, so a log that ever acquired one would be dated by it for ever: the same date-for-ever bug one field over, with the precedence reversed so the stale value wins, and reading *fresh* for ever rather than stale — the re-screen suppressed instead of repeated. Nothing writes that key today, which makes it latent rather than absent; it is fixed anyway, because a known instance of a class already seen three times in this bundle, one line from a fix already in hand, is not worth leaving for the condition that makes it reachable. This bundle runs no monitor pass, so the honest value is absent and the key is not carried through; an increment that really adds one must project it in `write_log` beside `timestamp`. The pin asserts the date the GATE derives — its own precedence, mirrored — rather than either field, so dropping the key and re-projecting it are both allowed and only a stale value winning is not. - **A missing or renamed blueprint fails the contract test by name**, rather than raising at import and aborting collection, which ran 0 of the bundle's tests instead of failing one. That was loud (`rc 2`, a named `FileNotFoundError`) but it said "everything is broken" where the truth was "this contract is broken", and the one test guarding a seam that is otherwise silent and expensive should name itself. The module-level skip stays on the blueprint TREE, which is genuinely absent in a deployed sandbox holding only this bundle; the FILE is read inside each test. - **The log carries a top-level `timestamp`**, projected from the latest check's `checkedAt` on every write. The marketplace freshness gate dates a record from a top-level `monitorTimestamp`/`timestamp` and nowhere else, so a log carrying its instants only on the entries is `undatable` — which counts as stale on every planning pass, for ever, re-dispatching the paid check each time. The gate's own comment assumes an undatable record self-heals in one run because "every writer stamps a timestamp"; a writer that stamps none never heals, which is the same silent, expensive failure as a mismatched `action.output.path` reached through the other half of the same gate. It is the one top-level key the writer owns rather than carries through: a stale value preserved by the unknown-keys rule would date the record to its first check for ever. - **The action's declared evidence is `latest.checkedAt` and `latest.coverageOutcome`**, and the counts are deliberately NOT declared. The gate resolves an evidence name as a dot path from the file's root — so a bare key resolves to nothing at all — and treats `0`, `""`, `false` and `[]` as "wrote this field and learned nothing". `findingCount`, `openPotentialMatches`, `indicatorTypesUnanswered` and `ownershipChainScreened` are all empty on a clean screen, which is the normal outcome, so declaring one would make every clean record read as a non-answer. `indicatorTypesUnanswered` is doubly unfit: it is `string[]` rather than a list of `IndicatorType` members on purpose, carrying raw coverage prose when the parse finds nothing, so a renderer expecting enum members would display a sentence as a malformed type. Nothing is written unless the agent answered, which is what makes two always-present fields enough. - **The analyst verdict, and the suppression it produces.** The screen's score band yields only `TruePositive` and `PotentialMatch` on a list hit, so it can never return a `FalsePositive`: every false positive in this system is a human decision, and the screen is stateless — so without this surface nothing could clear a `PotentialMatch` and a flagged supplier stayed flagged forever. `--verdict FalsePositive --match-key … --reason …` records one, and a later check suppresses the cleared finding **and says it did** (`suppressedByVerdict`), because a shorter finding list on its own is indistinguishable from a vendor that stopped reporting the item. `TruePositive` confirms a finding and leaves it counted, and a later verdict on the same key supersedes the earlier one — so a dismissal is reversible and both decisions stay in the log. - **A verdict is keyed to a MATCH, not to the check it was recorded against**, so every verdict in the log is applied to each new check's findings: one recorded against check #1 suppresses the same match when it reappears in check #3, and a verdict whose match no longer appears suppresses nothing. Suppression that attached to the occasion would force an analyst to repeat every dismissal, which is how this kind of tooling gets ignored. - **A recorded verdict is not the same thing as a suppression, and the outcome says which it was.** `effect` reports `suppressed`, `matched`, `matched_nothing` or `unknown` against the latest check's findings, with `openPotentialMatches` and `suppressedByVerdict` beside it; the two surprising cases also print to stderr. A verdict that matched nothing is legitimate — the vendor may have stopped reporting the finding — and it is *also* what a mistyped or stale match key produces, so an outcome carrying only the stored verdict told an analyst their dismissal had taken effect when it had not. That is false **confidence** rather than a false clean, and on a surface whose whole purpose is letting a human clear a finding it is just as misleading. `effect` is reported and never stored: it describes one instant, and the same verdict matches or does not match depending on what the next check returns. - **`by` comes from the sandbox's own identity** (`QONSTRUE_USER_ID`, which the broker injects), never from anything the analyst types — a self-declared author on a compliance decision is not an audit trail. Unavailable, the verdict is still recorded, marked `unattributed`: losing the decision is worse than losing its author. A blank `--reason` is refused outright, because it is the field the next reader judges whether the verdict still applies by. - **The match key is a legible structured string, not a hash** — `party.normalisedName | indicatorType | subcategory | (evidence.sourceRef or evidence.url)` — because an audit file is read by people deciding whether a verdict still applies, and a digest tells them nothing. Each component is escaped (`|` → `%7C`, `%` → `%25`), so a vendor label containing the separator cannot merge two distinct findings into one key. A finding missing a required component is refused rather than blank-keyed and stays counted as OPEN, since a key with a blank component would be shared by every finding agreeing on the rest. - **The response readers moved to `lib/adverse_response.py`.** `adverse_check.py` had reached 809 lines across four concerns and the verdict path depends on those readers, so splitting now costs one move rather than two. `open_potential_matches` did not survive the move with a caller: `suppression_counts` is the only place that needs the pre-suppression count and derives it from the same `open_matches` list it has to key anyway, so routing it through a second function would traverse the response twice and split one arithmetic expression across two modules — which is the thing that docstring says must not happen. It is deleted rather than given a caller to justify it; a docstring explaining a division of labour nothing exercises is a claim the next reader would believe. - **An incomplete check holds the freshness window exactly as a clean one does**, and the record is the only thing that distinguishes them. The gate judges a record an answer if *any* declared evidence name holds substance, and `coverageOutcome` is a non-empty string on every outcome — so a `degraded`, `timed_out` or `unreported` check is `Fresh` for the full 30 days. That is the unavoidable side of the fix above (no evidence list can make an incomplete screen a non-answer without making a clean one a non-answer too, which is the re-dispatch bug), so it is documented rather than engineered around: re-screening after a source outage is the analyst's act, not the planner's, and `screenCompleted` / `coverageOutcome` are the fields that say which kind of check this was. - **The coverage fields are pinned on stdout as well as on disk** (`test_the_coverage_honesty_obligations_reach_stdout_and_not_only_the_file`). `check_one` repeats `screenCompleted`, `coverageOutcome`, `indicatorTypesUnanswered` and `findingCount` onto its outcome because the blueprint's prompt instructs the agent to report them beside a `screened` status and stdout is the only place the agent can read them — and each of the four was removable with the suite still green, so the reason they were there was a comment. One assertion per field, each agreeing with the entry on disk, so a dropped line fails by its own name. - **The record: the raw response, then the log.** A screened record gets the agent's response stored verbatim at `adverse-indicators-checks/<UTC instant>.json` and an entry appended to `adverse-indicators-checks.json` beside it. The split is the design's spine — the raw file is *what the agent said*, the log is *what we concluded about it* — so the log carries a pointer, the coverage it was given and the counts a reader acts on, and **never a copy of the findings**. The raw response lands first, so a failed log append costs a manual repair rather than the paid answer; an **unreadable** log fails the check instead of being replaced, because it is the only home for the analyst's verdicts. - **`screenCompleted` is true only when every coverage row says `screened`**, and a response carrying no coverage at all is `coverageOutcome: "unreported"` rather than clean — `all([])` is True, which is exactly how a screen that answered nothing would otherwise be recorded as a full one. `indicatorTypesUnanswered` is parsed from the coverage reasons and falls back to the raw reasons, then to an explicit "UNKNOWN" sentence: an empty list there would read as "everything was answered", which is the false clean this record exists to prevent. - `latest` is the last check entry **projected**, not a second derivation, so the verdict increment has one suppression count to update rather than two. `suppressedByVerdict: 0` and `verdicts: []` are declared from the first check because a suppression must be recorded — a silently shorter list is indistinguishable from a vendor that stopped reporting a finding. - **All ten `IndicatorType` values are requested every time.** The screen reports a requested type no source can answer as a coverage gap rather than silently omitting it, so asking generously makes the record honest about coverage without this bundle tracking which vendor supplies what — which would drift the moment a source is added. - **`timeout_sec=480` is passed explicitly.** The broker's long A2A default is keyed on `skill_id.endswith(".discover")` and this skill id is `adverse-indicators.screen`, so an omitted value silently falls back to 180s against a 420s worst case. Passed explicitly rather than depending on a default a sibling repo owns, even once cortex-sandbox #49 widens that suffix set. - **Subject entity only** (`context: "SubjectEntity"`), and every outcome says so (`partiesScreened`, `ownershipChainScreened: false`). Screening parents and beneficial owners needs ploutonion's 120-candidate adjudication cap to chunk rather than discard, so it is a later increment — and a clean result that does not state what it skipped is misleading in exactly the way this check exists to prevent. - **`categories` is not sent**, unlike the modern-slavery flow's hard-coded `["labour"]`: that narrowing is what this bundle exists to remove. A category filter would drop sanctions, PEP and insolvency findings while all ten indicator types were still being requested, so the result would read as a full screen. `lookbackDays` is not sent either, so the news window is the screen's own default (730 days at the time of writing) — an input this bundle neither sets nor pins. - **A record with no resolvable name is skipped and nothing is written for it.** The name comes from `identity.json` only (`firmographic.name` or `legal.organizationName`); a name on `resource.json` is whatever a person typed on import. The jurisdiction is deliberately looser — identity *or* the record's own `address.countryCode` — because a country on the record is user-supplied truth rather than a guess, and a lookup that read identity only skipped every GB record it was given. A missing `identity.json` and an identity carrying no name are reported with different reasons, because the remedy differs. - **A refused agent (`a2a_agent_not_allowed`) and a broker failure both raise.** The sibling modern-slavery flow degrades a failed discovery to `{}` because it still has an assessment to write around it; here the response *is* the answer, so a swallowed failure would be written down as a clean screen and a refusal would hide a missing `referencedA2aAgents` entry behind a plausible result. - `normalisedName` is sent as the organisation name, **unfolded**. It is the ownership contract's merge key, folded by exactly one normaliser — `organization-ownership`'s `normaliseName`, which the screen re-derives from `name` server-side — and the supplied value is echoed back in `matches[].party` and `coverage[].party`, so approximating the fold here would key one company two ways depending on which flow it arrived by. - Carries the `resource.json` reader (`CORTEX_RECORD_FIELDS`, `RESOURCE_FILENAME`, `_nonempty`, `resource_collection_for_slug`, `nest_resource_json`, `read_resource_record`, `ORGANIZATION_ROOT`, `collection_root_for_slug`) copied verbatim from `organisation-identity-write`, and registers this bundle in that bundle's `test_record_reader_parity.py` so the copy is byte-equality pinned from its first commit rather than from the first time someone notices it drifted. 11/5/2026