Skip to main content

obstat

PyPI Python CI License

An auditable decision record for agent tool calls. The clearance is written down before the call runs — not reconstructed from logs afterwards.

Nihil obstat: nothing stands in the way. It was the formal clearance a censor granted in writing, before publication. That is the whole idea here. An agent asks to do something, a rule decides, and the decision goes to disk before the tool body executes. If the process dies mid-call, the record still says what was authorised and why.

pip install obstat

No dependencies. Not AWS, not an identity provider, not a policy service — the decorator, tomllib, sqlite3 and a file.

docs/obstat-spec.md is normative, and its §8 lists what is still weak.

60 seconds

obstat.toml:

[[rule]]
tool = "read_*"
effect = "allow"

[[rule]]
tool = "delete_*"
effect = "approve"

Your tool:

from obstat import guard


@guard(resource="doc:{doc_id}")
def delete_document(doc_id: str) -> str: ...  # obstat has already decided this may happen

First call returns instead of running:

>>> delete_document("q3-report")
{'obstat': 'approval_required',
 'approval_id': '4f1c2a9b8e07',
 'expires_in_seconds': 900,
 'retry': "A human must approve this call. Once approved, call the same tool
           again with identical arguments plus obstat_approval_id='4f1c2a9b8e07'."}

A human decides:

$ obstat pending
4f1c2a9b8e07  delete_document  anonymous  doc:q3-report  871s left
$ obstat approve 4f1c2a9b8e07
4f1c2a9b8e07 approved by ana

The agent retries with the id, the call runs, and .obstat/decisions.jsonl holds the whole story.

What it is not

There are several good libraries that gate MCP tool calls. This one is built around a narrower claim: the record is the product. Four things follow from that, and they are the reason to pick this over a permission wrapper.

The decision is durable before the body runs. Not flushed after, not written in a finally, not batched. record.decision() returns only after fsync. A log written after the fact is a story about what happened; a record written before it is evidence of what was authorised. There is a test that runs inside a tool body, reads the log off disk, and fails if its own decision record is not already there.

Authorisation is per resource, not per tool. A tier — READ, WRITE, DESTRUCTIVE — cannot say "may edit their own ticket, not yours". obstat resolves the resource from the call arguments and matches rules against it:

@guard(resource="jira_issue:{issue_key}")
[[rule]]
subject = "human:ana"
resource = "jira_issue:ACME-*"
effect = "allow"

An approval is bound to one call. It carries the tool, the subject, the resource and a digest of the arguments, and it is single-use. Approving "delete q3-report" cannot be spent on deleting something else, and cannot be spent twice. This is enforced in one BEGIN IMMEDIATE transaction, so two concurrent retries cannot both win.

The record says when it has been edited. Every record carries the hash of the one before it, and obstat verify recomputes the chain:

$ obstat verify
line 3: record cd53f9db… follows a record that is no longer in the log

An edited line and a deleted line both show up. A truncated tail does not, and anyone who can write the file can recompute the whole chain — this is tamper-evidence, not non-repudiation, and §8 says so in those words.

Identity is optional

Most MCP servers today have no token at all: stdio, one local user, or a gateway that already terminated auth. Demanding an identity provider before you can try a governance library is why governance libraries go untried. An anonymous call is a legitimate call here — it is recorded as anonymous, and the policy decides what anonymous may do.

When you do have identity, hand it over:

from obstat import Subject, set_subject_resolver

set_subject_resolver(lambda: Subject(id=current_user(), kind="human", verified=True))

verified=False is the honest flag for identity that came from somewhere a caller could influence — a header, an argument. It is recorded, so a reader can tell the difference between "Ana did this" and "something claimed to be Ana did this".

Policy

First matching rule wins. Nothing matching is a deny — an absent rule is not permission, and a missing policy file is an error rather than an implicit allow.

key matches default
tool the function name, or tool= on the decorator *
subject human:ana, agent:planner, service:etl, anonymous *
resource whatever the resource template produced *
effect allow, deny, approve required

Patterns are globs. The file is re-read when it changes, so editing policy does not need a restart.

The order

1  reject a caller-supplied subject
2  stop file
3  resolve the resource from the arguments
4  policy
5  approval, if policy asked for one
6  write the decision record — durable      <-- before, not after
7  run the body
8  write the outcome — best effort

Step 1 exists because subject is stripped from the tool's advertised signature. A client that sends one anyway is trying to name itself, and that is a denial before anything reads the value.

Step 8 is deliberately not durable. If the process dies between 7 and 8 the record reads "authorised, outcome unknown", which is the honest state; paying for a second fsync to say something merely informative is the wrong trade.

Operator commands

obstat pending              # approvals waiting on a human
obstat approve <id> [--by]  # decide
obstat deny <id> [--by]
obstat log -n 50            # the decision record
obstat verify               # recompute the chain; exit 1 if anything was edited
obstat stop                 # deny every guarded call
obstat resume

obstat stop is checked before policy, so stopping never depends on the policy file still being parseable.

What is not here yet

Arguments are fingerprinted (sha256:…), never stored — tool arguments carry credentials and personal data, and a governance log that leaks them is a liability rather than a control. A per-key allowlist for recording chosen values is the obvious next step.

Also absent, deliberately: retention and rotation of the log, Slack and webhook approval channels, a policy for where a result may be sent, and anything that talks to a cloud. Those belong at the edges, and the edges should be adapters rather than dependencies.

Configuration

variable default
OBSTAT_POLICY obstat.toml
OBSTAT_LOG .obstat/decisions.jsonl
OBSTAT_DB .obstat/approvals.db
OBSTAT_HALT .obstat/halt

Read at call time, never at import. A library that raises on import is a library you cannot try.

License

Apache-2.0. Copyright 2026 Marcin Marzęta.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

obstat-0.2.0.tar.gz (83.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

obstat-0.2.0-py3-none-any.whl (24.8 kB view details)

Uploaded Python 3

File details

Details for the file obstat-0.2.0.tar.gz.

File metadata

  • Download URL: obstat-0.2.0.tar.gz
  • Upload date:
  • Size: 83.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for obstat-0.2.0.tar.gz
Algorithm Hash digest
SHA256 7348a57c6f8a75a0009e9e30c45f3a0c26e03cca189e2da2b42b3c29f27b2cf9
MD5 c75af12e9081583799d524b5152f2b03
BLAKE2b-256 8c7dfca32e21e83a28798b945a17d0fb6396000d12aca82a8da5099122a5be4a

See more details on using hashes here.

Provenance

The following attestation bundles were made for obstat-0.2.0.tar.gz:

Publisher: publish.yml on marcinmarzeta/obstat

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file obstat-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: obstat-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 24.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for obstat-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 30218a5de898d01b64f54317f070ea34ea0750a13eb60750877100f183722282
MD5 747e125c78d27bc5d09139781c689725
BLAKE2b-256 2112dfa10dc6463df6b2c9f492f7492962a6ebdbaaf184c184972dec6a8e1910

See more details on using hashes here.

Provenance

The following attestation bundles were made for obstat-0.2.0-py3-none-any.whl:

Publisher: publish.yml on marcinmarzeta/obstat

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page