Metadata-Version: 2.5
Name: novarch
Version: 0.4.0
Summary: Runtime enforcement layer for AI agents in production — client SDK.
Project-URL: Homepage, https://novarch.ai
Project-URL: Documentation, https://docs.novarch.ai
Author-email: Novarch <support@novarch.ai>
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: agent,ai-safety,governance,kill-switch,llm,session
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.11
Requires-Dist: httpx>=0.26.0
Requires-Dist: pydantic<3.0,>=2.9.0
Provides-Extra: adk
Requires-Dist: claude-agent-sdk<1,>=0.2; extra == 'adk'
Provides-Extra: langchain
Requires-Dist: langchain-anthropic<2,>=1; extra == 'langchain'
Requires-Dist: langchain-core<2,>=1; extra == 'langchain'
Requires-Dist: langchain<2,>=1; extra == 'langchain'
Provides-Extra: langgraph
Requires-Dist: langgraph-prebuilt<2,>=1.0.2; extra == 'langgraph'
Requires-Dist: langgraph<2,>=1.0; extra == 'langgraph'
Description-Content-Type: text/markdown

# Novarch

**The runtime enforcement layer for AI agents.** Novarch sits in the path of
an agent's actions and, *before an irreversible write executes*, checks it
against rules your team wrote in plain English — **blocking** clear violations
and **pausing** borderline ones for a human to decide.

Per-action guardrails only see one call at a time. Novarch judges the agent's
**whole trajectory** — every lookup it ran, the reasoning it gave, and the
write it's about to commit — so it catches things a single-call check can't: a
payment to a vendor whose bank details changed three days ago, a refund that's
the customer's fourth this week, an off-policy promise with no exception
ticket. When the gate fires, you get an auditable record citing the exact rule
and the signals behind the decision.

This package is the **client SDK** — a thin, two-dependency library
(`httpx` + `pydantic`) that talks to a Novarch deployment. The judge, the
rules engine, and the operator triage dashboard run on the server side, hosted
by us or in your VPC. To get an endpoint and an SDK key, reach
<support@novarch.ai> or visit <https://novarch.ai>.

## How it works

Three moving parts, one mental model:

1. **Read tools run freely** and are *buffered* as evidence — the lookups and
   checks your agent does to inform a decision.
2. **A write tool is the gated moment** — the instant the agent crosses its
   trust boundary. Novarch bundles the buffered reads + the agent's stated
   reasoning into an `Action` and submits it to the judge.
3. **The judge applies your plain-English rules** and returns one of three
   verdicts: `pass` (write runs), `hold` (pauses for a human operator), or
   `kill` (blocked — the write never runs). Any uncertainty **fails closed**.

