Almost every tool-governance layer I have looked at writes its log after the call
returns. Some write it in a finally. Some batch it. Some hand it to a logging
framework that flushes on its own schedule.
That ordering quietly decides what your log can be used for.
If the record is written after the body runs, then a record that is missing has
two possible explanations, and nothing in the file distinguishes them:
- The call was never authorised, so it never ran.
- The call was authorised, ran, did its work, and the process died before the log line reached disk.
Those are not close together. One is the control working. The other is an
unlogged deletion. When someone asks you six weeks later what your agent was
permitted to do at 03:14, "there is no line for it" answers nothing.
So I wrote a small library that inverts the order.
obstat
obstat is an auditable decision
record for agent tool calls. Nihil obstat — nothing stands in the way — was the
formal clearance a censor granted in writing, before publication. That is the
whole idea.
from obstat import guard
@guard(resource="doc:{doc_id}")
def delete_document(doc_id: str) -> str: ...
An agent asks to do something, a rule decides, and the decision goes to disk —
written and fsynced — before the tool body executes. If the process dies
mid-call, the record still says what was authorised, for whom, against which
resource, and why.
record.decision() returns only after the fsync returns. Not flushed after,
not deferred, not batched. Everything else in the library is convenience; this is
the part an examiner relies on.
The claim has a test, not a paragraph
An architectural promise nobody can falsify is marketing. This one is checked by
reading the log from inside the tool body — the one place where anything
buffered, deferred, or written afterwards is invisible:
def test_record_is_durable_before_the_body_runs(workspace):
workspace(ALLOW_ALL)
seen: dict[str, list] = {}
@guard()
def read_thing(what: str) -> str:
# Read the log off disk from inside the body. Anything buffered, deferred
# or written afterwards is invisible here, which is the point.
seen["records"] = record.read()
return f"read {what}"
assert read_thing("a-file") == "read a-file"
decisions = [r for r in seen["records"] if r["phase"] == "decision"]
assert len(decisions) == 1
assert decisions[0]["effect"] == "allow"
Move the write one line later and the test fails. That is the property, stated in
a form that breaks when it stops being true.
The outcome record — did it succeed, did it raise — is written afterwards and is
deliberately not durable. If the process dies between the two, the log reads
"authorised, outcome unknown", which is the honest state. Paying for a second
fsync to say something merely informative is the wrong trade.
What follows from "the record is the product"
Authorisation is per resource, not per tier. READ / WRITE / DESTRUCTIVE
cannot express "may edit their own ticket, not yours". obstat resolves a resource
id from the call arguments and matches rules against that:
[[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 —
enforced in one BEGIN IMMEDIATE transaction, so two concurrent retries cannot
both win. The record that spends it names who approved, because "who said yes"
should not live only in a mutable SQLite row.
Arguments are fingerprinted, not stored. Tool arguments carry credentials and
personal data; a governance log that leaks them is a liability rather than a
control. You name the ones a human needs to see, and only those values are
recorded — because an approver deciding about sha256:ae32e6… is deciding about
nothing. The digest still covers everything.
Every record carries the hash of the one before it, so an edited or deleted
line shows up in obstat verify.
What three real mailboxes found
Before writing this post I put obstat in front of my own mail: three IMAP/SMTP
MCP servers — a personal mailbox, a gmail, and a public business address that
takes mail from strangers — with every outbound message behind an approval. Use
found things review had not.
An agent walked around the gate on day one. Asked how many unread messages
the mailboxes held, it found no guarded tool that answered, opened a raw IMAP
connection with the credential the server process was holding, and answered
correctly — 2,360 unread across two mailboxes, in no record at all. Nothing
failed. The gate simply was not on the path it took.
That finding is now the first entry in §8, because it is the one a reader is
most likely to misread past:
-
The record covers the gate, not the resource. Absence is evidence only
over the calls that came through
@guard. Everything else reads as quiet, not as incomplete. - A credential the caller can read is a gate the caller can walk past. The separation has to come from the host — a different account, a sandbox, a session with no shell. The ordinary MCP deployment, where advertised tools are the entire surface, is what obstat is designed for; a coding agent with a shell beside it is not.
-
Coverage is the control. A question the tool surface cannot answer becomes
a hole in the record rather than a refusal.
count_unreadexists on that server now because it did not then.
The library had reserved the one word an email tool needs. obstat injected
the caller's identity into a parameter called subject — and an email tool
wants send_email(to, subject, body). The dangerous failure was not the crash;
it was the quiet variant, where the parameter vanished from the advertised
schema and an identity object landed in the Subject: header. It is
obstat_subject now, and obstat_ is the only prefix the library reserves.
The record said what was authorised, never what happened. A bulk delete
records one sender whether it removed one message or ten thousand, and the
outcome said ok: true either way. Tools can now write obstat.note(deleted=…, from inside the body onto the outcome record — on failure too, since
matched=…)
half a bulk delete is the case a reader most needs a number for.
A glob matches the whole string, and smtplib delivers to every address in the
header. A "mail to yourself is free" rule — resource mail:*@example.com —
also matched attacker@evil.example,me@example.com, and send_message would
have delivered to both. A resource id is caller-controlled text: parse it in the
resource callable, don't pattern-match it. Whatever that callable raises becomes
a recorded denial, not an unrecorded crash.
None of these came from review, and two of them are obstat admitting a limit
rather than fixing a bug. That is the trade I want to be explicit about: the
library can make the polite path leave evidence. It cannot make every path
polite.
What it does not do
A truncated tail does not show up. Anyone who can write the file can recompute
the whole chain. This is tamper-evidence, not non-repudiation, and the spec
says so in those words — §8 of docs/obstat-spec.md is a list of what is still
weak, kept deliberately as prominent as the feature list.
One entry there was found by CI rather than by me. The concurrency test — two
real processes appending to one log — went green on Linux and macOS and came back
from the Windows leg at 57 of 60 records. Windows' append mode is a seek and a
write, not one atomic operation, so concurrent writers lose records silently. The
cross-process guarantee is now documented as POSIX-only, the fix is named
(msvcrt.locking(), which is precisely the inter-process lock the design
declines to take), and the test skips on Windows while the CI leg stays. I would
rather ship a documented hole than an undocumented one.
That test was written after two releases in which nothing touched threads or
processes. The lesson generalises: when a normative claim has no test, that is
where the bugs are — not in the code that gets exercised daily.
The same shape caught something else four releases later, and it is the one I
find most instructive. The spec said a call is rejected if its arguments do not
fit the tool. The code bound them partially, so a call missing a required
argument passed the gate, took an allow record, and then died in the body with
a TypeError — the log asserting a call had been authorised when it could never
have run. That is precisely the kind of unearned claim this whole project exists
not to make, and it sat there for four versions.
It survived because the MCP SDK validates arguments against the advertised
schema before the call reaches the decorator. Through a server the bad call
never arrived, so the gap was invisible from the outside; I only saw it by
writing a test that called the guarded function directly. Two things follow. A
guarantee that holds only because something upstream happens to be careful is
not your guarantee. And a test that exercises your code the way your users do
will systematically miss the cases your users' tooling filters out first.
Trying it
pip install obstat
obstat init # a starter policy; everything denied until you uncomment a rule
No runtime dependencies. Not AWS, not an identity provider, not a policy service —
the decorator, tomllib, sqlite3, and a file. A governance library nobody can
try on a laptop is one nobody adopts.
Identity is optional, too. 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 evaluate a governance library is why governance libraries
go unevaluated. An anonymous call is a legitimate call here — it is recorded as
anonymous, and the policy decides what anonymous may do.
docs/obstat-spec.md is normative: behaviour changes update it in the same
commit, and where the spec and the code disagree, one of them is a bug.
Apache-2.0. I would particularly like to hear from anyone who has had to answer
the "what was your agent allowed to do, and when" question for real, because I
have built this against my own guess at that conversation.
Top comments (2)
Strong framing. The pre-body decision record gives you durable authorization evidence, but the mailbox example shows that completeness needs its own evidence too.
I would pair each run with a versioned coverage manifest that binds:
Then the verifier can distinguish “no guarded call occurred” from “this execution environment had an uninstrumented path to the resource.”
A useful negative test would launch the real server image, enumerate every credential/socket/file capability, and attempt the same operation outside the tool registry. Any successful bypass should fail the release, not merely appear as an absent log line.
The authorization/outcome split is honest. Adding an explicit coverage attestation would make the scope of that honesty machine-checkable.
I like the split between authorization and outcome. The missing piece for me is a cheap falsifier for coverage. Run the server image, list the credentials and sockets it can reach, then try the sensitive operation outside the advertised tool path. If that succeeds, an absent log line should fail the release rather than read as proof nothing happened.