Reference

Policies

A policy is an ordered list of rules with a default action. First match wins. Every rule reads typed fields from a closed vocabulary, so a policy can be read top to bottom by a person and evaluated with no model in the loop.

Endpoints

MethodPathDoes
GET/v1/policiesEvery policy slug with its active and latest version.
POST/v1/policiesCreate a slug and its first version. 409 policy_conflict if the slug exists. Does not activate.
GET/v1/policies/:slugThe active version. 404 if the slug has none active.
GET/v1/policies/:slug/versions/:nOne version, with the English it came from and its author.
POST/v1/policies/:slug/versionsA new immutable version. Does not activate.
POST/v1/policies/:slug/activate{ version } — flips the active pointer and records when.
POST/v1/policies/compileEnglish in, candidate rule set out. Saves nothing.
POST/v1/policies/replayA candidate over your last N decisions. Saves nothing.

Versions are immutable. Creating one never changes what /v1/decide does; activating one does, within about a minute, as cached copies expire. To roll back, create a new version with the old rules and activate it — activating a version older than the active one is a 409.

The rule set

{
  "slug": "default",
  "source": { "kind": "manual" },        // nl | manual | recommendation
  "default_action": "allow",             // used when no rule matches
  "rules": [
    {
      "id": "r_partners",                 // r_ + [a-z0-9_], unique in the version
      "label": "Partners are always allowed",
      "when": { "field": "email.domain", "op": "in_list", "list": "partners" },
      "then": { "action": "allow" },
      "on_unknown": "skip"
    },
    {
      "id": "r_no_disposable",
      "label": "No disposable addresses",
      "when": { "field": "verification.email_type", "op": "eq", "value": "disposable" },
      "then": { "action": "deny" },
      "on_unknown": "skip"
    },
    {
      "id": "r_agents",
      "label": "Agents go to the API-key flow",
      "when": { "field": "verification.actor_class", "op": "eq", "value": "agent_mailbox" },
      "then": { "action": "allow_with_flow", "flow": "agent" },
      "on_unknown": "skip"
    },
    {
      "id": "r_industry_fit",
      "label": "Review companies outside fintech and insurance",
      "when": { "field": "enrichment.company.industry", "op": "not_in", "value": ["fintech", "insurance"] },
      "then": { "action": "review", "tags": ["off_icp"] },
      "on_unknown": "skip"
    }
  ]
}
  • then.action is one of allow, allow_with_flow, review, challenge, deny. flow is required with allow_with_flow and forbidden otherwise; it is lowercase letters, digits, - and _.
  • on_unknown decides what happens when the rule cannot be evaluated because a field it reads is missing. skip (the default) falls through to the next rule; match treats missing as a match. A rule that denies on missing enrichment is accepted, with a warning that missing data will become lost signups.
  • default_action defaults to allow. default_flow is required when it is allow_with_flow.
  • requires — which enrichment fields, lists, attributes and whether ICP the rules read — is always computed by the server. A submitted one is ignored. It is what lets /v1/decide skip enrichment for a policy that never needs it.

Email types

verification.email_type is what kind of address it is: business, free, role, disposable, relay or unknown (definitions and precedence in email type). free is the consumer-provider class (Gmail, Outlook, Yahoo, Apple and the rest), the one people also call social, personal or consumer email.

The Email types card at the top of the dashboard’s Policy screen sets Allow, Review or Block for free (the row “Free / social email”), role, relay and disposable addresses (business is always allowed). It is not a separate engine: each setting is one rule with a reserved id in your default policy, placed before your own rules.

{ "id": "r_type_free", "label": "Free email providers",
  "when": { "field": "verification.email_type", "op": "eq", "value": "free" },
  "then": { "action": "deny" }, "on_unknown": "skip" }
  • Block is a deny rule, Review a review rule, and Allow is no rule at all. An explicit allow at the top would let those addresses past every rule below it, which a switch labelled Allow must not do.
  • The reserved ids are r_type_free, r_type_role, r_type_relay and r_type_disposable. A flip saves a new version that changes that one rule and activates it, with source: { kind: "manual", text: "Email types: free=block" }. You can write the same rules through the API; the card reads them back by id.
  • Until your organisation has a default policy, the card shows disposable on Block and everything else on Allow, and the first switch you flip saves those settings as version 1. A policy that already existed without an r_type_disposable rule shows disposable on Allow, because that is what it does; the card never adds a rule you did not flip.
  • The rules act in /v1/decide only. /v1/verify and /v1/check keep answering whether the address delivers, so a blocked free address still reads likely_valid there and carries email_type: "free" for you to act on.
  • Your Blocklists run before any of this, in the verification itself: a blocked address or domain fails there, and an allowed one is never typed disposable. An allow does not exempt an address from the free, role or relay rules: an allowed Gmail address is still free.