Your agent code catches one exception, `NovarchKillError`, for every way a
write can be stopped. A run that parks across a human decision rather than
blocking through it catches two more: see [A paused write can outlive the
process that made it](#a-paused-write-can-outlive-the-process-that-made-it).

## Install

```bash
pip install novarch
```

Optional framework adapters ship as extras (see [Framework
adapters](#framework-adapters)):

```bash
pip install "novarch[langgraph]"    # LangGraph — one native seam, no per-tool wiring
pip install "novarch[langchain]"    # LangChain @tool composition
pip install "novarch[adk]"          # Claude Agent SDK (async tools)
```

## Quickstart

Point the SDK at your deployment, then wrap your tools and your agent's entry
point. This example uses `GenericContext` — the minimal, vertical-agnostic
evidence shape (an id, a summary, an amount, plus any extra fields you want the
judge to see).

```bash
export NOVARCH_SERVER_URL=https://<your-deployment>
export NOVARCH_SDK_KEY=<your-sdk-key>
```

```python
from novarch import augur, NovarchKillError
from novarch.verticals.generic import GenericContext

# Reads run normally — the SDK buffers them as evidence for the judge.
@augur.tool(kind="read")
def lookup_customer(customer_id: str) -> dict:
    return crm.get(customer_id)

# The write is the gated moment. `context_builder` maps THIS call's arguments
# into the evidence your rules will be judged against.
@augur.tool(
    kind="write",
    action_type="issue_refund",
    context_builder=lambda customer_id, amount: GenericContext(
        record_id=customer_id,
        summary=f"Refund ${amount:.2f} to {customer_id}",
        amount=amount,
    ),
)
def issue_refund(customer_id: str, amount: float) -> str:
    return payments.refund(customer_id, amount)

# One @augur.session wraps the agent's entry point. Everything inside it —
# reads, reasoning, the write — is one governed trajectory.
@augur.session(tenant_id="acme", team_id="support", agent_id="refund-bot")
def handle_ticket(customer_id: str, amount: float) -> str:
    lookup_customer(customer_id)                      # buffered as evidence
    augur.set_reasoning("Customer reported a double charge; verified in CRM.")
    return issue_refund(customer_id, amount)          # ← judged right here

try:
    handle_ticket("cust-4821", 240.00)
except NovarchKillError as e:
    # Blocked by a rule, denied by an operator, or failed closed.
    log.warning("refund stopped at the gate: %s", e)
```

That's the whole surface: `@augur.tool(kind="read")`, `@augur.tool(kind="write", ...)`,
`@augur.session(...)`, and `augur.set_reasoning(...)`. Sync and `async def` tools
both work — the decorators dispatch on the wrapped function. In the decorator
form, `context_builder` receives the **write tool's own arguments** (here,
`customer_id` and `amount`).

## The rules see your evidence

Your rules live on the server and are written in plain English — that's the
product. Whatever you put in the context is what they get to reason over.
Suppose a rule reads:

> *"Block any refund over $500 for a customer with an open dispute."*

`GenericContext` accepts arbitrary extra fields, so add the dispute count and
the judge can key off it — no schema change:

```python
context_builder=lambda customer_id, amount: GenericContext(
    record_id=customer_id,
    summary=f"Refund ${amount:.2f} to {customer_id}",
    amount=amount,
    open_dispute_count=disputes.count_open(customer_id),  # extra → visible to the rule
)
```

The judge sees this context **plus** every buffered read and your
`set_reasoning(...)` text — the whole trajectory, not just the write's
arguments.

## Already using LangGraph?

Don't decorate every tool. Register **one hook** at LangGraph's native
tool-execution seam and it gates the whole `ToolNode`:

```python
from novarch import augur
from novarch.integrations.langgraph import NovarchToolGate, WriteSpec
from novarch.verticals.generic import GenericContext
from langgraph.prebuilt import ToolNode

@augur.session(tenant_id="acme", team_id="support", agent_id="refund-bot")
def run(question: str):
    gate = NovarchToolGate(
        # Classify each tool once. Reads buffer; writes gate.
        tool_kinds={"lookup_customer": "read", "issue_refund": "write"},
        write_specs={
            "issue_refund": WriteSpec(
                action_type="issue_refund",
                context_builder=lambda args: GenericContext(
                    record_id=args["customer_id"],
                    amount=args["amount"],
                ),
            ),
        },
    )
    node = ToolNode(tools, wrap_tool_call=gate.wrap())
    # ...build and invoke your graph with this node...
```

One difference to note: the `WriteSpec` `context_builder` receives a **single
`args` dict** (the tool call's arguments), whereas the decorator form receives
the tool's arguments directly.

At the seam, a `pass` executes, a `kill` raises `NovarchKillError` to halt the
graph, an operator **deny** injects a blocked `ToolMessage` so the agent can
recover, and any fail-closed case raises. Async graphs use
`awrap_tool_call=gate.awrap()` and `await gate.aclose()` at teardown.

> **Keep the kill switch intact.** LangGraph's *default* error handling
> re-raises, so a `kill` correctly halts the graph. But if you set a custom
> `handle_tool_errors` on the `ToolNode`, it will turn a BLOCKED verdict into an
> ordinary error message and the agent continues past it. If you set it at all,
> use `kill_safe_handle_tool_errors()` from the same module — it re-raises
> Novarch's halt signal and applies your fallback to everything else.

## What happens at the gate

The table below is the decorator path. (Buyer-facing labels are what an
operator sees in the dashboard.)

| Verdict | Buyer-facing | Behavior |
|---|---|---|
| `pass` | — | The write executes. |
| `hold` | **PAUSED** | The call **blocks** while a human operator approves or denies in the triage dashboard. Approve → the write runs; deny or timeout → `NovarchKillError`. A session that declared it can park raises `NovarchPausedError` instead of blocking. |
| `kill` | **BLOCKED** | `NovarchKillError` immediately; the write never runs. |

Under the LangGraph seam the only difference is `hold` → **deny**: instead of
raising, it injects a blocked `ToolMessage` so the agent can recover and try
something else. `kill` and every fail-closed case still raise.

**Fail closed.** Any uncertainty — judge error, judge timeout, operator
timeout, a server restart past the grace window — raises `NovarchKillError`
with a `[fail_closed_*]` prefix in the message. Your agent never proceeds on a
write Novarch couldn't confidently clear.

Every gated write is a **blocking round-trip to the judge** (one LLM call), and
a `hold` blocks the caller until an operator decides or the wait elapses.
`NovarchKillError` carries `verdict`, `rule_id_cited`, `rationale`, `action_id`,
and `session_id` for your alerting and audit paths.

**How long a hold waits belongs to the deployment.** An admin sets the hold
window; your session declares what it can honour, and the server answers with
the smaller of the two. Declare it with `@augur.session(max_hold_s=...)`:
omit it to declare `poll_timeout_s` (5 minutes by default), pass a number for
your own ceiling, or pass `augur.ADOPT_DEPLOYMENT_WINDOW` to wait as long as
the deployment says a hold may wait. Adoption is an opt-in, because a process
that dies mid-wait turns a hold a person was asked to judge into a fail-closed
block.

## A paused write can outlive the process that made it

A nightly batch runs at 2am and the people who decide arrive at 8. Blocking for
six hours is not something a batch process can do, so the gate can pause without
waiting: your run records the item as deferred and exits, a person decides in
the morning, and a later run picks the answer up and executes the write once.

Parking takes two declarations. The deployment's hold window has to be
**indefinite** (an admin sets that in ADMIN → SETTINGS), and your session has to
say it can park:

```python
from novarch import augur, NovarchPausedError, NovarchAlreadyExecutedError

@augur.tool(
    kind="write",
    action_type="create_purchase_order",
    context_builder=lambda po: GenericContext(record_id=po["id"], amount=po["amount"]),
    action_id_builder=lambda po: po["id"],     # your own id for this work item
)
def stage_po(po: dict) -> str:
    return erp.stage(po["id"])

@augur.session(
    tenant_id="hallway", team_id="field-ops", agent_id="po-agent",
    session_id="nightly-2026-09-03",           # your own id for this run
    resumable=True,
    max_hold_s=augur.ADOPT_DEPLOYMENT_WINDOW,
)
def nightly() -> None:
    try:
        stage_po(po)
    except NovarchPausedError as e:
        log.info("deferred %s for a person", e.action_id)
    augur.suspend()                            # park the session for the next run
```

A paused action now raises `NovarchPausedError` instead of blocking. It is a
separate exception from `NovarchKillError` so your batch can tell *a person is
deciding* from *the gate refused*, and it carries `session_id`, `action_id`,
`rule_id_cited` and `rationale`.

The run that comes back re-enters the same session and asks for the standing
answer:

```python
@augur.session(
    tenant_id="hallway", team_id="field-ops", agent_id="po-agent",
    session_id="nightly-2026-09-03",
    attach=True, resume=True,
    resumable=True, max_hold_s=augur.ADOPT_DEPLOYMENT_WINDOW,
)
def resume_nightly() -> None:
    stage_po(po)          # approved overnight → the write runs, exactly once
```

`resume=True` requires `attach=True`, and every write in a resumed session must
supply its own `action_id` through `action_id_builder`. Both are refused before
any request goes out, with a `[fail_closed_config]` prefix.

A third run presenting the same work item raises
`NovarchAlreadyExecutedError`, carrying the time the write was acknowledged.

### What you have to keep across runs

Novarch stores no list of pending work and offers no endpoint to discover one.
The resumed run has to bring three things itself:

- the same **`session_id`**,
- the same **`action_id`** for each work item, and
- enough state to **rebuild the same action payload**.

A run that opens a new session presents a different key. It finds no standing
verdict, is judged fresh, and the protection against paying twice does not
reach it. Derive both ids from your own work item (`po-1234`,
`nightly-2026-09-03`) so a re-run produces the same ones.

### What the context builder puts in is what binds the approval

The standing verdict is bound to a fingerprint of `action_type` plus the
context your `context_builder` produced. A resume presenting different content
is refused. That makes the builder a safety surface: leave the destination
account out of the context and two writes that differ only there fingerprint
identically, so the resume path will honour an approval given for the other
one. Put every field that decides what the write *does* into the context.

The judge reads more than the context. Tool results and the agent's reasoning
reach it, and AP-2 is decided on exactly those. A resume carries fresh tool
results and fresh reasoning, and a matching fingerprint hands back the standing
verdict without looking at either. The window an admin sets is what bounds how
long a verdict stays honourable.

### A second write is a second commitment

An approval is consumed once. To write again, present a **new `action_id`**; it
is judged on its own merits and recorded as its own commitment. A resumed run
also has no access to the earlier run's return value, so re-fetch what you need
from your own system rather than expecting it back.

### When the gate cannot tell you whether the write landed

The SDK claims the right to execute, runs your write, then acknowledges it. If
the process dies in between, the gate **cannot know** whether the write landed,
and a later resume raises `NovarchKillError` with
`verdict="execution_unconfirmed"` rather than guessing. An operator settles it
in the portal: *confirm executed* closes it, *confirm not executed* clears the
claim so a later run can take it again. Your own idempotency is what covers a
deliberate retry.

Four refusals can come back on a resume. Three are terminal and want a new
`action_id`: the content no longer matches (`[resume_fingerprint_mismatch]`),
the action predates the fingerprint (`[resume_commitment_unversioned]`), or a
release changed what a commitment means and invalidated the approvals given
under the old meaning (`[resume_commitment_version_changed]`). The fourth,
`[resume_no_standing_verdict]`, means nothing has been decided yet — **retry
the resume**. It also happens when a first attempt is still in front of the
judge, and minting a new id there pays twice. On that one ground the session
parks on the way out instead of completing, so the retry can attach to it.

## Framework adapters

All optional; the core SDK is framework-agnostic.

| Extra | Import | Use |
|---|---|---|
| `novarch[langgraph]` | `from novarch.integrations.langgraph import NovarchToolGate, WriteSpec` | One native seam gates a whole `ToolNode` (shown above). |
| `novarch[langchain]` | `from novarch.integrations.langchain import augur_tool` | Wrap an `@augur.tool` function as a LangChain `BaseTool`. |
| `novarch[adk]` | `from novarch.integrations.claude_agent_sdk import augur_async_tool` | One decorator that stacks the Claude Agent SDK `@tool` over `@augur.tool` for async tools. |

## Typed vertical contexts

`GenericContext` covers any workflow. For domains with a rich, fixed signal
set, typed contexts give stricter validation and turn known fields into
first-class evidence:

- `from novarch.verticals.ap import APContext` — accounts payable / vendor payments
- `from novarch.verticals.cs import CSContext` — customer support / refunds

Reach for these when your rules key off vertical-specific signals (bank-change
recency, dispute counts, exception-ticket references). Otherwise `GenericContext`
plus a few extra keyword fields is enough — the judge sees any extras you pass.

## Docs & license

Full documentation: <https://docs.novarch.ai> · Product: <https://novarch.ai>

This client SDK is licensed **Apache-2.0**. The Novarch platform — judge, rules
engine, operator triage — is separately licensed and available as a managed
service or a private deployment.
