Test & deploy
Before you ship a policy, simulate it against a real captured action to see the verdicts it produces, roll it out in observe-only mode to watch those verdicts in production, then deploy it.
The safe path to a live policy has three stages. Simulate the policy against captured actions to see the verdict each one gets. Roll it out in observe-only mode so the gate signs verdicts without blocking anything. Then deploy, so the SDKs pull the active policy and start enforcing it.
Simulate, observe, and enforce all run the same Rust core. A verdict you preview is byte-identical to the one the gate makes in production, because there is one implementation. Read Policy for the model and Policy files for the heso.toml format.
1. Simulate against a captured action
Simulating runs your working policy over a captured action and shows the verdict it would produce: the rule that matched, its plain-English sentence, the matched conditions, and the decision path (allow, block, redact, or require_approval) — plus any floor that raised the outcome. Run it before anything ships.
The dashboard simulator at /policy/simulate takes a captured action, runs the real first-match-wins engine and both pinned floors over it, and shows the resulting PolicyOutcome. There is no in-browser decision-against-action call; that simulation runs in the dashboard.
Client-side, the @hesohq/verify-wasmsurface lets you parse a policy, validate its floors, and read back each rule’s canonical sentence with no network — so you catch a broken policy and confirm the wording before you deploy:
import init, { parsePolicy, policyRulesFromToml, ruleToSentence } from "@hesohq/verify-wasm"
await init()
// Full parse + the load-time floor check. Throws on a bad policy.
try {
parsePolicy(toml)
} catch (err) {
// err.message starts with the engine reason:
// "[PARSE] …" — malformed TOML
// "[FLOOR_BYPASS] …" — a rule tries to allow a dangerous lane without
// approval; the message names the rule id + verb
console.error(err)
}
// Read back every rule's canonical sentence — the exact string a receipt
// stores in policy.rule_display.
const rules = JSON.parse(policyRulesFromToml(toml)) // PolicyRule[]
for (const rule of rules) {
console.log(ruleToSentence(JSON.stringify(rule)))
// "Allow a payment of $5,000 or less"
}A dangerous lane — a payment, a delete, an account change, a data export — carries a built-in floor. Even when your rule says allow, the floor can raise the verdict to require_approval, and a rule that tries to allow a floored lane without approval is rejected at load with [FLOOR_BYPASS]. The simulator shows you this before it surprises you in production. See Pinned floors.
Preview functions
parsePolicy()function
Run the full parse plus the load-time floor check over a policy. Returns void on success; throws on a policy that could never deploy.
parsePolicy(tomlSrc: string): voidParameters
- tomlSrcstringrequired
- The full heso.toml source.
Throws
[PARSE] if the TOML is malformed; [FLOOR_BYPASS] if a rule tries to allow a dangerous lane without approval (the message names the rule id and verb).
policyRulesFromToml()function
Parse a heso.toml policy into its structured rules with the real parser.
policyRulesFromToml(tomlSrc: string): stringParameters
- tomlSrcstringrequired
- The full heso.toml source.
Returnsstring
A JSON array of PolicyRule, in evaluation order.
Throws
[PARSE] if the TOML is malformed.
ruleToSentence()function
Render the canonical English sentence for one rule — the exact value that appears as rule_display on a receipt.
ruleToSentence(ruleJson: string): stringParameters
- ruleJsonstringrequired
- A single PolicyRule serialized as JSON.
Returnsstring
The canonical sentence, e.g. “Allow a payment of $5,000 or less”.
2. Roll out in observe-only mode
Simulation tells you how the policy decides a few actions. Observe-only mode lets you watch it decide real traffic without changing behavior. Set blocking=False on heso.init(): a refused action is still captured, signed, and audited — but it does not raise, so the body runs anyway.
import heso
# observe-only: capture + sign every action, but never refuse one.
heso.init(blocking=False)
@heso.tool
def search(query: str) -> str:
return web.search(query)
@heso.destructive # a delete that would normally need approval...
def delete_record(record_id: str) -> None:
db.delete(record_id) # ...still runs, but a refused-verdict receipt is signedNow every action produces a receipt carrying the verdict it would get under enforcement. Pull those receipts and look for actions that would have been blocked or sent to approval. When the verdicts look right, drop blocking=False (the default is blocking=True) and the same policy starts refusing.
In observe-only mode a payment, a delete, and an account change all still run. It is a rollout stage to watch verdicts, not a safety control. Flip to blocking before you trust the gate to stop anything. A blocked action then raises BlockedError before the body runs; require_approval raises SuspendedError and pauses for a human co-sign.
3. Deploy
Deploying publishes the policy to the control plane so every SDK loads the same bytes. The flow:
- Update the active policy.The dashboard policy editor saves through a Review & sign step; it calls the same parse, preview, and floor-check functions above, so it refuses a policy that would throw [PARSE] or [FLOOR_BYPASS]. Your own control-plane flow can POST /v1/policy instead. Either way the backend stores the exact bytes and hashes them into a policy_hash.
- SDKs pull it. Each SDK fetches the active policy with GET /v1/policy/pull. The org is resolved from your api-key — there is no team id, and an empty toml is the fail-safe deny pin.
In TypeScript, pullPolicy() from @hesohq/sdk wraps the pull and returns a PolicyPullResult with the deployed policyId, the policyHash, and the policy toml the engine loads:
import { configure, pullPolicy } from "@hesohq/sdk"
configure(process.env.HESO_API_KEY!, process.env.HESO_ENDPOINT!)
const bundle = await pullPolicy()
// PolicyPullResult { status, policyId, policyHash, toml }
// The org is resolved from your api-key — there is no team id.
console.log(bundle.status) // "policy" | "up_to_date"
console.log(bundle.policyId) // the deployed policy id
console.log(bundle.toml) // the policy TOML the engine loadsCall configure(apiKey, endpoint) once at startup before any cloud call. Auth and the environment variables are covered on Authentication; the full endpoint list is on the API reference.
The same policy_hash the backend stored is what the gate pins each decision to, so the policy that gated an action is provably the policy you deployed.
A simulation proves how the engine will decide a given action under a given policy: the rule, the sentence, the matched conditions, the decision path, and any floor that applies. It is a dry run, not a record that the action happened — that comes from a signed receipt, which runs the same engine and adds the signatures.