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
| Method | Path | Does |
|---|---|---|
GET | /v1/policies | Every policy slug with its active and latest version. |
POST | /v1/policies | Create a slug and its first version. 409 policy_conflict if the slug exists. Does not activate. |
GET | /v1/policies/:slug | The active version. 404 if the slug has none active. |
GET | /v1/policies/:slug/versions/:n | One version, with the English it came from and its author. |
POST | /v1/policies/:slug/versions | A new immutable version. Does not activate. |
POST | /v1/policies/:slug/activate | { version } — flips the active pointer and records when. |
POST | /v1/policies/compile | English in, candidate rule set out. Saves nothing. |
POST | /v1/policies/replay | A 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.actionis one ofallow,allow_with_flow,review,challenge,deny.flowis required withallow_with_flowand forbidden otherwise; it is lowercase letters, digits,-and_.on_unknowndecides what happens when the rule cannot be evaluated because a field it reads is missing.skip(the default) falls through to the next rule;matchtreats missing as a match. A rule that denies on missing enrichment is accepted, with a warning that missing data will become lost signups.default_actiondefaults toallow.default_flowis required when it isallow_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/decideskip 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
denyrule, Review areviewrule, 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_relayandr_type_disposable. A flip saves a new version that changes that one rule and activates it, withsource: { 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
defaultpolicy, 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 anr_type_disposablerule 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/verifyand/v1/checkkeep answering whether the address delivers, so a blocked free address still readslikely_validthere and carriesemail_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 stillfree.
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 write | It 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.
| op | value | Meaning |
|---|---|---|
eq, neq | string, number or boolean | Case-sensitive equality. On company.tech, membership of one value. |
lt, lte, gt, gte | number | Number fields only. |
in, not_in | array of strings or numbers | Set membership. On company.tech, in means any element is in the set and not_in means none is. |
matches | glob string | * matches any run of characters; nothing else is special; case-insensitive. No regex. |
in_list, not_in_list | list name | Membership of a named list. A list that cannot be loaded is unknown, never "not a member". |
present, absent | none | Tests 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.
| Field | Type | Values | Replayable |
|---|---|---|---|
verification.verdict | string | valid, likely_valid, risky, likely_invalid, invalid, unknown | Yes |
verification.bounce_probability | number | 0–1 | Yes |
verification.recommendation | string | send, hold, challenge, drop | Yes |
verification.actor_class | string | human_mailbox, agent_mailbox, relay_alias, role_account, shared, unknown | Yes |
verification.mailbox_type | string | corporate, 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 write | Yes |
verification.esp | string | microsoft365, google_workspace, consumer_esp, self_hosted, unknown | Yes |
verification.email_type | string | business, free, role, disposable, relay, unknown. free is the consumer-provider class (Gmail, Outlook, Yahoo, Apple, …), also called social email | Yes |
verification.signals.syntax | string | pass, fail | Yes |
verification.signals.mx | string | pass, fail | Yes |
verification.signals.disposable | boolean | Yes | |
verification.signals.role_account | boolean | Yes | |
verification.signals.catch_all | boolean | Yes | |
verification.signals.relay_alias | boolean | Yes | |
verification.calibration | string | prior, fitted | Yes |
verification.degraded | boolean | Absent means false, not unknown | Yes |
email.domain | string | Lowercased | Yes |
email.local_part | string | Lowercased | No |
email.tld | string | Last label only: uk, not co.uk | Yes |
enrichment.person.title | string | Free text from the vendor | No |
enrichment.person.seniority | string | c_suite, vp, director, manager, senior, entry, unknown | Yes |
enrichment.person.department | string | engineering, product, design, marketing, growth, sales, customer_success, finance, legal, hr, operations, executive, other, unknown | Yes |
enrichment.person.location.country | string | ISO country code | Yes |
enrichment.company.domain | string | Yes | |
enrichment.company.industry | string | The industry vocabulary below | Yes |
enrichment.company.employees | number | Yes | |
enrichment.company.revenue_range | string | As the vendor reports it | Yes |
enrichment.company.founded | number | Year | Yes |
enrichment.company.country | string | ISO 3166-1 alpha-2, uppercase | Yes |
enrichment.company.type | string | public, private, nonprofit, government, education, unknown | Yes |
enrichment.company.tech | string[] | Lowercased technology slugs | Yes |
enrichment.coverage.person | number | 0–1 | Yes |
enrichment.coverage.company | number | 0–1 | Yes |
enrichment.visitor.identified | boolean | From a push source | Yes |
enrichment.visitor.pages | number | From a push source | Yes |
icp.score | number | 0–100 | Yes |
icp.fit | string | strong, fit, weak, no_fit | Yes |
icp.coverage | number | 0–1 | Yes |
actor.tier | string | cryptographic, infrastructure, behavioural, none — always unknown today | Yes |
actor.authorized | boolean | Always unknown today | Yes |
actor.agent_identity.operator | string | Always unknown today | Yes |
attributes.<name> | string | Your own, sent in context.attributes | Yes |
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,
questionsis non-empty and the compiler asks rather than guesses. When it cannot produce a valid rule set at all, the answer is422 compile_failedwith the questions insideerror.questions. - The model sees your text, the rules of the
baseversion 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| Field | Meaning |
|---|---|
evaluated | How many stored decisions the candidate was run against. |
decisions | Count 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_active | Only the transitions that occurred, keyed like allow_to_deny. |
sample | Up 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_1k | Extra enrichment spend per 1,000 decisions versus the active version. |
warnings | Including 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
| What | Limit |
|---|---|
| Rules per policy | 100 (a compiled policy is held to 50) |
| Predicate depth | 8 |
| Predicate nodes per rule | 64 |
| Values in an in / not_in set | 200 |
| Rule label | 200 characters |
| Tags per rule | 16, each up to 64 characters |
| A string value in a predicate | 253 characters |
| Stored source text | 10,000 characters |
| Text sent to compile | 8,000 characters |
| Request body, writes and replay | 64 KB |
| Request body, compile | 16 KB |
Errors
| Status | Code | When |
|---|---|---|
400 | invalid_request | Malformed body, bad slug or version, or compile text that is empty or too long. |
400 | policy_invalid | The 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. |
404 | not_found | No such slug or version. |
409 | policy_conflict | Creating a slug that exists, or activating a version older than the active one. |
422 | compile_failed | The compiler could not produce a valid rule set, or is not configured on this deployment. error.questions carries what it needs answered. |