The same card has a Describe a rule box that sends your sentence to compile with the active default version as the base, shows the diff, the compiler’s explanation and a replay, and saves nothing until you activate. The compiler is taught these sentences as worked examples, and they are part of the evaluation set its prompt has to pass:

You writeIt compiles to
“block all non business emails”verification.email_type neq business → deny
“don't allow gmail, yahoo and social emails”verification.email_type eq free → deny
“block social email ids”verification.email_type eq free → deny
“block only gmail.com and yahoo.com”email.domain in [gmail.com, yahoo.com] → deny
“review role accounts, block disposable”verification.email_type eq role → review; verification.email_type eq disposable → deny
“allow icloud but block other social emails”email.domain in [icloud.com, me.com, mac.com] → allow; then verification.email_type eq free → deny
“block apple relay emails”verification.email_type eq relay → deny (every alias service, not only Apple)
“block free email except for signups from the EU”No rule yet: a question back, because the country has to reach Sortance first (send it as attributes.country)

“Gmail, yahoo and social emails” is read as the whole free class, so it also blocks outlook.com and icloud.com; the explanation says so. Name the domains (“only gmail.com and yahoo.com”) to block just those.

Predicates

{ "all": [...] }, { "any": [...] } and { "not": ... } combine leaves. Evaluation is three-valued: a leaf over a missing field is unknown, not false. all is a miss if any child misses, otherwise unknown if any child is unknown; any is a match if any child matches, otherwise unknown if any is unknown; not keeps unknown as unknown. The rule’s on_unknown only applies at the top.

opvalueMeaning
eq, neqstring, number or booleanCase-sensitive equality. On company.tech, membership of one value.
lt, lte, gt, gtenumberNumber fields only.
in, not_inarray of strings or numbersSet membership. On company.tech, in means any element is in the set and not_in means none is.
matchesglob string* matches any run of characters; nothing else is special; case-insensitive. No regex.
in_list, not_in_listlist nameMembership of a named list. A list that cannot be loaded is unknown, never "not a member".
present, absentnoneTests whether the field has a value. Never unknown.

Fields a rule may read

This is the whole vocabulary. The validator and the compiler reject anything else, including a value outside a field’s closed set: company.industry eq "finance" fails because finance is not an industry value. “Replayable” says whether the field is kept on the stored decision; the ones that are not are personal data, and replay evaluates them as unknown.

FieldTypeValuesReplayable
verification.verdictstringvalid, likely_valid, risky, likely_invalid, invalid, unknownYes
verification.bounce_probabilitynumber0–1Yes
verification.recommendationstringsend, hold, challenge, dropYes
verification.actor_classstringhuman_mailbox, agent_mailbox, relay_alias, role_account, shared, unknownYes
verification.mailbox_typestringcorporate, consumer, role, unknown. To block or review free email, use verification.email_type eq free, which is what the Email types card and the compiler writeYes
verification.espstringmicrosoft365, google_workspace, consumer_esp, self_hosted, unknownYes
verification.email_typestringbusiness, free, role, disposable, relay, unknown. free is the consumer-provider class (Gmail, Outlook, Yahoo, Apple, …), also called social emailYes
verification.signals.syntaxstringpass, failYes
verification.signals.mxstringpass, failYes
verification.signals.disposablebooleanYes
verification.signals.role_accountbooleanYes
verification.signals.catch_allbooleanYes
verification.signals.relay_aliasbooleanYes
verification.calibrationstringprior, fittedYes
verification.degradedbooleanAbsent means false, not unknownYes
email.domainstringLowercasedYes
email.local_partstringLowercasedNo
email.tldstringLast label only: uk, not co.ukYes
enrichment.person.titlestringFree text from the vendorNo
enrichment.person.senioritystringc_suite, vp, director, manager, senior, entry, unknownYes
enrichment.person.departmentstringengineering, product, design, marketing, growth, sales, customer_success, finance, legal, hr, operations, executive, other, unknownYes
enrichment.person.location.countrystringISO country codeYes
enrichment.company.domainstringYes
enrichment.company.industrystringThe industry vocabulary belowYes
enrichment.company.employeesnumberYes
enrichment.company.revenue_rangestringAs the vendor reports itYes
enrichment.company.foundednumberYearYes
enrichment.company.countrystringISO 3166-1 alpha-2, uppercaseYes
enrichment.company.typestringpublic, private, nonprofit, government, education, unknownYes
enrichment.company.techstring[]Lowercased technology slugsYes
enrichment.coverage.personnumber0–1Yes
enrichment.coverage.companynumber0–1Yes
enrichment.visitor.identifiedbooleanFrom a push sourceYes
enrichment.visitor.pagesnumberFrom a push sourceYes
icp.scorenumber0–100Yes
icp.fitstringstrong, fit, weak, no_fitYes
icp.coveragenumber0–1Yes
actor.tierstringcryptographic, infrastructure, behavioural, none — always unknown todayYes
actor.authorizedbooleanAlways unknown todayYes
actor.agent_identity.operatorstringAlways unknown todayYes
attributes.<name>stringYour own, sent in context.attributesYes

