Releases: shamiksaharcciit-oss/onedoor
Release list
onedoor 0.7.0
onedoor 0.7.0 — 2026-09-05
Tag v0.7.0. Notes drawn from CHANGELOG.md's 0.7.0 section
(R011: release notes are a slice of the changelog, never a rewrite of it). This edition
supersedes draft B, seal c405e827fa4cf6ef1dbe1ca03df1980961403e4d146b8021f9bc81a9aafb1bda,
after the operator dogfooding pass that gated the release.
What this release is
The Policy Studio, the whole room. 0.6.2 shipped two fixes to a Studio that was one
screen and a canvas. 0.7.0 is eight screens that read the policy set, the ledger, the
live state and the receipts — and three ways to write a policy, two of them new.
Why the number is 0.7.0 and not 0.6.3
Because a version number describes content. This release carries the entire Ledger Room
arc — eight screens built over ND-055 — plus two new authoring paths. Calling that a
patch would understate it to the only audience version numbers exist for. The number
follows what is in the release; it was not chosen to fit a date.
This release removes nothing. No endpoint retired, no field dropped, no behaviour
changed for anything that was already working. Every addition below is additive, and the
engine, the wire protocol and the enforcer's schema are untouched.
Writing a policy, three ways — two of them new
Every one of them produces a draft. A draft changes nothing. The only path from a
draft to the rules an agent is actually governed by is the ratification ceremony, which is
a page a person reads and confirms.
In the editor
A guided form and the raw rule, two panes over one parsed object, so they cannot disagree
with each other. Validation now runs as you type: the text goes to the server, the
engine's own loader looks at it, and the answer comes back rendered. Nothing is parsed in
the browser — which is why the panes and the validator cannot drift apart, and why turning
scripting off leaves you exactly the editor 0.6.2 had.
From a file
Upload YAML on the Drafts page. The file is checked by the loader's own four stages, in
the loader's own order, and the draft shows what would be refused at boot, at which stage,
and where in the file. The uploaded bytes are frozen verbatim before anything parses them.
A file the loader would refuse still becomes a draft. You get the reasons, not your
file handed back. And a draft the loader would refuse cannot be ratified: its preview
says so, the ceremony page draws no button, and a direct call to the ratify route is
refused with reason candidate_invalid_at_load — the preview and the real ratification
share one application path, so they refuse the identical candidate for the identical
reason.
Before this release, the Studio could only ever reach the last of those four checks,
because the editor handed it rules that had already survived the first three. The point of
the track is a single sentence: nothing the loader would refuse at boot should first be
discovered at boot.
Over the API
POST /api/v1/drafts and the routes beside it: read, list, add or update one rule, ask
what the loader thinks, and submit for ratification. The schema is at
/api/v1/openapi.json.
The v1 API adds no approval route — ratification belongs to the human ceremony. One
legacy route (POST /draft/{id}/ratify), predating actor identity, still serves; it
records its approver as declared, never authenticated, and is retired with the key_id
work.
It now says so in its own response, and the legacy route is marked deprecated in the
schema itself.
submit records that a human has been asked. It moves no version pointer and writes no
receipt. A draft pinned to a version no longer in force cannot be submitted at all —
base_moved, naming both versions — until it is re-pinned and its numbers recomputed
against the version now in force.
Two lists, and why they are two
The Studio separates what the loader will refuse at boot from how a rule that loads
perfectly well will behave once a request arrives:
- The loader would refuse this — what fails at startup.
- Once in force, these rules will… — decision-time behaviour, each line naming the
reason code the engine will actually record.
They are never merged, and the distinction is not cosmetic. A euro cap with no cost_param
loads; the engine denies at decision time with cost_unknown. Showing that as a boot
refusal would tell you the engine refuses something it accepts — and an operator who learns
that stops believing the list that was right.
Neither list claims completeness. The refusal list carries the notice that the engine's
validator stops at the first failure in each rule; the behaviour list says only what it
knows how to predict — and when something above it is refused, its own preamble says so
rather than claiming the loader accepts what the list above just refused.
The rest of the room
Policies — every rule in the version in force, read from that version's snapshot, with
what each rule does in plain English beside the rule itself. A version whose snapshot
cannot be read says so, rather than rendering as an empty policy set.
History — the execution ledger, chain-numbered, with every filter that shaped the view
visible in the view. A field the ledger does not yet record reads not recorded; a row
outside the chain reads unchained — never a sentence about a version that is, in fact,
in force.
Live state — the kill switch and the budgets. The switch's state is shown and no
button is drawn, with the reason stated: this process may not write to the enforcer's
store, and a control that renders as operable and is not would be worse than none.
Re-evaluate under any version — take a decision the ledger recorded and replay it under
a different policy version. The engine decides; nothing is re-executed; both versions are
named in the same breath; and a version whose rules cannot be retrieved renders as not
retrievable rather than replaying as an empty policy set.
Verify — a page built for a stranger. It gives you two files — as downloads of the
exact stored bytes, so nothing is lost to copy-and-paste — and a command that reads them,
opens no database, and tells you verified, failed, or unreadable. Three outcomes,
because telling you your receipt is bad when what is bad is your download would be the
worst error the page could make. The receipt is parsed; the snapshot is only hashed — so a
receipt that will not read is unreadable, and a snapshot whose bytes hash elsewhere is
failed, whatever the corruption.
The ceremony — ratification is a page before it is an action. Reading it ratifies
nothing. It states what will be in force, what changes, and what this does not undo — and
it does not call the change irreversible, because that would be false: there is no
un-ratify, and the way back is forward.
Tested by a person before it was tagged
Every screen in this release was walked by an operator on a machine that had never run
it, following docs/DOGFOODING_SCRIPT.md — an ordered, timeboxed script whose every
command is checked by a test before anyone types it. The pass found defects; they are
recorded, with their fixes, in docs/from_core/ in the repository, and the release was
held until each fix was witnessed on the operator's screen. The user manual that ships
with this release (docs/OneDoor_User_Manual.pdf) teaches what that walk learned.
Installing
pip install --upgrade "onedoor[studio]"
python -m onedoor.studio --db onedoor.db --studio-db studio.dbThen open http://127.0.0.1:8787. The Studio binds loopback only and refuses anything
else before a socket exists.
--db must name the same file your decision service uses. The service defaults to
onedoor-service.db and the Studio's --db to onedoor.db, so accepting both defaults
points them at different stores.
Existing Studio stores are upgraded in place on first open. Existing policy databases are
untouched — this release adds no enforcer migration.
What has not changed, and what this does not claim
- No engine change, no wire change, no enforcer schema change. The decision path is
byte-for-byte the one0.6.2shipped. - Nothing is removed. No deprecation takes effect in this release; the one deprecated
route still serves and says so. - The Studio still edits no live rule. Everything it writes goes to its own store; the
enforcer's database contains no row it can edit, ratification excepted and sealed. - Decimal strings in numeric bounds remain a known limitation, unchanged and noted in
the editor at the fields it affects. The fix is specced and lands after this release. - The ledger records no caller identity. The Studio says so on the screen where that
matters rather than answering identity questions with provenance facts. Actor identity
is specced and follows.
Integrity: sha256(body) = 318d24cdf1a80d0a8db062011a44269398db4cfb6e0dbbdd0807980fad87dedb
onedoor 0.6.2
onedoor 0.6.2
Notes drawn verbatim from CHANGELOG.md's 0.6.2 section (R011:
release notes are a slice of the changelog, never a rewrite of it).
Fixed — ND-055 P0: the Studio's empty state, and the silent database trap
Both found by Shamik working through 0.6.1 by hand. Additive and documentation-shaped;
no engine change, no schema change, no wire change.
F-G — the empty state was a dead end. The Studio index with no drafts emitted 0
forms, 0 buttons, 0 inputs, 0 links; its whole body read "onedoor policy studio no
drafts". It now offers a create-draft form and the equivalent curl one-liner, on the
empty state and beside a populated list alike. A state with no next move is a wall, not a
state.
The form is plain HTML and needs no JavaScript: the route reads the
application/x-www-form-urlencoded body with the standard library, because
request.form() would require python-multipart even for urlencoded bodies and a
dependency for one text field is one the [studio] extra does not need. A browser
submission gets a 303 to the draft it created; the JSON API, which passes title as a
query parameter, still receives JSON — the caller's content type decides, so nothing
existing changed.
F-H — the silent database trap. The decision service defaults to
onedoor-service.db and the Studio's --db to onedoor.db, so accepting both defaults
points them at different stores: the Studio comes up, works, and shows an empty world.
The Studio now says so when the enforcer store it opened has never held a policy, naming
both defaults and the flag that is probably wrong, and the README's quickstart spells the
same filename in both commands. A wrong default that cannot be noticed is a defect twice.
Fixed — a browser form POST returned 422 before it ever reached the handler
Found while building F-G. from __future__ import annotations makes every annotation a
string, and FastAPI resolves route annotations against the module's globals — not the
closure create_app builds them in. Request, imported only inside that function, was
invisible at resolution time, so FastAPI read request: Request as an unresolvable
query parameter. The name now lives at module scope behind an import guard, and the
X-6 property is unchanged: importing the module still works without FastAPI, and
create_app still refuses with a remedy.
onedoor 0.6.1
onedoor 0.6.1
Notes drawn verbatim from CHANGELOG.md's 0.6.1 section (R011:
release notes are a slice of the changelog, never a rewrite of it).
A patch release from the first operator validation against shipped bytes. Shamik
installed 0.6.0 from PyPI on a clean machine and worked through the whole surface as a
first-time user; five items came back. Four are fixed here. The fifth changes a verdict, so
it is escalated rather than taken locally.
Nothing here is breaking. No wire-observable change, no migrations.
Fixed — the Studio server returned Internal Server Error on every page
GET / failed deterministically with
sqlite3.ProgrammingError: SQLite objects created in a thread can only be used in that same thread. The Studio's stores are opened once at startup, and every route is a sync def,
which FastAPI runs in a threadpool — a different thread per request.
Every library-level test passed while the served surface was broken, which is the
lesson worth keeping: a gate is a command and the world it runs in, and the route
function and the route under uvicorn's threadpool are different worlds. A served surface
is now tested through the server — tests/studio/test_server_served.py reaches the app
with TestClient the way a browser does, including eight sequential requests, because a
single request can pass by thread luck.
The fix is the pair onedoor.service has always used: both connections opened
check_same_thread=False and every route serialised on a lock. Those go together —
the flag alone would trade a loud error for a quiet race — so StudioState owns both and
neither is optional.
Fixed — onedoor.__version__ did not exist
A stranger's first sanity check failed. It now reads from the installed distribution, so it
cannot drift from pyproject.toml: a version derived is the only version that cannot rot.
Fixed — the Studio app self-described as version 0.4.x
A literal that was already wrong when 0.5.0 shipped and had no way of ever becoming
right — a name outrunning its artifact, in the one field whose job is to say which artifact
this is. Now derived from onedoor.__version__.
Added — a four-command quickstart in the README, for someone who has only PyPI
Install, copy the shipped pack, set keys, run — with the three outputs that tell a reader
it worked. The previous quickstart began pip install -e ".[dev]", which assumes a clone;
the repository workflow is now a subsection beneath it. Every command was run from a clean
venv against published bytes before being written down.
Known limitation, newly named — decimal strings in params
{"amount_eur": 120.00} works; {"amount_eur": "120.00"} is refused by a numeric bound
as must be numeric — while cost_eur accepts the string form and the cap path already
reads a decimal string as money. Measuring it found something sharper than the report:
adding a numeric bound changes which wire types an action accepts, because
caps.resolve_cost accepts str and bounds does not.
The failing direction is closed — a denial, never a permit — and the fix changes a
verdict, which makes it core's call rather than a maintainer's. Escalated as
escalations/ESCALATION-20260827-006.md; named in Known limitations meanwhile, so an
integrator meets it in documentation rather than in a refusal.
Fixed — the formatter could reach outside the source tree
ruff check . --fix from the repository root walked into an operator's virtualenv sitting
beside the source and rewrote its third-party files. Ruff excludes .venv by default and
does not exclude venv. [tool.ruff] exclude now fences every virtualenv shape, and
tests/test_formatter_fence.py finds them by their pyvenv.cfg marker rather than by
name — a name-based fence catches venv and .venv and misses trial-env, which is the
miss class that caused it.
A recursive tool's path argument is a claim about everything beneath it.
onedoor 0.6.0
onedoor 0.6.0
Notes drawn verbatim from CHANGELOG.md's 0.6.0 section (R011:
release notes are a slice of the changelog, never a rewrite of it). Conformance status
with every gap named lives in CONFORMANCE.md; the ticket-by-ticket
plan is in BACKLOG.md.
Additive. Nothing existing changes meaning. No wire-observable change: no new reason
codes, no changed verdict shapes, no altered two-phase exchange, and a -00 enforcement
point is unaffected. No new enforcer migrations — the last is 0018, as in 0.5.0.
This release completes the Policy Studio. 0.5.0 shipped its first three tickets;
this one adds the remaining three — the coverage map, the payments template pack,
and the proposer — so ND-052 is delivered end to end: backtest, ratification,
canvas, coverage, packs, proposer.
The line the whole epic holds: the proposer is never the enforcer. The thing that
drafts policy has no path to the active set except the ratification ceremony, and it
enters as a candidate like any other — asserted structurally by a test that walks the
decision path's import closure and refuses to find the Studio, or any network client, in
it.
Everything Studio is behind the [studio] extra and off by default. An installation
that changes nothing behaves exactly as it did under 0.5.0. The Studio keeps its own
studio.db — schema version 2 in this release, upgraded forward automatically — because
the enforcer's database contains no row the Studio can edit.
A word changed meaning, deliberately. Constitution principle 5 said "the derivation
gets a receipt"; a proposal is not recomputable, so it gets a derivation record
instead — one that says on its face that it does not attest re-derivability, and that
the candidate's authority comes from the checks it passes, never from the record. The
amendment and its reasoning are in docs/studio-constitution.md.
Upgrading: nothing to do. No engine migrations; the Studio store upgrades itself on
first open.
Added — ND-052 / S6: the policy proposer, and the epic completes
The Studio's last ticket. The proposer is never the enforcer: it drafts a candidate and
has no path to the active set except the ratification ceremony, entering it as a candidate
like any other.
- A derivation record, not a receipt. Every other artifact this project emits is
recomputable — that is what makes it a receipt. A proposal is not: the same description
through the same model twice may differ, and recording the instrument pins the
conditions, never the output. Constitution principle 5 was amended rather than
stretched to say so, and the record states on its face both that it is not
re-derivable and that the candidate's authority comes from the checks it passes, never
from the record. proposer_provenance: live | fixture— the same value pair asledger_provenance,
because it is the same distinction and a renderer must not learn a second dialect for it.
Inside the record's digest, so relabelling a fixture-drafted candidate as a model's work
breaks the record's own address.- Descriptions are received data. Stored as BLOBs, byte-for-byte, never normalised —
the digest a record cites is taken over exactly the bytes the operator wrote. - One surface, two sections, never one table. The coverage map's rows are measurements;
the proposal's mentioned-but-unruled rows are a model's reading of a sentence. Each
section states its warrant, and every asserted row cites the coverage state it was
checked against. - The decision path cannot reach a proposer — a structural test walking the import
closure from source, lazy imports included, and refusing any network client at all. - A benchmark that publishes its misses first. No score gates anything; the demo may
run when the results, misses included, are published beside it and the demo states its
number. The corpus includes adversarial descriptions and the published misses include the
security-shaped ones.
New module onedoor/studio/proposer.py with a deterministic fixture proposer so CI runs
with no key and no network. A model-backed proposer is a separate credentialed component
and is not part of this build — and nothing falls back to the fixture silently.
Added — ND-052 / S5: the payments template pack
onedoor/templates/payments/ — worked examples, shipped in the wheel, adopted through
the ratification ceremony.
- No placeholders, anywhere. Every value is concrete and fail-closed. A blank is a
promise that someone will remember — and a template with blanks cannot be checked,
because it is not yet the thing the check checks:{{daily_cap}}is not aPolicy, so
a pack full of blanks would pass its own law tests against an artifact that does not
exist yet. Adjusting means editing a real number that was already safe. - Every effect the pack names is declared, asserted through
coverage.build's own
declared_inertdetector rather than a checker written for the pack — and no declared
effect has a null floor, which is theND-040/U4 half of the same law. PACK_DIGESTis the pack's file identity — byte-for-byte what shipped, comments
included — generated bypython -m scripts.pack_digest, never typed. The meaning
identity is the existingpolicy_digest, cited rather than re-minted.- Adoption goes through the ceremony, so the receipt's
candidate_digestis the
pack'spolicy_digestby construction: lineage is recoverable by recomputation rather
than by a stored pointer. No schema change. - The boundary is named, not disclaimed.
PACK.mdstates plainly that this is not a
compliance artifact and that nobody who wrote it has payments domain authority, then
names what is absent: sanctions screening, KYC, chargebacks, multi-currency settlement,
regulatory reporting. A named gap is a service to the reader; a disclaimer is a service
to the writer.
Fixed — the wheel shipped no template data files
include = ["onedoor*"] ships Python modules; the pack's .yaml and .md needed a
package-data entry. This is the 0.3.0 defect — a wheel that shipped no migrations —
reproduced exactly, and caught by a test written before the build rather than by a user's
first query.
Added — ND-052 / S4: the coverage map
Constitution principle 4 — non-coverage is stated, never silent — as something a
deployer can look at. Four states, and the ranking is by what each does at decision
time, not by how alarming its name sounds:
declared_inert, first and loudest — a rule labels an effect with no
effect_policiesrow behind it. The label is silently dropped: no tier floor, no
effect caps. It sounds fine and behaves dangerously, which is why it outranks
everything else on the map.uncovered_observed— the ledger saw this action type and no policy declares it.
default_deny: it sounds bad and behaves safely, because the engine refuses loudly and
the operator finds out.unobserved— a declared effect nothing in the cited range exercised. Rendered
absent, never as safe: a measurement nobody took is not a clean result.covered, quiet.
The map's sources are the policy set and the ledger — not a description, which does
not exist until the proposer ships. A description says what someone remembered to write
down; the ledger says what happened.
It is a view that cites, not a receipt. Its result is a pure function of the policy
snapshot's version_hash and the ledger's cited range, both already content-addressed,
so a coverage digest would be a second address for facts that have one.
docs/coverage-derivation.md documents the derivation well
enough for a second implementation — and records the impurity that writing it exposed:
actions_audit stores action types but not resolved effects, so effect exercise is
derived from today's rules applied to past traffic, which every rendering states.
Rendered without --ok/--bad. Those are verdicts' alone: red on a receipt means this
was denied, a past fact; on a coverage cell it would mean this would be denied, a
prediction about a class — and a colour that means two things means neither.
Added — python -m scripts.gate, the documented way to run gates
A gate now runs through subprocess with no shell and no pipe, and passes only when
the exit code and the declared output contract both hold. It prints what it ran,
where, and with which tool versions, so its output cannot be mistaken for a hand-run
transcript.
This exists because a documented rule was not holding: cmd | tail then $? reads
tail's status, and that landed for the third time despite being written down. Laws
pushed into construction outrank laws kept in memos. tests/test_gate_discipline.py
refuses any committed shell that reads $? after a pipe, holds the gate contracts apart
so one gate's output cannot satisfy another's, and asserts a path carrying a backslash
escape survives being passed as argv.
Building it reproduced the very defect it targets: the first contract table declared the
tests gate as the literal " passed", which is a substring of ruff's "All checks
passed!" — so a lint run would have satisfied the test gate. The contracts are now
patterns requiring a count, and a test compares each against every other gate's real
output.
onedoor 0.5.0
onedoor 0.5.0
Notes drawn verbatim from CHANGELOG.md's 0.5.0 section (R011:
release notes are a slice of the changelog, never a rewrite of it). Conformance status
with every gap named lives in CONFORMANCE.md; the ticket-by-ticket
plan is in BACKLOG.md.
Additive. Nothing existing changes meaning. No wire-observable change: no new reason
codes, no changed verdict shapes, no altered two-phase exchange. A -00 enforcement
point is unaffected. Seven forward-only migrations (0012–0018) apply on first run.
This release is the evidence pillar. ND-001 chains audit rows, ND-015 signs
them, ND-017 anchors them into an RFC 6962 Merkle tree, ND-010 lets a permit outlive
the process that issued it, ND-009 resumes through an approval, and ND-051 renders
the result as a receipt you can read. Every one of them is opt-in and off by default:
an installation that changes nothing behaves exactly as it did under 0.4.1.
The line the whole epic exists to hold: onedoor never vouches for itself. A
signature this store can check against its own keyring is self_consistent, never
verified; verified requires something the store does not hold. The viewer renders
that distinction rather than flattening it, and shows the failure state instead of the
value whenever verification is not sound.
The Policy Studio ships behind the [studio] extra, and it is incomplete on purpose.
Included: the backtest engine, the ratification ceremony, and the canvas. Not
included: the coverage map, the finance pack, and the proposer. It is a proposer and
never an enforcer — nothing in it writes to the decision ledger, the canvas server binds
loopback only, and drafts live in a separate studio.db because the enforcer's database
contains no row the Studio can edit.
Upgrading: run the engine once to apply the migrations. Nothing else. To turn on the
evidence features, see docs/row-preimage.md and chain.enable; signing additionally
needs onedoor[signed].
Added — ND-052 / S3: the policy canvas
An editor for candidate policies that shows the hash they would become, the rules they
change, and what they would have done to the ledger — then invokes S2's ceremony.
- A separate, loopback-bound process.
python -m onedoor.studiois not part of
onedoor.service, and that is a security boundary rather than a packaging choice:
the service is the PDP, and one leaked credential must not both answer decisions and
rewrite the rules those decisions are made under. The server refuses to bind
anything but loopback — a literal loopback address orlocalhost, nothing else,
refused before a socket exists. A hostname is refused without being resolved,
because a boundary that depends on what DNS said a moment ago is a lookup, not a
boundary. There is no flag that turns the refusal off; a flag that turned it off would
be the config drift it exists to catch. - Drafts live in the Studio's own
studio.db. The enforcer's database contains no
row the Studio can edit. Mutability already lives in the main store where the enforcer
owns the mutation; what it has never held is a row a second process edits. Losing
studio.dbloses drafts and nothing else — receipts are evidence and stay sealed where
evidence lives. - Pin and surface. A draft is pinned to the version it was opened against and never
silently re-bases: a live re-base is a stale read arriving before the click, where
ratification's compare-and-swap cannot catch it. A moved active set names both
hashes — a warning that names no versions is a mood, not a fact — and every computed
number goes stale together and recomputes together, because the panels are one object
rather than three fields. - Validation collects instead of raising, without becoming a second validator. The
canvas wrapspolicy_loader.validate_policyand reports its messages verbatim. It says
"problems found", never all problems, and renders that notice even when the list
is empty: the engine's validator stops at the first failure in each rule, and defects
that only appear when rules are read together are invisible to a per-rule check. - Refusals travel verbatim. A lost race and the two citation failures reach the
canvas with their own words and their own named reasons, never flattened into "could
not ratify" — they are distinct facts with distinct remedies. - Oneview, minus the fence that does not apply. The canvas takes §4's tokens, §5's
anatomy and §2's law; §3's static/read-only delivery fence governs the receipt viewer,
as the spec's own status line says. State colours stay verdicts' alone: the diff zone
separates additions by seal, weight and rule, and the semantic pair appears only in the
backtest panel, whose counts are verdicts. Held by a test in both directions.
New optional extra onedoor[studio]. No AADP wire-observable behaviour changes, and the
engine gains no dependency: the Studio's FastAPI requirement is hard at the point of use
and absent everywhere else.
Fixed — two imports the CI environment did not install
uvicorn and langchain-core are imported by the package and were missing from the
[dev] extra, which is all CI installs. uvicorn arrived with the Studio server and
turned CI red on both jobs; langchain-core was already there and had been passing only
because langchain happens to pull it in — a gate that would have gone red on a morning
nobody touched the code, the moment an upstream restructured its requirements.
Both are now declared. The mypy override that would also have turned CI green was
rejected: silencing ignore_missing_imports makes the gate pass by making it check
less, and the one call site the dependency exists for is exactly the site that then goes
unchecked.
A test now closes the class locally: tests/test_packaging.py reads the package's own
ASTs and asserts every third-party module it imports resolves to a distribution [dev] installs, with exceptions carrying written reasons.
Added — ND-052 / S2: the ratification ceremony
Diff a candidate against what is in force, see the hash it would become, ratify, and
get a receipt. This is the act that turns a candidate — which has only a digest over
models — into a version recorded in policy_versions.
- The previewed hash is the produced hash. The number shown is not computed
alongsiderecord_snapshot; it is produced by it, in a scratch store that is thrown
away. The scratch store holds the candidate merged over the active set, because
the snapshot renders the whole policy table and seeding it with only the changed rules
yields the hash of a two-rule deployment — a different number wearing the right label.
A sabotage test does exactly that and watches the equality fail. - A lost race refuses; it never silently writes. Ratification is a compare-and-swap
against theversion_hashthe diff was read from. A UI has a gap between reading and
clicking, and an operator must not sign something other than what they read. It
refuses loudly and does not re-diff on the operator's behalf. - A cited backtest is checked at the ceremony. The digest must resolve in this store
and itspolicy_digestmust equal the candidate's — otherwise refusal, under two
different named reasons, because a citation that resolves to nothing and one that
resolves to a test of a different candidate are different facts. Ratifying without a
backtest stays allowed, and the absence is rendered in every view rather than left
as a null nobody sees. Where a backtest is cited, itsledger_provenanceis
surfaced by dereferencing: a fixture-informed ratification is legitimate and must be
visible as one. ratified_by_session, notratified_by. onedoor has no authenticated per-caller
identity, so the field holds a declared session, and every rendering says
"declared, not authenticated". A field's name is part of its honesty. An
authenticated principal will beonedoor/ratification/2.- The receipt exports as two files — itself and the snapshot it names — and verifies
from those alone: the receipt matches its own digest, and the snapshot hashes to the
version it ratified. No database, no deployment.
Migration 0017 adds the append-only ratifications table.
Changed — the kill switch does not block ratification, and the lift now says why
The switch wins over every action under every policy, so nothing ratified can move while
it holds: the moment of risk is the lift, not the ratification. Blocking policy
edits mid-incident would punish the operator tightening rules while stopping no attacker
who already had ratification access.
So the state is recorded rather than enforced — kill_switch_engaged is a hashed field
on every ratification receipt — and the release path is where a change becomes loud.
Migration 0018 records the policy version in force when the switch is engaged, and
releasing it reports any change since: "the rules changed while the door was shut, from
X to Y." Surfaced through both the admin endpoint (policy_change_while_engaged) and
the MCP proxy; the lift is not blocked either. This product makes states visible; it
does not take the wheel.
The report has four states and none collapses into another: changed, unchanged,
undeterminable (an episode with no recorded version) and no_episode (a store
upgraded while the switch was already held). Only unchanged says the rules held still,
and it says it because two hashes were compared.
killswitch.set_engaged now returns that report on release and None on engage; the
admin endpoint's response gains a policy_change_while_engaged field. No AADP
wire-observable behaviour changes.
Known limitation, stated rather than implied away: a ratification cannot retire...
onedoor 0.4.1
Additive. Nothing existing changes meaning. New opt-in policy vocabulary and two
forward-only migrations; every rule you have deployed matches exactly what it matched
under 0.4.0, which is asserted rather than intended (see the compatibility corpus
below). No wire-observable change: no new reason codes, no changed verdict shapes, no
signature changes. A -00 enforcement point is unaffected.
Upgrading: run the engine once to apply migrations 0010–0011. Nothing else.
Added — ND-040: URL-valued parameters are matched as URLs
A param_effects rule may now declare a url: block instead of a pattern:, and
matching happens against the canonicalized target rather than the parameter's
string form. Opt-in: a rule without a url: block matches exactly what it matched
before, and tests/guardrail/test_param_effects_compat.py asserts that against every
pattern shipped in this repository plus generated inputs — no deployed policy changes
meaning because the engine was upgraded.
param_effects:
- param: url
add_effects: [money.egress]
url:
hosts: [bank.example.com] # canonicalized on both sides
include_subdomains: false # explicit, never implied
cidrs: [203.0.113.0/24] # for IP-literal targets
schemes: [https]
opaque: {builtin: true} # hosts whose target cannot be knownCorrecting the mechanism sentence in the 0.4.0 disclosure. That entry said the
three URL-shaped evasions would be closed by canonicalizing first. Building it showed
that is true of one of them. The promise stands and is kept; the description of
how was wrong, and a disclosure that keeps a wrong mechanism to avoid an edit is not
a disclosure register working:
| Evasive case | What actually closes it |
|---|---|
https://bank%2Eexample%2Ecom/transfer |
Canonicalization. %2E decodes to .; this is the canonicalization case proper. |
https://203.0.113.7/transfer |
CIDR matching, and a deployer who declares the network. A hostname pattern cannot express an address at all. The mechanism makes the case expressible; it does not supply the knowledge. |
https://t.co/x9k2 |
Not canonicalization at all. The host really is t.co; the bank is behind a redirect, and following it is a network call the PDP's offline model forbids. Closed by a declared class of opaque hosts — a shipped, versioned starter list plus the deployer's own, matched by exact host after canonicalization, treated as possibly the declared target because it might be. |
The semantics in one sentence: a host in the declared redirector class is never
auto-executed; a human approves it, or policy denies it. An action whose consequences
cannot be verified must not be auto-executed — that is not the same as saying it
can never happen. A redirector's true destination is unknowable without the network
call determinism forbids, and the honest governance answer to unknowable is "a human
decides", not "nobody decides".
This is an invariant, not tier arithmetic. It holds whatever the action's tier is
and whether or not the effect you attached declares a floor. Stating it that way is
not pedantry: relying on the effect floor alone left a real hole, found by probing this
exact condition before release. A policy could declare opaque and point at an effect
with min_tier: null, and a declared redirector would then auto-execute silently — the
deployer asked for the protection, the engine took the declaration, and nothing
escalated. The mechanism was one YAML line away from being decorative. It never shipped
that way.
Measured on the instrument that disclosed the gap.
experiments/aliasing_benchmark.py gains an L3 layer beside L2 — L2 is left
exactly as it was, because a fix that edits the baseline it is measured against has
destroyed its own evidence:
layer named generic✓ evasive innocent-ok note
L2 5/5 4/4 0/4 3/3 + deterministic param rules
L3 5/5 4/4 3/4 3/3 + URL-typed rules (ND-040)
tests/guardrail/test_aliasing_acceptance.py asserts every number in that table in
CI, including the one that did not move: the base64 shell case (ND-048) is
asserted still failing, so this fix cannot be read as closing more than it does.
innocent-ok staying 3/3 is the over-blocking guard — governance that fires on
innocents is a defect, and the opaque-host class is exactly what could have broken it.
- A target that cannot be interpreted is denied, not guessed. A parse differential
becomes a denial rather than a bypass — the governing sentence isscopegate's
(Apache-2.0, D. Mellafe Zuvic), cited rather than reinvented: a scope gate must
interpret a target at least as strictly as the networking stack that will later
connect to it. The reason code is the existingmalformed; no new wire
vocabulary. The audit row recordsmalformed_kind='url_canonicalization'and the
canon_schemathat produced the verdict, so an operator can tell a broken client
from someone probing the effect matcher, and so a verdict that changes after an
upgrade is attributable to the canonicalizer rather than to the rules. - No new runtime dependency. The canonicalization is part of the instrument, so a
canonicalization that changes under a library upgrade would be an instrument change
wearing a patch release. The standard library's IDNA codec maps the Cyrillic
homograph toxn--ank-9cd.example.com— visibly notbank.example.com— which is
the whole security property: non-collision and determinism, not IDNA2008
completeness. IPv4 shorthand (0x7f.1,2130706433,127.1) is parsed in-module
rather than bysocket.inet_aton, whose acceptance of those forms is
platform-dependent. - Upgrading: run the engine once to apply migrations
0010–0011, which add
malformed_kind,canon_schemaandopaque_classtoactions_audit. Forward-only,
all NULL on existing rows, and NULL means "this verdict did not depend on a
canonicalization or an opaque declaration" — which for a pre-ND-040row is simply
true.
Known gaps this does NOT close
- An undeclared shortener is not caught. The opaque class is a starter list, not a
census: new redirectors appear constantly, anyone can run one on their own domain,
and a caller can use one this list has never heard of. The mechanism raises the cost
of that evasion and names the ones worth naming;opaque.extraexists because a
deployer knows their own environment's link-wrappers better than we do. - The IP-literal case needs a declared network. A deployer who does not know their
target's address range cannot write the CIDR that catches it. ND-048is untouched.bash -c "$(echo <base64> | base64 -d)"carries no
matchable literal; the governed effect is real and no deterministic parameter rule
reaches it. Ticketed asND-048so it cannot age out of the disclosure, with no
fix scheduled — and now asserted as still-failing in the test suite, so the gap
cannot close by accident either.- The stdlib implements IDNA2003, which differs from IDNA2008 on a handful of
characters (ß, final sigma, a few others). A difference produces a non-match,
never a false match, so the failure direction is safe — but a policy written
against an IDN host in that set would not match a request spelling it the other way. - An envelope-validation
malformeddenial writes no audit row (ND-050). A
request whose envelope fails validation is denied before a policy or a request
object exists, so there is nothing to append against and the returned result carries
noaudit_id. Present in≤0.4.0; found while buildingND-040and not caused by
it. The action does not happen and the caller is told, so nothing is mis-permitted
— but "the audit log is append-only: decisions, results, denials, dry-runs and
kill-switch blocks" is a claim this project makes, and one class of denial is outside
it. Note the asymmetry this release creates and did not cause: a malformed URL
now writes a row namingmalformed_kind, a malformed envelope writes none.
Ticketed, not fixed here — appending needs a row shape for a request that failed to
parse, which is a design question rather than a one-liner.
v0.4.0
0.4.0 — 2026-08-22
One breaking increment: breaking for archives and readers, not for PEP enforcement.
A -00 enforcement point still denies correctly against this release — a PEP's
behaviour is fixed by the verdict, never by the reason string. What changes is what
the audit says, what the decide response carries, and the signature of
report_result.
Upgrading: run the engine once to apply migrations 0007–0009; they are
forward-only and add columns to actions_audit and policy_versions. Then, in order
of how likely it is to touch you:
report_result(..., ok: bool)is nowreport_result(..., outcome: Outcome), and
POST /v1/reporttakes"outcome"instead of"ok".- Reason codes
cap_daily_rate/cap_eur_day/cap_eur_monthare gone; match on
cap_rate/cap_valueand read the window from the newbudgetobject. - Your policy content-hash changes once even if your rules did not — see below.
Changed — BREAKING for archives and readers, not for enforcement
- Reason codes are unit-neutral (
aadp/0.2).cap_daily_rate→cap_rate;
cap_eur_dayandcap_eur_month→cap_value, with the window and unit
moving intoND-003'sbudgetobject rather than the code.sender_mismatchis
reserved and never emitted untilND-005wires the check it reports on. Clean
break, no dual emission — safe because reason codes are audit vocabulary: a
PEP's behaviour is fixed by the verdict, never by the reason string, so an older PEP
that has never heard ofcap_valuestill denies correctly. If you match on reason
strings in dashboards or alerts, they change here. - Received params are stored verbatim; generated structures are canonicalised.
Theparse → json.dumps(default=str)round trip is gone. When an enforcement point
sends bytes — over HTTP or the MCP proxy — the audit row stores those bytes:
250.00stays250.00, because the record must show what was transmitted, not
what this PDP would have written. The in-process binding is handed objects and has
no sender's bytes, so it serialises once, canonically, at ingress. Which of the
two produced a row is recorded (params_provenance:received|serialized,
migration0009) rather than inferred — areceivedrow can be re-derived against
what the caller sent, aserializedone only against what this PDP produces, and
letting the second pass for the first is the thing the column prevents. NULL means
unknown: rows written before0.4.0were neither verbatim nor canonical, and
inferring either for them would be inventing evidence. There is deliberately no
received_digestcolumn — the bytes are stored, so the digest is derivable. report_resulttakes a four-valueoutcome, notok: bool— BREAKING for
enforcement points.success | failure | timeout | not_attempted, and
/v1/reportaccepts the wireoutcomefield (already normative in-00, so this
is conformance catch-up rather than a wire break). Settlement now depends on the
outcome:success,failureandtimeoutsettle the budget reservation;
not_attemptedreleases it, as an audited event. Settle on doubt — a timeout
is not evidence the action did not happen, so only a positive assertion of
non-occurrence frees budget. This closes a live conformance defect (A4b): before
0.4.0,not_attemptedandtimeoutboth collapsed intofailedand the
reservation settled before anything examined the outcome, so a PEP that correctly
refused to act still had its tenant charged for an action that never occurred.
connector_okis now NULL rather than false fornot_attempted, because recording
false asserts an attempt that did not happen. The in-process executor reports
not_attemptedwhen no connector is registered — that path was charging budget for
a dispatch that found nothing to call.
If you callreport_resultorPOST /v1/report, this is the change to make.- Cap denials carry a machine-readable
budgetobject. Present iff the
verdict is a denial with reasoncap_valueorcap_rate, on the decide response
and persisted tobudget_json. Seven required fields:dimension
(value|rate),unit(ISO 4217 for value, a token likecallsfor rate),
window,limit,consumed,remaining,window_resets_at. Currency lives in
unit, never in a field name. This is what makes the unit-neutral codes safe:
cap_valuecollapses the oldcap_eur_day/cap_eur_month, so without it an
evidence reader could no longer tell a day breach from a month one. Numerics are
canonical decimal strings;window_resets_atis RFC3339 UTC derived from the same
timezone the counters are keyed in. - Every audit row is stamped
aadp/0.2. A row with no stamp MUST be read
underaadp/0.1— that absence is a fact about when the row was written, not a
value to infer. Existing rows keep the codes they were written with; history is not
rewritten. - The policy snapshot records which canonicalisation produced its hash
(snapshot_schema, migration0008). The content-hash changes on upgrade for
unchanged rules, and this is what makes that diff attributable — "renderer
changed, rules did not" versus "rules changed" — from the record rather than from
memory of when you upgraded. Absent means schema 1. - Numeric policy bounds and parameters are
Decimal, never IEEE doubles. Policy
YAML numbers load asDecimal, JSON ingress parses withparse_float=Decimal, and
bounds, cost resolution and settlement all carry the exact value through. Two
visible consequences: denial messages no longer show float artefacts (above max 23, notabove max 23.0), and the policy content-hash changes on upgrade even if
your rules did not —bounds_jsonandcaps_jsonnow record decimals in canonical
shortest-exact form (100,100.00and1E+2all record as100and hash
identically), so an unchanged policy set gets a newversion_hashonce. Existing
audit rows keep the hash they were stamped with. - Migration
0007adds the0.4.0row format toactions_audit:protocol,
budget_json,outcome, and the whole receipt envelope. Everything past the first
three lands dark — declared and governed, filled by later increments — so a
table that cannot be updated is migrated once rather than three times.
Defects present in 0.3.6 and earlier, closed by this release
Found by the 0.4.0 code survey rather than by incident, and named here because the
known-gaps register applies to bugs found after a release exactly as it applies to
gaps known at one.
- Numeric parameters are compared as IEEE doubles, and a bound can admit a value
that exceeds it.json.loadsruns with noparse_float, so a numeric parameter
becomes a double before any check sees it; a wire amount carrying more precision
than a double holds is rounded onto the bound and allowed. Demonstrated: policy max
500.10, wire amount500.1000000000000000001, verdict allowed. The admitted
excess is about half an ulp of the bound — ~5e-14 at500.10, but ~10 at a bound of
1e17, so it is negligible at money scale and material for large-magnitude bounds.
The symmetric case (a compliant value falsely denied) also exists and fails closed.
Mitigation for0.3.6deployments: send money amounts as JSON strings —
"500.10"is exact end to end, because cost resolution accepts strings. Closed in
0.4.0by parsing withparse_float=Decimalat every ingress and typing numeric
bounds asDecimal. - Policy YAML numbers are loaded as floats (
yaml.safe_load), which is how the
bounds above became doubles. Closed by the same change. - Money is stored through
str(Decimal), so equal amounts persist as different
text (2.50,5.00,7.500,10.000for four €2.50 spends). Assessed and
benign for enforcement: the money is in no key or index, is never compared as text
in SQL, and the round trip is value-preserving — 4000/4000 generated values, zero
comparison flips in 40,000 comparisons, accumulation exact. It makes the audit's
text untidy and would break a digest computed over that column, which is why it is
fixed rather than left.
v0.3.6
0.3.6 — 2026-08-21
Hygiene and one real conformance fix. No wire-format change; no behaviour change
for existing policies.
The reference implementation stops publishing a contract violation (ND-021)
examples/litellm_guardrail.py called report_result(ok=True) from
async_pre_call_hook — asserting an action had succeeded before the gateway had
done anything. That is a violation of the two-phase contract this project exists
to define, shipped as a documented example and cited in the draft's Implementation
Status as "not conformant as written".
Decide and report are now split across hooks: the pre-call hook decides and holds
the permit, reporting nothing; async_post_call_success_hook and
async_post_call_failure_hook report the real outcome. Correlation is
data["litellm_call_id"]; when it is absent the adapter refuses before deciding,
so no permit is issued that it could not report on. The pending-intent map is in
process memory — a documented limitation, mirroring ND-010 in the decision
service, with reservation reclamation as the backstop.
Ten new tests, including the regression that fails against the old behaviour.
Also in this release
ND-024— the vestigialintake_policy,preferencesandsessionstables
(inherited from a pre-onedoor product) are dropped by migration0006.
push_subscriptionsis kept and now says in a comment that it is reserved for
web-push delivery, so nobody mistakes it for dead schema.ND-025— CI actually enforces the gates.ruff,ruff format,mypy --strictandpytestrun on a 3.12/3.13 matrix;ruffis pinned, because an
unpinned linter makes CI non-deterministic. All four now pass; none of them did
before.ND-036—ROADMAP.mdis a pointer to the live documents rather than a
stale feature list. Eleven work items that lived only in it were migrated into
BACKLOG.mdrather than deleted.- Packaging: a
[litellm]extra. The LiteLLM example imported a package no
extra installed, so anyone following the docs hitModuleNotFoundError. - Repository integrity:
.gitattributesforces LF and freezes the vendored,
digest-pinned reference artifact byte-for-byte. Windows'core.autocrlfdefault
silently rewrote those bytes, which made every pinned manifest fail verification
in a way that reads as evidence tampering rather than as an encoding bug. READMEno longer advertises a stale test count; the CI badge carries it.
Fixed
pytestandpython -m pytestno longer disagree. 19 test modules import
tests.conftest, which needs the repository root onsys.path;python -m
supplies it implicitly and the barepytestthat CI runs does not. The suite
passed locally and could not even be collected in CI.
Known gaps, stated rather than implied
- No obligation machinery at all (
CONFORMANCE.mdN6 /ND-038). A conformant
PDP obligation attached to a permit would be silently ignored by onedoor's own
enforcement points. report_resulthas no outcome parameter, sonot_attemptedandtimeout
collapse intofailed(CONFORMANCE.mdA4b /ND-039). A reservation is
settled before the outcome is examined, which charges budget for an action that
may never have occurred. Fixed in0.4.0.- Transport security (A1), sender-constrained permits (A2) and hash-chained audit
entries (P1) are not implemented. - URL-valued parameters are matched as strings, and known evasions defeat that
(ND-040).param_effectsfull-matches a regex against a parameter's string form,
so a pattern likehttps://(pay|bank)\.example\.com/.*does not survive contact
with an adversarial URL. Measured, not suspected:
experiments/aliasing_benchmark.pyscores 0/4 on its evasive set at every layer,
including the layer with deterministic parameter rules. The four cases are a
redirector/shortener (https://t.co/...), an IP literal, a percent-encoded
host (bank%2Eexample%2Ecom), and a base64-obfuscated shell command.
Three of those four are URL-shaped and are whatND-040addresses — canonicalize
first (scheme normalization, IDNA, host lowercasing, explicit subdomain semantics,
CIDR awareness) and deny on canonicalization failure, so a parse differential is a
denial rather than a bypass. Scheduled for0.4.x, immediately after0.4.0.
Reasoned from the matcher's design rather than individually measured, the same
string-matching weakness also covers IDN homographs, auser@hostprefix, a
trailing-dot host and case variation. - The fourth evasive case is a separate gap that
ND-040does not close.
Indirect or obfuscated command construction (bash -c "$(echo <base64> | base64 -d)")
is not a URL-canonicalization problem, and no deterministic parameter rule catches
it; the benchmark says so in its own output. Nothing in this release addresses it. - What follows for a deployer, plainly: do not rely on
param_effectspatterns as
a network scope control against an adversarial input. Use them to label effects of
cooperative inputs, and put a fail-closed egress control in front of anything that
matters. Known evasions are published here rather than left to be discovered.