---
title: "Account Blocking"
description: "The accounts handle exposed by the engine carries an admin API for blocking and unblocking accounts and account groups from outside any policy callback."
---

<!-- markdownlint-disable MD013 MD033 -->
# Account Blocking

The `accounts` handle exposed by the engine carries an admin API for
blocking and unblocking accounts and account groups from outside any
policy callback. This is distinct from the in-policy kill-switch
facility (see [Policies - Account Blocking by Engine](Policies.md#account-blocking-by-engine)),
which triggers a block automatically when a policy returns a
`scope = account` reject or when `apply execution report` emits an
`AccountBlock` value.

The same engine-owned path covers a recovered panic from a Go policy's
`ApplyExecutionReport` callback. The binding converts it to a
`SystemUnavailable` account block before the engine records it; see
[When the engine records a block](Policies.md#when-the-engine-records-a-block).

A third engine-owned trigger is a failed mutation finalizer. That one is
documented in full below, in
[Mutation Finalizer Contract](#mutation-finalizer-contract), because its reach
is not always one account.

The admin API is reached through `engine.accounts()`. The same handle
also owns the [account group membership registry](Account-Groups.md).

## What Gets Blocked

An `accounts` block gates **pre-trade** order evaluation only. The
engine short-circuits the entire policy chain for every incoming order
on the blocked account - no policy runs. A block set by admin `block` or
`block group` rejects with `AccountBlocked` immediately; a [restored persisted
cause](#restore-a-persisted-cause) instead rejects with its recorded `policy`,
`code`, `reason`, and `details`. Account adjustments (non-trade operations on
balances and positions) continue to work regardless of block state; the block
is strictly a pre-trade signal.

Drop copy is the intentional exception. It applies an order that already
happened, so existing account and account-group blocks do not gate it. Policies
still run and may request another account block after recording the historical
effects. The accepted drop-copy operation distinguishes the first block
requested by that call from the effective blocked state when apply returned.
That state is a snapshot taken before the operation was returned, and the block
itself is outside the finalization boundary: rolling the operation back does not
undo it.

The exception applies only after the engine has read the order account. That
account is its routing and account-control key, so an unreadable `account_id`
causes a fatal `MissingRequiredField` reject before any policy runs or state is
changed. This does not create a global account block.

## By-Account Blocking

`block(account, reason)` adds the account to the engine's blocked set.
`unblock(account)` removes it - clearing the block regardless of its origin:
it lifts both an admin block set through this API and a kill-switch block
recorded by the engine from a policy (for example a PnL limit). If the
account is already blocked, calling `block` again is a no-op - the first
reason is kept. Use `replace block reason` to change the reason of an
existing block.

[Account retirement](Account-Retirement.md) also clears the account's own block
of any origin. Group-level and engine-wide blocks remain.

### Restore a Persisted Cause

After a restart, `block with cause(account, cause)` restores a persisted account
block with its original `policy`, `code`, `reason`, `details`, and `user data`.
Later pre-trade requests reject with that cause before any policy runs. Its
transaction provenance is not restored, so rollback cannot remove it; only
`unblock(account)` can.

Persist the cause that later rejects on the account report, not the triggering
pre-trade reject: a block latched from a pre-trade reject with `scope = account`
keeps that reject's `policy`, `reason`, `details`, and `user data` but carries
`code = AccountBlocked`; a post-trade or account-adjustment block is stored as
returned.

The first cause in the account's own slot wins. If that slot already has a
cause, restore succeeds without changing it. A group or engine-wide block does
not occupy the slot: restore adds the account cause, which is reported before
the group and engine-wide causes. Restore is per account only. Restore a group
with `block group(group, reason)`, whose cause always has the admin shape; an
engine-wide block cannot be restored because no admin call raises one.

Restore on a newly built engine before any pre-trade, account adjustment,
execution report, drop copy, policy reconfiguration, or account-group change
can record a block for that account, and wait for it to return before starting
them. If an in-flight operation already holds a provisional cause, restore is a
successful no-op; that operation's rollback can then remove its cause and leave
the account unblocked.

The cause needs a recognized reject code and valid UTF-8 strings. C returns
`false` and writes `OpenPitParamError`; C++ sync throws `openpit::Error`; C++
async fails its future with `asyncengine::ErrorCode::TaskFailed`; Go returns an
error; Python raises `ValueError` or `OverflowError` when the cause is
constructed with an unrecognized code or user data outside `0..2**64-1`, and
`OverflowError` from `block_with_cause` itself only where the platform word is
narrower than 64 bits; JavaScript throws `ParamError` or
`RangeError` when the cause is constructed with an unrecognized code or
out-of-range user data, and `AccountIdError` from `blockWithCause` itself for
an invalid account identifier.

| Language | Restore a persisted account-block cause |
| --- | --- |
| Go | `accounts.BlockWithCause(account, cause)`; on the async engine `asyncAccounts.BlockWithCause(ctx, account, cause)` |
| Python | `accounts.block_with_cause(account, cause)` |
| JavaScript | `accounts.blockWithCause(account, cause)` |
| C++ | `accounts.BlockWithCause(account, cause)`; on the typed async engine `asyncAccounts.BlockWithCause(account, cause)` |
| Rust | `accounts.block_with_cause(account, cause)` |
| C | `openpit_engine_block_account_with_cause(engine, account_id, cause, out_error)` |

## By-Group Blocking (Live Predicate)

`block group(group, reason)` installs a group-level block. The engine
evaluates group membership live on every pre-trade request: any account
that is currently a member of a blocked group is rejected, including
accounts that join the group after the block was set. Conversely, an
account that leaves a blocked group is no longer group-blocked.

`unblock group(group)` removes the group-level block.

The default account group cannot be passed to `block group`,
`unblock group`, or `replace group block reason` - these calls return
an error for the reserved default.

## Engine-Wide Blocking

An engine-wide block stops every pre-trade request on every account, whatever
its account group. No admin call raises it - `block` and `block group` are
always scoped. The engine raises it itself, in two cases:

- an execution report signals a kill switch but exposes no readable
  `account id`, so exposure that already exists cannot be attributed to one
  account;
- a mutation finalizer registered by a custom policy fails - see
  [Mutation Finalizer Contract](#mutation-finalizer-contract).

The engine-wide block carries the cause the engine recorded for it, and it is
clearable without rebuilding the engine. `unblock all` lifts it and lifts
nothing else: accounts and account groups blocked individually stay blocked, and
their own recorded cause still wins over the engine-wide one, so clear those
with `unblock` and `unblock group`. Calling `unblock all` while no engine-wide
block is active is a no-op.

| Language | Clear the engine-wide block |
| --- | --- |
| Go | `accounts.UnblockAll()`; on the async engine `asyncAccounts.UnblockAll(ctx)` |
| Python | `accounts.unblock_all()` |
| JavaScript | `accounts.unblockAll()` |
| C++ | `accounts.UnblockAll()` |
| Rust | `accounts.unblock_all()` |
| C | `openpit_engine_unblock_all_accounts(engine)` |

An engine-wide block is the engine reporting that its own bookkeeping may no
longer be trustworthy. Clear it once the inconsistency behind it has been
investigated, not as a way to get order flow moving again.

## Mutation Finalizer Contract

A mutation finalizer - the commit or the rollback callback a policy registers
with a mutation - has **no right to fail**. By the time it runs, the decision is
already made and the state it finalizes was applied eagerly, so there is nothing
left to compensate and no caller left to answer. `commit` and `rollback` are
void on every surface, and they stay void when a finalizer reports failure.

A reported failure is never ignored either. The engine's own bookkeeping is then
in an unknown state, so the engine arms a kill switch under its own cause:

| Field | Value |
| --- | --- |
| `policy` | `Engine` |
| `code` | `SystemUnavailable` |
| `reason` | `mutation finalizer failed` |
| `details` | `a mutation commit or rollback callback failed; engine state may be inconsistent` |

The reach of that kill switch follows the failed mutation's provenance:

- a mutation registered by an engine-owned built-in policy has a bounded reach,
  so the account the pipeline ran for is blocked;
- a mutation registered by a custom policy has an unbounded reach - such a
  policy may write engine-wide barriers such as a broker-level rate limit or a
  P&L bound - so **every** account is blocked.

Every mutation registered through a binding is a custom-policy mutation, so the
engine-wide reach is what Go, Python, JavaScript, C++, and C policies get.

The block is engine-owned and carries no account or account-group identifier.
Nothing reports it to the caller that was finalizing; that caller learns the
ordinary way, on a later pre-trade request. After the engine-wide block is
armed, an ordinary pre-trade request is rejected; the resulting reject carries
the recorded cause of the first applicable block - account, then account-group,
then engine-wide - so it carries the engine's recorded `SystemUnavailable`
mutation-finalizer cause only when neither narrower block applies. A narrower
block may itself carry `SystemUnavailable`, so the reject code alone does not
identify the block. Clear the engine-wide block with [the engine-wide
unblock](#engine-wide-blocking).

The contract holds on every pipeline - pre-trade reservation finalization,
drop-copy operation finalization, compensation of a fatal drop-copy evaluation
exit, the account-adjustment batch, and
[account retirement](Account-Retirement.md), where commit applies the removal for
the first time and a commit-finalizer failure is also returned as
`finalizer failed` - and on implicit finalization, where a
handle released without `commit` or `rollback` rolls back and the block is the
only channel left.

### What This Means for a Policy Author

A custom policy that cannot guarantee its finalizer will succeed is choosing an
engine-wide outage as its failure mode. Everything that can fail belongs
*before* the mutation is registered: apply the tentative state first, then leave
the pair with nothing but bookkeeping that cannot fail - no allocation that may
throw, no I/O, no lock that may be poisoned, no call back into the engine. A
finalizer that only adjusts a counter or restores a value captured at
registration time has no failure mode to report.

### Language-Level Surfacing

Arming the kill switch does not replace a binding's own callback-error channel.
Both effects happen for the same failure:

- Go recovers a panicking callback at the SDK boundary and reports it to the
  core as exactly such a failure. `Commit`, `Rollback`, and `Close` stay void, so
  the kill switch is the whole of what the caller can observe.
- Python re-raises the original exception, with its original type and message,
  from `commit()` or `rollback()` once every remaining callback of the batch has
  run.
- JavaScript throws `PolicyCallbackError` with the original value as `cause`.
- C++ rethrows the original exception from `Commit()` or `Rollback()`, likewise
  after the batch finishes. A destructor cannot rethrow, so on implicit rollback
  the kill switch is the only channel.
- Rust adds no containment around policy or mutation callbacks, so a native
  Rust finalizer must not panic.

On the one path that has a reject channel of its own - compensation of a fatal
drop-copy evaluation exit - the `SystemUnavailable` reject is **appended** after
the fatal policy rejects, so `rejects[0]` still answers why the operation
failed rather than how the cleanup failed.

## Reason

The reason is an operator-supplied free-text cause string. It may be empty;
the SDK does not enforce non-emptiness. The reason is replayed in the
`AccountBlocked` reject that the engine returns to the caller on every
blocked pre-trade attempt. A restored cause instead replays its own `policy`,
`code`, `reason`, and `details`, rather than the `AccountBlocked` admin shape.
Enforcing a non-empty reason for operator accountability is the caller's
responsibility.

## Examples

The examples build an engine, block account 99224416 with a reason, unblock it,
and show a one-line group block/unblock.

<details>
<summary>Go</summary>

<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/go/examples_wiki_test.go -->
```go
engine, err := openpit.NewEngineBuilder().
 FullSync().
 Builtin(policies.BuildOrderValidation()).
 Build()
if err != nil {
 log.Fatal(err)
}
defer engine.Stop()

accounts := engine.Accounts()

// Block account 99224416 - all subsequent pre-trade orders are rejected.
accounts.Block(param.NewAccountIDFromUint64(99224416), "compliance hold")

// Unblock account 99224416 - pre-trade orders are allowed again.
accounts.Unblock(param.NewAccountIDFromUint64(99224416))

// Block every current and future member of a group in one call.
desk, err := param.NewAccountGroupIDFromUint32(7)
if err != nil {
 log.Fatal(err)
}
if err := accounts.BlockGroup(desk, "desk suspended"); err != nil {
 log.Fatal(err)
}
if err := accounts.UnblockGroup(desk); err != nil {
 log.Fatal(err)
}
```

</details>

<details>
<summary>Python</summary>

<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/python/tests/integration/test_examples_wiki.py -->
```python
engine = (
    openpit.Engine.builder()
    .no_sync()
    .builtin(openpit.pretrade.policies.build_order_validation())
    .build()
)

accounts = engine.accounts()

# Block account 99224416 - all subsequent pre-trade orders are rejected.
accounts.block(openpit.param.AccountId.from_int(99224416), "compliance hold")

# Unblock account 99224416 - pre-trade orders are allowed again.
accounts.unblock(openpit.param.AccountId.from_int(99224416))

# Block every current and future member of a group in one call.
desk = openpit.param.AccountGroupId.from_int(7)
accounts.block_group(desk, "desk suspended")
accounts.unblock_group(desk)
```

</details>

<details>
<summary>JavaScript</summary>

<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/js/tests/examples_wiki.test.ts -->
```ts
import { Engine } from "@openpit/engine";
import { buildOrderValidation } from "@openpit/engine/pretrade/policies";

const engine = Engine.builder()
  .builtin(buildOrderValidation())
  .build();

const accounts = engine.accounts();

// Block account 99224416 - all subsequent pre-trade orders are rejected.
accounts.block(99224416, "compliance hold");

// Unblock account 99224416 - pre-trade orders are allowed again.
accounts.unblock(99224416);

// Block every current and future member of a group in one call.
const desk = 7;
accounts.blockGroup(desk, "desk suspended");
accounts.unblockGroup(desk);
```

</details>

<details>
<summary>C++</summary>

<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/bindings/cpp/test/wiki/account_blocking_test.cpp -->
```cpp
#include "openpit/accounts/accounts.hpp"
#include "openpit/engine.hpp"
#include "openpit/param/account_id.hpp"
#include "openpit/pretrade/policies.hpp"

openpit::EngineBuilder builder(openpit::SyncPolicy::Full);
builder.Add(openpit::pretrade::policies::OrderValidationPolicy{});
openpit::Engine engine = builder.Build();

openpit::accounts::Accounts accounts = engine.Accounts();

// Block account 99224416 - all subsequent pre-trade orders are rejected.
accounts.Block(openpit::param::AccountId::FromUint64(99224416),
               "compliance hold");

// Unblock account 99224416 - pre-trade orders are allowed again.
accounts.Unblock(openpit::param::AccountId::FromUint64(99224416));

// Block every current and future member of a group in one call.
openpit::param::AccountGroupId desk =
    openpit::param::AccountGroupId::FromUint32(7);
if (auto err = accounts.BlockGroup(desk, "desk suspended")) {
  // handle err->message
}
if (auto err = accounts.UnblockGroup(desk)) {
  // handle err->message
}
```

</details>

<details>
<summary>Rust</summary>

<!-- Test mirror: https://github.com/openpitkit/pit/blob/main/crates/openpit/tests/examples_wiki.rs -->
```rust
use openpit::param::{AccountGroupId, AccountId};
use openpit::pretrade::policies::OrderValidationPolicy;
use openpit::{Engine, OrderOperation};

let engine: openpit::LocalEngine<OrderOperation> = Engine::builder()
    .no_sync()
    .pre_trade(OrderValidationPolicy::new())
    .build()?;

let accounts = engine.accounts();

// Block account 99224416 - all subsequent pre-trade orders are rejected.
accounts.block(AccountId::from_u64(99224416), "compliance hold".to_string());

// Unblock account 99224416 - pre-trade orders are allowed again.
accounts.unblock(AccountId::from_u64(99224416));

// Block every current and future member of a group in one call.
let desk = AccountGroupId::from_u32(7)?;
accounts.block_group(desk, "desk suspended".to_string())?;
accounts.unblock_group(desk)?;
```

</details>

## Related Pages

- [Policies - Account Blocking by Engine](Policies.md#account-blocking-by-engine):
  the in-policy kill-switch that blocks accounts automatically from
  policy callbacks
- [Account Groups](Account-Groups.md): account group membership registry
  and the `account group id` type
- [Pre-trade Pipeline](Pre-trade-Pipeline.md): request and reservation
  semantics, including how blocked accounts surface in results
- [Policy API](Policy-API.md): custom policy hooks and the mutation pair a
  policy registers
- [Reject Codes](Reject-Codes.md): standard business reject codes,
  including `AccountBlocked` and `SystemUnavailable`
