# Changelog onedoor is the reference implementation of the AADP Internet-Draft (`draft-saha-aadp`). Per-requirement conformance status, gaps included, lives in [CONFORMANCE.md](CONFORMANCE.md); the ticket-by-ticket plan is in [BACKLOG.md](BACKLOG.md). ## Unreleased ### Added — `ND-055` V1: the Studio shell ("the ledger room") Ships in `0.7.0`, after Sept 12. Additive; no engine, schema or wire change. The Studio's own design tokens, vendored from the mockup core named as binding design authority and pinned by digest — a warm umber ground, a serif for ceremony, mono with tabular numerals for digests, and a `review` state colour oneview has no name for. The palette diverges from oneview's deliberately: the design note says *"warm charcoal/umber, never blue-black"*, and settles that §4's rule (seal gold never signals state) binds everywhere while the palette does not travel. A header with the wordmark and the version banner — *in force `` · ratified `` · N policies · M effects · loopback only* — and a tab bar over the five sections. The banner has **three** words for its date, not two: a date, `never ratified`, and `not ratified through this Studio` for when the version in force is not the one the latest ratification produced. Printing that ratification's date beside it would be wrong confidently, in the field an auditor reads first. Tabs are links and unbuilt sections render a page saying which stage builds them, rather than a dead link or a 404. Digests render `first-8…last-4`, full on hover, with copy-on-click as progressive enhancement: one small inline script adds the class that enables `cursor:copy` in the same statement that attaches the handler, and returns early where no clipboard API exists. Nothing the server emits carries that class, so with scripting off the page promises nothing it cannot do. ### Added — `ND-055` V2: the policy library (S1) The Policies screen: every rule in the version in force, with its decision, tier, caps, bounds, effects and coverage badge, and a detail page that says in plain English what each rule does beside the rule itself. It reads the **snapshot behind the pinned version**, not the live policy tables. The two agree through the normal write path and can disagree through any other, and then they answer different questions — the tables are what the next version would contain, the pinned version is what the engine is deciding against and what the page's own digest names. Three outcomes are kept apart: no version in force, a version whose snapshot cannot be read, and a real set. The middle one renders as *"this is not an empty policy set"*, because reporting it as empty would tell an operator nothing is permitted while the engine is permitting things. Every page carries the sentence that an action with no policy is denied — a list of permissions reads as a list of restrictions unless it says otherwise. ### Added — a dogfooding walkthrough, with its commands under test `docs/DOGFOODING.md` walks a person through all eight Studio screens on a fresh install, ending with the verify command run against a receipt the walkthrough itself produced. Its commands are read out of the document by a test and either run or checked, and each one says which: three are executed to completion with their exit codes asserted, four are validated with the reason they cannot be run and with whatever they claim checked instead. A command added to the walkthrough that nothing checks fails the build. `python -m onedoor.studio.walkthrough` is a walkthrough aid rather than a product feature: it puts one real decision through the engine so the History and re-evaluate screens have something to show. ### Added — `ND-055` V8: the Verify page, and the law tests across every screen A deposition page for a reader who trusts nobody involved: it states that it cannot verify anything for you, shows the two files, and prints the exact command to check them yourself. `python -m onedoor.studio.verify receipt.json snapshot.json` reads two files, opens no database, and reports three outcomes in its exit code as well as its words — verified, failed, and unreadable, which is not a failed check but a check that never ran. The six properties this build owes are now asserted over every page the Studio serves, with the set of pages read from the app's own route table rather than from a list. ### Fixed — two pages contradicted the promise in the Studio's own header `/docs` and `/redoc` served Swagger UI and ReDoc from a CDN, with fonts from Google and a favicon from a third site, on a server whose header reads *"loopback only — nothing leaves this machine."* The auto-generated API docs are now off: the Studio is an operator GUI on loopback, not an API surface, and its JSON endpoints are documented in the README. ### Fixed — the Studio served two designs at once `/` and `/draft/{id}` still rendered the pre-shell page after the Drafts screen moved to `/drafts`. Both now redirect, because published links keep working — and two fixes that had been stranded on the old page, the empty-store warning and the create-a-draft `curl` one-liner, are restored on the page operators actually reach. ### Added — `ND-055` V7: the editor (S2) A guided form and the raw rule, side by side, inside a draft. Both panes are rendered from one parsed object, so they cannot disagree: editing either and saving re-renders both from what the server stored. Syncing them in the browser would have meant a second implementation of the policy parser, and the two would have differed on exactly the inputs this engine cares about — decimal strings, unicode, key order. The guided form is a declared subset and the page says which fields it does not offer; saving from it leaves those untouched rather than dropping what it never displayed. A rule that cannot be parsed answers 400, says the draft is unchanged, and writes nothing. Decimal fields carry a note describing how the engine treats decimal strings today: a cap reads one exactly, a numeric bound over the same parameter refuses it, so declaring a bound changes which wire types the action accepts. Nothing on this page edits the rules in force. ### Changed — the palette measurements print in the run's summary The contrast and colour-separation matrices moved out of test bodies and into pytest's terminal summary. They are disclosure rather than assertion: the thresholds that can fail a build are still asserted in the tests, and the numbers are now printed once, in one place, at the end of the run. ### Added — `ND-055` V6: re-evaluate a past decision under any version The flagship. From any decision in the History ledger, replay it against the rules of any version this store can rebuild, with the original verdict beside the one those rules would have reached. *Was this refusal right under last week's policy?* — one click. The replay runs the **engine itself** against a throwaway database loaded with the historical policies, not a hand-written comparison of rules: the instrument is identical, not merely the answer plausible. Replaying against the version that actually decided reproduces the recorded verdict, which is the control case the feature rests on. Nothing in the real store changes. Both versions are named together — the one that decided, the one replaying — and every state of the panel carries the limit: it says what would have been decided, not what will be, and nothing was re-executed. A version this store holds no snapshot for renders **not retrievable** and shows no verdict at all. Replaying against an empty policy set would return a confident refusal with the shape of a real verdict and none of its meaning, so the comparison reports that it could not be made rather than that it found no difference. A row whose recorded parameters cannot be rebuilt is a separate failure with its own words. ### Added — `ND-055` V5: drafts, and ratification as a ceremony page (S3) The drafting pipeline the engine already had, made visible: the pinned base, a per-rule was / would-become diff, the validator's findings with its honesty notice verbatim, the backtest panel, and a repin that refuses a stale base by showing no numbers at all rather than numbers computed from a version no longer in force. **Ratification is a page before it is an action.** A GET shows what will be in force, what changes, and what ratifying does not undo; only then is there a confirm. The page does not say the change cannot be undone, because that is false — there is no un-ratify, and the way back is forward: ratifying again, a new version and a new receipt, with the record keeping both. A refused ratification answers 409 in the ceremony's own words and says nothing was applied. The backtest panel states its own limit: it replays decisions already recorded and says what would have happened, not what will. Each changed verdict is described by direction, because a single count hides the one direction that matters most — permitting what was refused. ### Added — a policy's two voices, side by side and never merged A rule's detail page now shows the operator's own frozen description beside the plain-language rendering of what the rule does — quoted, attributed, and visually distinct. The page states that neither is derived from or checked against the other, so a reader can see where the two disagree. A description that does not mention the rule says so; where no description exists the panel is omitted entirely, because an empty quotation would read as an operator who wrote nothing. ### Added — `ND-055` V4: the live room (S5) Cumulative budget bars per cap window, open reservations with their ages, approval lifecycles, and the kill switch's state with its rank stated in words. **Held money is not shown as spent.** A reservation is written into the cap counter at reserve time, so the counter is consumed *plus* reserved; the page subtracts what open reservations are holding rather than reading the counter as spend. A budget that showed reclaimable money as gone would mislead in exactly the screen an operator opens during an incident. A window with no declared cap draws **no bar at all** — a full bar and an empty bar both state a proportion, and there is none to state. A declared cap with no counter yet is shown at zero, so a fresh deployment does not look as though it had no budgets. Limits come from the snapshot behind the pinned version, not the live tables. Reservation deadlines have three outcomes, not two: within, past, and unreadable. A screen must not answer a question it could not evaluate. **The kill switch is read-only, and the page says why.** An admin API exists on the decision service, and reaching it from the Studio would need either a second write path into the enforcer's database or the PDP's admin credential inside a policy editor — the two things the split between these processes exists to prevent. The state is shown and no control is drawn, because a control that renders as operable and is not is a lie in the shape of a button. The page also states what the switch does *not* stop: policy-making continues, because nothing ratified can move while the switch holds. ### Added — `ND-055` V3: the execution ledger (S4) The History register: every decision the engine made, filterable by action, verdict, policy version, request origin and date range, with a detail view showing the rule path, the frozen parameters, the four digests and the chain fields. Entries are numbered by the hash chain's own sequence, not by their position on the page — an ordinal that changed with the filter would mean an auditor quoting "entry 14" was quoting the page rather than the ledger. Rows predating the chain say `unchained` rather than showing a number nobody assigned. Filters travel in the query string, so the address of a view is the view, and a filter value the ledger no longer holds is echoed back marked as absent rather than silently dropped. **Filtering by API key is not offered, and the page says why**: the service authenticates callers with bearer keys but no caller identity is written to the audit row, so the ledger cannot say who asked. Offering the nearest column instead would answer a question about identity with a fact about provenance. The page states that it shows what was recorded and does not re-verify the chain — that is the Verify page's job, against the receipt rather than against a rendering of it. ### Fixed — a detail page for a rule that does not exist now answers 404 It answered 200 with an honest body. The status code is the machine-readable verdict, and every crawler, cache, monitor and script reads the type rather than the prose. ### Changed — all chrome text clears WCAG AA Table headers, empty-state messages, key labels and the footer's loopback claim were all styled in a token at 2.96:1. Audited by use: everything that must be read was corrected, and the one decorative case — a disabled tab — kept its readability anyway, because a disabled control that is also unreadable is bad twice. ### Changed — the state colours clear WCAG AA, and no state is colour alone The approved palette's chips failed AA at chip size — the refusal chip worst of all, at 3.33:1, which is the wrong one to lose. All three foregrounds are lightened until each clears 4.5:1 on its own background, hue and saturation held, brand untouched. The fix is recorded beside the vendored token block rather than edited into it, so the palette that renders can still be compared with the palette that was approved, and each correction carries the measurement that forced it. That correction costs colourblind separation, and no choice of colour avoids it: the refusal red failed contrast *because* it was dark, and any red light enough to read converges with amber under tritanopia. So the guarantee moved off the number and onto the markup — every state chip renders a colour **and a word**, which is what accessibility guidance actually asks for. The contrast and separation figures now print in CI. ### Changed — the brand accent no longer carries state, anywhere Core superseded R049 §3's `--seal` clause: the rule binds everywhere, with no grandfathered screens. Seven rules across the coverage map and the proposal page marked a state or a classification in seal gold; all seven now distinguish by size, position, weight and texture instead — three mechanisms, and three are enough. The check that finds them was strengthened to its positive form: rather than asking whether verdict rules avoid gold, it enumerates every rule that uses gold and asks what routes it, with the state vocabulary read from the enumerations that declare the states. Two tests that had *required* the old styling were inverted in the same commit as the migration — a test that requires a violation must not survive one commit longer than the violation it protects. ### Fixed — the shell read the wrong database `banner_for` looked for `ratifications` in the Studio's draft store; it is an enforcer table. Every shell route raised `no such table: ratifications` on a fresh install. Caught through the server on the first request, by none of the library-level tests. ## 0.6.2 — 2026-08-28 ### 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. ## 0.6.1 — 2026-08-27 **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.* ## 0.6.0 — 2026-08-27 **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](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 as `ledger_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 a `Policy`, 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_inert` detector rather than a checker written for the pack — and no declared effect has a null floor, which is the `ND-040`/U4 half of the same law. - **`PACK_DIGEST`** is the pack's *file* identity — byte-for-byte what shipped, comments included — generated by `python -m scripts.pack_digest`, never typed. The *meaning* identity is the existing `policy_digest`, cited rather than re-minted. - **Adoption goes through the ceremony**, so the receipt's `candidate_digest` **is** the pack's `policy_digest` by construction: lineage is recoverable by recomputation rather than by a stored pointer. No schema change. - **The boundary is named, not disclaimed.** `PACK.md` states 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_policies` row 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](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. ## 0.5.0 — 2026-08-24 **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.studio` is 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 or `localhost`, 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.db` loses 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 wraps `policy_loader.validate_policy` and 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 alongside `record_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 the `version_hash` the 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* its `policy_digest` must 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, its `ledger_provenance` is surfaced by dereferencing: a fixture-informed ratification is legitimate and must be visible as one. - **`ratified_by_session`, not `ratified_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 be `onedoor/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* a rule. `upsert` has no delete and the candidate merges over the active set, so an omitted action type stays exactly as it was — and the receipt's `changes` therefore reports `added` and `modified` and has no `removed` field at all, rather than carrying one that can never be non-empty. ### Fixed — reclamation rows were sealed under one preimage version and claimed another `append_expiry` writes the `reservation_expired` row that records budget going back when a permit's deadline passes unreported. It does not go through `_row_values`, where `preimage_version` was stamped — so **every reclamation row was sealed under `onedoor/row-preimage/2` while its hint claimed `/1`**, and a verifier reading the hint recomputed the row under the wrong field order. Every one failed verification. **No deployment is affected**: chaining is opt-in and off by default, so no production store has sealed a row at all. It survived the whole crypto epic because **every chain test runs inside one frozen instant, where no reservation deadline ever passes**. The Studio's fixture ledger — three simulated days of traffic — was the first thing that reclaimed anything, and all 23 of its reclamation rows failed at once. The law that came out of it: **time is an input, and a suite that never lets it pass has not tested what it triggers.** The hint is self-authenticating by design, so the defect surfaced as a loud failure rather than a silently accepted row — the mechanism working exactly as specified, with this project as the liar. The stamp now lives in `_stamp_chain`, where the sealing version is chosen, so two places can no longer disagree about one fact. A targeted regression (one reservation, one passed deadline, one sealed row) guards it independently of the fixture, and a structural test asserts that **every** audit write path stamps the chain — so a future compaction or archival writer cannot inherit the same gap. One behaviour changed alongside it: an **unchained** row now carries no version hint at all. A hint on a row that was never sealed is a claim about a sealing that did not happen. ### Added — `ND-017`: content-addressed receipts and Merkle anchoring **The crypto epic's last ticket.** Each chained row now carries four content-addressed digests — `e_digest`, `i_digest`, `t_digest`, `v_digest`, in columns dark since `0007` — and ranges of rows are anchored under RFC 6962 roots a deployer publishes outside the store. `docs/receipt-digests.md` is the normative definition, with a second implementation built from it and six golden vectors. The four were read from the vendored artifact's own scheme rather than invented, and **confirmed by arithmetic**: the shipped manifests carry `t_digest = 4f53cda1…b945`, which is SHA-256 of canonical `[]` — so `T` really is a *declared closure* and not a bag of facts. Every digest is over canonical JSON, so **no concatenation appears and the `len8` dialect is not reached** — said plainly rather than decorated with an unused framing. Two amendments from R040, both where delivery's flags pointed. **`T` does not carry the policy hash**: `E` already seals it as an input identity, and the same hash in two preimages is X-14 *inside the seal itself*. **`I` does not carry the anchor cadence**: cadence schedules anchoring, not deciding, and inside `I` an ops-schedule tweak would have re-identified the deciding instrument for every row after it. It lives on the anchor object, where a change is visible in the stream it governs. **`anchor_ref` can never be written**, and the design is better for it. It is a column on `actions_audit`; anchoring necessarily happens after a row is sealed; the no-update trigger forbids `UPDATE` — verified against a live store, not assumed. So the anchor points at a *range of rows* and membership is resolved by lookup. A back-reference would have been a second answer to a question the range already answers, needing a writable column on the one table whose value is that it cannot be written. **X-8, and the reason stated where it is enforced:** the chain is verified before a root is computed, and a fault anywhere refuses the seal — an anchor over a broken chain would publish a root that certifies damage, permanently and in public. **onedoor never vouches for itself: at the key layer and the anchor layer alike, `verified` requires something the store does not hold.** A proof that checks against a root the store carries is `self_consistent`; `verified` needs the published root. And because anchoring is periodic, the newest rows read **`absent`** — a viewer that showed them red would train an operator to ignore red. The acceptance is an **environment**, not an assertion: the third-party verifier runs in a subprocess whose working directory holds exactly the anchor and the receipt. If it ever needed the database, that test would fail rather than look fine. ### Added — `ND-015`: signed decision receipts (Ed25519) Each chained row is signed over its `row_hash`, with the signature, the derived `key_id` and `alg` landing in columns that have existed dark since `0007`. **No hashed column, no preimage version** — a signature attests the row hash and cannot precede it, which is why those three were classified `EXCLUDED` before this ticket was written. **A receipt system must not be its own witness.** A signature checked against a public key found in the **same store** as the row it signs proves internal consistency, not authenticity: an attacker with write access adds their own key, re-signs what they altered, and the store agrees with itself perfectly. The append-only triggers do not close it — a keyring must accept `INSERT`s or rotation is impossible — and the chain does not either, because a keyring row is not an audit row. So signature checks have **five** outcomes, and the middle one is the point: | | | |---|---| | `verified` | checks against a `key_id` **the caller supplied from outside the store** | | `self_consistent` | matches this store's own keyring — real information, and not verification | | `unverifiable` | the key is unknown here; the signature may be perfectly good | | `failed` | the bytes do not verify | | `absent` | no signature: signing was not in operation | `self_consistent` exists because collapsing it into `unverifiable` would throw away a check that genuinely passed, and calling it `verified` would be the store witnessing itself. The viewer renders it in its own class — **never green** — with the sentence *"supply a trusted key to verify"* beside it. An adversarial test demonstrates the whole argument: an attacker registers their own key, re-signs a row, the store reports `self_consistent`, and an external anchor still refuses it. **Custody.** The private key is deployer-supplied and never enters the repo, the database or a receipt — asserted by a test that greps every stored value for key material. `key_id` is **derived**, a fingerprint of the public key, never assigned: a chosen label can drift from what it names and a digest cannot. **Rotation is append-only** (migration `0014`, with the same no-update/no-delete triggers as `actions_audit`): a retired key stays, because the receipts it signed must verify forever. **X-6 at enable time, not install time.** `cryptography` is a `[signed]` extra — a library-only user who never signs should not carry it. The failure that matters is a deployment that believes it signs and does not, and a hard install dependency does nothing about that, because belief comes from config. So **signing configured plus library missing means the process refuses to start**, asserted as a stated invariant. `alg` records **`ed25519` (RFC 8032) and not the library**: Ed25519's output is deterministic, so a library version in per-row evidence would assert a dependence that does not exist. The library and its pinned version are recorded once at the deployment layer — semantics in the receipt, process provenance in the register. ### Added — `ND-009`: PEP-driven resumption via `approval_ref` An enforcement point can present a reference to an approval a human already granted. Resumption is a **new** decide with a **new** `request_id` carrying the ref, and the binding is **action-equivalence**, not `request_id` — a PEP presenting it on a different request is doing the required thing. **Every failure mode behaves identically and differs only in evidence.** Expired, consumed, forged, wrong-action, not-yet-approved: all of them resolve to *not authorised*, the action re-evaluates on its own merits, and a Tier-3 action simply proposes again. A bad ref never grants — and never errors, because an error path would tell a prober whether the ref existed. The forensic difference lives in `approval_ref_status`, the seven-value evidence field, and **not one reason code was added**. **Action-equivalence is identity up to spelling** (R035 §3): same `action_type`, and params equal under the canonical rendering. Key order and `250.00` versus `250` are spelling; `250` versus `900` is not. The human saw params, so an approval for €250 cannot be spent on €900 — which effect-set equality alone would have allowed, since both share a `money.egress` label. **Single-use survives a race.** Consumption is the *first* write and its `rowcount` is the gate, inside the `BEGIN IMMEDIATE` the decision already holds. Two simultaneous resumptions yield exactly one execution, and the loser proposes: **a lost race never denies and never errors; it just does not grant.** **The kill switch still wins** after a valid ref, asserted as an invariant rather than left to emerge from check ordering — and the evidence records *both* facts, `honored` alongside the `kill_switch` denial, rather than blaming the approval. `principal_mismatch` is **reserved and never emitted**, held by a test exactly as `sender_mismatch` is. onedoor has no authenticated per-caller identity — `session_id` arrives in the same untrusted body as the ref — and scoping to it would be a control that does not control anything. The value ships so the vocabulary is complete in one increment; it starts being emitted when `ND-004`/`ND-005` provide an identity. **Found while building it:** `Decimal("250")` serialises to the JSON integer `250`, and `json.loads(..., parse_float=Decimal)` returns an **`int`** — `parse_float` never sees an integer. So the stored side of an approval carried `int` where the presented side carried `Decimal`, and equivalence reported `action_mismatch` for **every whole amount**. Safe (no grant) but wrong. Numbers now render through `canon_decimal`, and are **tagged** so a numeric `250` cannot collide with the string `"250"` — the vendored artifact's rule 4 names that trap, and here it would be permissive in the worst way: the bounds gate that refuses a string amount never runs once a ref has granted. ### Changed — the row preimage is now versioned: `onedoor/row-preimage/2` `approval_ref_status` is **hashed**, because it records *why* an approval did or did not authorise an action — flipping `expired` to `honored` is exactly the edit a chain exists to catch. Hashing a new column is a new preimage version, so `/2`. Migration `0013` also adds **`preimage_version`**, a per-row hint **excluded** from the hash and self-authenticating: the authority is the magic string inside the preimage, so a row whose hint disagrees with how it was sealed fails verification under the version it names. A lying hint produces detection, not confusion. **This ends the one-shot window.** `prev_hash` links are unaffected by a version change — each row hashes the previous row's `row_hash`, whatever produced it — so a ledger whose rows transition `/2 → /3` re-derives end to end, and future columns get future versions **on live chains**. Before the hint, a new hashed column was possible only while chaining was off everywhere and impossible for anyone who had switched it on, because the table forbids `UPDATE` and sealed rows can never be re-hashed. `/2` was the last bump that needed that window, and the boundary case is verified by a test. `sig`/`key_id`/`alg` stay excluded (a signature attests the row hash and cannot precede it) and `anchor_ref` stays excluded (X-8 anchors after re-verification, and an edited anchor fails the Merkle proof — the right detector for it). `ND-050` was deliberately **not** pre-folded: guessing its row shape to save a bump would be designing a ticket in a hurry inside another one. ### Added — `ND-010`: a permit outlives the process that issued it `service/app.py` kept pending intents in a dict and its own docstring promised `0.4` would rebuild them from the `exec_intent` row instead. Until now a restart between decide and report stranded every in-flight permit: the reservation stayed held, the deadline ran, and the reclaimer eventually voided budget for an action that may well have happened. `state.pending` is now a **query against the ledger**, and `/v1/report` looks the intent up rather than popping memory. Reconstructed permits are **the same durable rows** — no new evidence identity, no budget re-reservation — asserted by counting audit rows and cap counters across a simulated restart rather than by trusting the code path. **A rebuilt permit is its own type, and that is the design.** `rationale`, `cost_eur` and `session_id` are stored nowhere in `actions_audit`, so reconstructing an `ActionRequest` would mean passing `cost_eur=Decimal(0)` — **a default that looks like a fact**, which any later reader would take at face value. `RebuiltIntent` has no such field, so the mistake is unavailable rather than avoided, and it carries provenance references to the rows it derives from. **A wrong label on a receipt, caught before it shipped.** `report_result` hands the request to `audit.append`, which calls `frozen_params`: that returns `params_raw` verbatim, or **re-serialises when `params_raw` is None** — and only a live ingress sets `params_raw`. A post-restart result row would therefore have stamped `params_provenance = "serialized"` on bytes that arrived `received`. Not a crash and not a test failure: a quiet falsehood in the evidence, written at the moment the system is least observed. A rebuilt permit now carries the intent row's frozen bytes and its provenance, exactly as `append_expiry` has always done for reclamation rows. **A rebuilt row's `created_at` is its own write time, never backdated.** The ledger records when it *learned* a thing; a rebuilt row carrying the original's timestamp would be the ledger testifying to a moment it did not witness. `RebuiltIntent` names the other one `requested_at` and has no `created_at` at all, so a caller cannot reach for the wrong one. **Four outcomes at recovery time**, and the middle two are why it is a type rather than an `Optional`: `rebuilt`; `absent` (never permitted, or already reported — the ordinary answer); `unverifiable` (the evidence disagrees with itself — `cap_reservations` has no foreign key to `actions_audit`, so a held reservation naming a missing intent is reachable); `failed` (stored and unreadable). `/v1/report` maps them to distinct HTTP statuses: an absent intent is a client asking about nothing pending (404), while an unverifiable one is the store disagreeing with itself and is nobody's client error (500). Collapsing them would report a damaged ledger as a bad request. ### Added — `ND-001`: hash-chained audit entries Each audit row now hashes its own contents plus its predecessor's hash, so a deletion or an in-place edit breaks the chain and a walker localises the break to the row that moved. **Off until switched on** — `chain.enable()` is a deliberate, once-only, recorded act, and an upgrade alone changes nothing. **The preimage is the ticket, and it is frozen.** `docs/row-preimage.md` defines the exact bytes `row_hash` covers, written so an implementer with no access to the source can reproduce every digest from that text alone. `tests/guardrail/test_row_preimage.py` holds **a second implementation built from the document rather than from the code** — an implementation that agrees with itself has proved nothing — plus the four golden vectors R031 §1.3 named: the shift collision, absent-versus-empty, a value containing the framing's own header bytes, and a one-byte perturbation. - **Absent is a type tag, never a zero-length string.** Every field enters as an `ABSENT` tag with no payload, or `PRESENT` + an 8-byte big-endian length + the bytes. NULL and `""` differ in their **first byte**. `budget_json` NULL means *no budget was owed* and `""` would mean *a budget was produced and it was empty*; R015 makes those different facts, and this is where an adversary would look for the collapse. - **The vendored artifact carries no length-prefix dialect**, checked rather than assumed — no `struct`, no `to_bytes`, no packing anywhere in it. So the encoding is written down in full as R031 required, built on the one byte-level discipline the artifact does ratify: RFC 6962's domain-separation tags. - **A column is hashed or deliberately excluded, never neither.** A test asserts every column of `actions_audit` appears in the field order or in the exclusion table with its reason, so a future migration fails until someone classifies the new column. A column that silently fell outside the hash would be a field an attacker could edit without breaking the chain, and it would look complete in review. **Group commit is kept, not refused** (N2). The chain is stitched inside `flush` before the `executemany`. Refusing it would have made a performance feature and an integrity feature mutually exclusive, and every deployer wanting both would quietly disable the one that is harder to notice missing. **Measured consequence, stated because the decomposition first claimed otherwise:** buffering defers result rows, so the ledger's *row order* differs between the two paths and their chains differ with it. That is what group commit is. The invariant that holds — and the one the decision needs — is that **the preimage does not depend on which path wrote the row**. **Verification reports four outcomes and never averages them.** A log with an unchained prefix and an intact chain after genesis is not "verified" and not "failed"; it is both, stated per region. Rows before genesis are `absent` — they cannot be hashed retroactively because the table forbids `UPDATE`, and that is history rather than damage. A chain that is partly written is `unverifiable`. A row whose contents no longer hash to its record is `failed`, localised to itself rather than poisoning every row after it. **The viewer did not change.** `ND-051` rendered the chain block's absent state naming this ticket; `ND-001` fills the columns, `_check_chain` flips from `absent` to `verified`, and the page renders real digests with **not one line of `page.py` edited** — asserted as a test. That is what "one verification, and the viewer does not own it" was for. **Upgrading:** migration `0012` adds a `UNIQUE` index on `seq` so the database refuses a duplicate chain ordinal rather than leaving it to the walker. Index only — the chain *columns* have existed since `0007`. Existing rows are untouched and stay unchained. ### Added — `ND-051`: the receipt viewer `python -m onedoor.viewer --store --out ` reads an audit store and emits **one static, read-only HTML page**: the decision receipt as the hero object, with the checks that back it, and the tail of verdicts in the order the ledger took them. No backend, no network at view time, no dashboard — the design spec's scope fence is enforced by a test rather than by intention. **One verification, and the viewer does not own it.** The checks live in `onedoor.guardrail.receipt` and the page renders their output. The rule is structural and tested: the renderer imports no hashing module, reaches into the engine only for the verifier, and cannot construct a status from a string. Two implementations of "is this sound?" eventually disagree, and the one the user sees would be the wrong one. **Four outcomes in a user interface** — `verified`, `absent`, `unverifiable`, `failed` — and the distinction is the product rather than a technicality: - **`absent`** is *not yet in operation*. Hash-chained entries (`ND-001`) have not run, so `row_hash`, `prev_hash` and `seq` are NULL, and the chain block **says so, naming the ticket**. The reference mockup shows a digest there. Rendering one from a NULL column would have been the easiest thing in the world to do and would have been fabrication. The wording is deliberate: *not yet in operation*, never *not yet produced*, so absent-by-schedule is never readable as broken. - **`unverifiable`** is *produced and then lost* — a policy snapshot row that is gone, a chain that is half written. It renders **as loudly as an outright failure**, because a check that could not run is not a check that passed. - If verification is not sound, the page shows the **failure state and none of the receipt's values**. Not the values behind a warning: a reader copies the number and leaves the caveat behind. Both mandatory tests are **sabotage-verified in CI**, and the assertion is exact rather than "something failed": render-as-if-verified fails the failure-state property **and no other**; a fabricated digest fails the X-11 property **and no other**. A third sabotage was added unasked, because it is the likelier real mistake — nobody fabricates a digest on purpose, but somebody will format `10` as `10.00` to make a column line up, and under E8 those are the same value and different evidence. Design tokens are vendored **byte-identical** from the spec's own code fence and digest-pinned; a revised spec raises rather than silently rendering last week's palette. Every colour on the page is a token, checked; no verdict rule may use the brand accent. `--demo-store` builds a **labelled** sample store by running the real engine, never by writing audit rows by hand, and **the label travels in the store rather than on the command line** — a flag is forgotten, a row in the artifact is not. ## 0.4.1 — 2026-08-22 **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. ```yaml 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 known ``` **Correcting 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 is `scopegate`'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 **existing** `malformed`; no new wire vocabulary. The audit row records `malformed_kind='url_canonicalization'` and the `canon_schema` that 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 to `xn--ank-9cd.example.com` — visibly not `bank.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 by `socket.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_schema` and `opaque_class` to `actions_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-040` row 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.extra` exists 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-048` is untouched.** `bash -c "$(echo | base64 -d)"` carries no matchable literal; the governed effect is real and no deterministic parameter rule reaches it. Ticketed as `ND-048` so 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 `malformed` denial 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 no `audit_id`. **Present in `≤0.4.0`; found while building `ND-040` and 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 naming `malformed_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. ## 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: 1. `report_result(..., ok: bool)` is now `report_result(..., outcome: Outcome)`, and `POST /v1/report` takes `"outcome"` instead of `"ok"`. 2. Reason codes `cap_daily_rate` / `cap_eur_day` / `cap_eur_month` are gone; match on `cap_rate` / `cap_value` and read the window from the new `budget` object. 3. 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_day` **and** `cap_eur_month` → **`cap_value`**, with the window and unit moving into `ND-003`'s `budget` object rather than the code. `sender_mismatch` is **reserved and never emitted** until `ND-005` wires 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 of `cap_value` still denies correctly. **If you match on reason strings in dashboards or alerts, they change here.** - **Received params are stored verbatim; generated structures are canonicalised.** The `parse → 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.00` stays `250.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`, migration `0009`) rather than inferred — a `received` row can be re-derived against what the caller sent, a `serialized` one 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 before `0.4.0` were neither verbatim nor canonical, and inferring either for them would be inventing evidence. There is deliberately no `received_digest` column — the bytes are stored, so the digest is derivable. - **`report_result` takes a four-value `outcome`, not `ok: bool` — BREAKING for enforcement points.** `success | failure | timeout | not_attempted`, and `/v1/report` accepts the wire `outcome` field (already normative in `-00`, so this is conformance catch-up rather than a wire break). **Settlement now depends on the outcome:** `success`, `failure` and `timeout` settle the budget reservation; **`not_attempted` releases 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_attempted` and `timeout` both collapsed into `failed` and 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_ok` is now NULL rather than false for `not_attempted`, because recording false asserts an attempt that did not happen. The in-process executor reports `not_attempted` when no connector is registered — that path was charging budget for a dispatch that found nothing to call. **If you call `report_result` or `POST /v1/report`, this is the change to make.** - **Cap denials carry a machine-readable `budget` object.** Present **iff** the verdict is a denial with reason `cap_value` or `cap_rate`, on the decide response **and persisted** to `budget_json`. Seven required fields: `dimension` (`value`|`rate`), `unit` (ISO 4217 for value, a token like `calls` for 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_value` collapses the old `cap_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_at` is 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 under `aadp/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`, migration `0008`). 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 as `Decimal`, JSON ingress parses with `parse_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`, not `above max 23.0`), and **the policy content-hash changes on upgrade even if your rules did not** — `bounds_json` and `caps_json` now record decimals in canonical shortest-exact form (`100`, `100.00` and `1E+2` all record as `100` and hash identically), so an unchanged policy set gets a new `version_hash` once. Existing audit rows keep the hash they were stamped with. - **Migration `0007`** adds the `0.4.0` row format to `actions_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.loads` runs with no `parse_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 amount `500.1000000000000000001`, verdict **allowed**. The admitted excess is about half an ulp of the bound — ~5e-14 at `500.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 for `0.3.6` deployments: send money amounts as JSON *strings*** — `"500.10"` is exact end to end, because cost resolution accepts strings. Closed in `0.4.0` by parsing with `parse_float=Decimal` at every ingress and typing numeric bounds as `Decimal`. - **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.000` for 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. ## 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 vestigial `intake_policy`, `preferences` and `sessions` tables (inherited from a pre-onedoor product) are dropped by migration `0006`. `push_subscriptions` is 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 --strict` and `pytest` run on a 3.12/3.13 matrix; `ruff` is **pinned**, because an unpinned linter makes CI non-deterministic. All four now pass; none of them did before. - **`ND-036`** — `ROADMAP.md` is a pointer to the live documents rather than a stale feature list. Eleven work items that lived only in it were migrated into `BACKLOG.md` rather than deleted. - **Packaging:** a `[litellm]` extra. The LiteLLM example imported a package no extra installed, so anyone following the docs hit `ModuleNotFoundError`. - **Repository integrity:** `.gitattributes` forces LF and freezes the vendored, digest-pinned reference artifact byte-for-byte. Windows' `core.autocrlf` default 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. - **`README`** no longer advertises a stale test count; the CI badge carries it. ### Fixed - `pytest` and `python -m pytest` no longer disagree. 19 test modules import `tests.conftest`, which needs the repository root on `sys.path`; `python -m` supplies it implicitly and the bare `pytest` that 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.md` N6 / `ND-038`). A conformant PDP obligation attached to a permit would be silently ignored by onedoor's own enforcement points. - `report_result` has no outcome parameter, so `not_attempted` and `timeout` collapse into `failed` (`CONFORMANCE.md` A4b / `ND-039`). A reservation is settled before the outcome is examined, which charges budget for an action that may never have occurred. Fixed in `0.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_effects` full-matches a regex against a parameter's string form, so a pattern like `https://(pay|bank)\.example\.com/.*` does not survive contact with an adversarial URL. **Measured, not suspected:** `experiments/aliasing_benchmark.py` scores **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 what `ND-040` addresses** — 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 for `0.4.x`, immediately after `0.4.0`. Reasoned from the matcher's design rather than individually measured, the same string-matching weakness also covers IDN homographs, a `user@host` prefix, a trailing-dot host and case variation. - **The fourth evasive case is a separate gap that `ND-040` does not close.** Indirect or obfuscated command construction (`bash -c "$(echo | 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_effects` patterns 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. ## Earlier releases Reconstructed from git tags; these predate this file. | Version | Tag subject | |---|---| | 0.3.5 | integrations: onedoor as LangChain agent middleware | | 0.3.4 | guardrail: reservation reclamation (AADP §6) | | 0.3.3 | F7 — every euro cap was inert unless the caller set `cost_eur` by hand | | 0.3.2 | onedoor did not work on Windows | | 0.3.1 | release the packaging fix |