Attribute names match ^[a-z][a-z0-9_]{0,63}$. Reading an icp.* field is rejected while your account has no active ICP rubric.

Industry values

Every enrichment vendor’s industry label is mapped into this list; a label no mapping recognises becomes other and the vendor’s own text is kept in company.industry_raw for display. An absent industry is null (unknown), never other.

fintech, banking, insurance, investment_management, venture_capital, accounting, crypto_web3, real_estate, saas, software, developer_tools, artificial_intelligence, data_analytics, cybersecurity, it_services, internet, ecommerce, marketplaces, gaming, edtech, health_tech, hr_tech, martech, proptech, legal_tech, hardware, semiconductors, telecommunications, manufacturing, industrial_automation, automotive, aerospace_defense, construction, chemicals, mining_metals, energy, clean_energy, utilities, agriculture, logistics, transportation, travel, hospitality, retail, consumer_goods, food_beverage, sports_fitness, healthcare, pharmaceuticals, biotech, medical_devices, consulting, legal, marketing_advertising, staffing_recruiting, media, entertainment, publishing, education, government, nonprofit, other

POST /v1/policies/compile

English in, a candidate rule set out. Nothing is saved. A model is called exactly here, at authoring time, and never on a decision.

// request
{
  "text": "Don't allow temp emails. Anyone on the partners list is always allowed. Let AI agents in through the API-key flow.",
  "base": { "slug": "default", "version": 13 },   // optional: the version this text amends
  "slug": "default"                                 // optional
}

// 200
{
  "candidate": { /* a rule set, as above, unsaved */ },
  "explanation": [ { "rule_id": "r_partners", "says": "..." } ],
  "warnings": [ "..." ],
  "questions": [ { "id": "...", "question": "...", "options": ["..."], "default": "..." } ],
  "requires": { "enrichment": false, "enrichment_fields": [], "icp": false, "lists": ["partners"], "attributes": [] }
}
  • The output is validated by the same code that validates a policy you write by hand. A field, list, value or action the schema does not know is rejected; the model cannot widen the vocabulary.
  • When the text is ambiguous, questions is non-empty and the compiler asks rather than guesses. When it cannot produce a valid rule set at all, the answer is 422 compile_failed with the questions inside error.questions.
  • The model sees your text, the rules of the base version when you are amending one, the field vocabularies, your list names, kinds and entry counts, and your ICP criterion ids. It never sees a list entry, an enrichment profile or a decision.
  • Compile does not activate anything. Replay it, then save and activate it yourself.

POST /v1/policies/replay

Before you activate, run the candidate over your own recent decisions and see what would have changed.

{ "candidate": { /* rule set */ }, "window": { "last_n": 500 } }   // default 500, at most 2,000
FieldMeaning
evaluatedHow many stored decisions the candidate was run against.
decisionsCount per action: allow, allow_with_flow, review, challenge, deny.
by_rule{ rule_id, matched, note? } per rule that matched. note explains when on_unknown: match turned missing enrichment into matches.
changed_vs_activeOnly the transitions that occurred, keyed like allow_to_deny.
sampleUp to 20 rows: trace_id, email_masked (•••@domain — the address was never stored), old_decision, new_decision, rule_id. Changed rows first.
estimated_cost_delta_usd_per_1kExtra enrichment spend per 1,000 decisions versus the active version.
warningsIncluding how many decisions had no stored enrichment and were evaluated with enrichment: null.

Replay reads what each decision stored, which excludes personal fields (email.local_part, enrichment.person.title and the person’s name and LinkedIn URL). Rules over those fields replay as unknown. A list the candidate references but the stored decision did not is loaded as it is now. The cost estimate uses the per-lookup list prices recorded in the worker, which have not been confirmed against every vendor’s current rate card; read it as an order of magnitude.

Limits

WhatLimit
Rules per policy100 (a compiled policy is held to 50)
Predicate depth8
Predicate nodes per rule64
Values in an in / not_in set200
Rule label200 characters
Tags per rule16, each up to 64 characters
A string value in a predicate253 characters
Stored source text10,000 characters
Text sent to compile8,000 characters
Request body, writes and replay64 KB
Request body, compile16 KB

Errors

StatusCodeWhen
400invalid_requestMalformed body, bad slug or version, or compile text that is empty or too long.
400policy_invalidThe rule set fails the schema or names an unknown field, operator, vocabulary value, list or ICP. The message carries the first five errors with their paths.
404not_foundNo such slug or version.
409policy_conflictCreating a slug that exists, or activating a version older than the active one.
422compile_failedThe compiler could not produce a valid rule set, or is not configured on this deployment. error.questions carries what it needs answered.