One comment on every pull request
OODLC (say “oodle-see”) is an open way to write those promises down. Oodle, its CLI, runs the base branch and your change, then posts what happened to each one.
It blocks a merge only over something a person declared. Everything else it notices, it tells you about and lets through.
Pick a pull request to see what Oodle would say.
Oodle commented on #213
Outcome diff: 1 blocking
3 held · 1 changed · 0 broken · 0 new · 0 removed · 0 redefined · 0 unknown · 0 behavior changes
- 🟡 changed (blocking)checkout.payment-confirmedcustomer · [default] body.currency added
To approve, a maintainer reviews with:
/oodle approve checkout.payment-confirmed@1a2b3c4d
The customer will see something new. That is probably fine, but someone other than the author has to say so.
The life of one promise
A checkout service promises that a customer who pays sees a confirmation and gets one receipt. Here is that promise, from the line someone writes to the review that lets a change to it through.
Someone writes it down
An outcome is a sentence about a person or system outside yours: a customer, an external caller, data you own, an obligation. It names the intent it serves, the request that sets it off and what has to be true afterwards.
It lives in
oodlc/, a plain folder of YAML at the root of the repo. Give the folder a CODEOWNERS entry and changes to it reach the people who should see them.oodlc/checkout.yaml outcomes: - id: checkout.payment-confirmed intent: buy-without-surprises statement: After a successful payment the customer sees a confirmation and gets exactly one receipt boundary: customer trigger: http: POST /checkout conditions: [first_purchase, payment_provider_slow, security.no-credentials] expect: status: 200 body: order_id: { exists: true } status: confirmed effects: - { kind: email.sent, match: { template: receipt }, count: 1 } latency_ms_max: 2000 when: security.no-credentials: status: 401 effects: [{ kind: payment.capture, count: 0 }]
Oodle runs it somewhere sealed
Oodle starts your app in process, with no port open, and tries each outcome under each condition it lists: a first purchase, a slow payment provider, a request with no credentials.
Calls to Stripe or SendGrid are answered by stubs and recorded, so the outcome can say “exactly one receipt” and mean it. A call to a host nobody named doesn't leave the process; it fails the run. If the service keeps its data in Postgres, a real one runs in the same process, reset before every run. The clock, uuids and random numbers are fixed, so the same code gives the same answer twice.
oodlc/config.yaml app: src/app.ts defaults: given: state: { customers: [{ id: c1, email: ada@example.com }] } stubs: payment.capture: result: { id: pay_1, status: succeeded } latency_ms: 120
Every pull request gets a diff of promises
On each pull request Oodle runs the base branch and the change, then sorts every outcome into one of eight piles. The comment lists them, and the check fails only if something blocks.
Things the system does that nobody promised, like an internal audit event or a health endpoint, are behaviors. When they drift, the comment says so in its own section and the check stays green.
Status Means Blocks the merge held Passes, and the customer sees exactly what they saw before. No changed Still passes, but what the customer sees is different. Until approved broken Passed on the base branch, fails on this one. Yes, and can't be approved failing Fails here and was already failing before. Yes new The base branch didn't promise this yet. It passes. No removed Taken out of the catalog. Until approved redefined Its definition in the catalog was edited. Until approved proposed Drafted, usually by an agent. Runs and reports. No A person approves the change they meant
When a change to a promise is on purpose, the comment ends with the line that approves it. A maintainer other than the author puts that line in a review, and the check runs again green with their name on it.
The approval covers that change as it stands. Push something that changes the output again and it needs approving again. A broken outcome never gets an approve line.
review on #213 /oodle approve checkout.payment-confirmed@1a2b3c4d # the comment, once the check has run again 🟡 changed ✅ approved by @mara checkout.payment-confirmed customer · [default] body.currency added
What survives a rewrite
OODLC keeps three kinds of statement apart. Which kind a statement is decides whether a rewrite is allowed to change it. To protect a behavior, a person promotes it to an outcome.
| Layer | Written by | Survives a rewrite | Example |
|---|---|---|---|
| Intent | A person | Yes | Customers can buy without surprises |
| Outcome | A person declares or approves it | Yes, by definition | Paying shows a confirmation and sends one receipt |
| Behavior | Oodle, by watching | No, agents may change it | Checkout emits internal.audit |
Constraints sit across all three: rules like “never charge without an order” that are checked on every run, including Oodle's probes of routes no outcome describes. Breaking one always blocks, and so does editing one.
Next to the tests you have
Oodle doesn't replace your test suite. It checks something none of it is built to check: what people outside the service experience, decided by a person, through every rewrite.
| You already have | It checks | What Oodle adds |
|---|---|---|
| Unit tests | Functions as they are written today. Rewrite the code and the tests get rewritten with it. | Checks from outside the service, so a rewrite has to keep the same promises. oodle mutate --tests shows which unit tests only catch what an outcome already catches. |
| Contract tests | The shape of the requests and responses between services you own. | What happened because of a request: the receipt sent, the row written, the charge made, and that nothing else was. |
| Snapshot tests | That output didn't change. Any change fails, and gets accepted by whoever updates the snapshot. | Only declared promises block, and a change to one needs someone other than the author. Everything else is reported and lets the merge through. |
| End-to-end tests | A deployed environment, through a browser or the network. | Runs in process with no port, Docker or network, with the clock and randomness fixed, so it gives the same answer twice. It doesn't cover UI yet. |
Start from the service you have
oodle init finds your HTTP app, following listen() back to the module that builds it, and writes oodle.app.ts, which hands it to Oodle's adapter. The only change it may ask of your code is that the module export the app without listening on import.
It names the hosts your service calls, from the URLs in your code and SDKs like Stripe's in package.json, and stubs each one with a placeholder. Then it calls each route in the simulation and saves what came back as proposed outcomes, so you start by promoting one instead of writing one from nothing.
oodle doctor tells you whether Oodle is running your code, whether every call it makes has a stub, whether anything escapes the simulation, and whether two identical runs agree.
The pull request that adds Oodle passes. The base branch promised nothing, so every outcome arrives as new.
Your first outcome in 5 minutesAdopting an existing serviceThe Express exampleApprovals in CI
import { httpApp } from '@oodlc/oodle/adapter'; import { app } from './src/server.ts'; export default httpApp(app, { effects: { 'POST api.stripe.com/v1/charges': 'payment.charge', 'api.sendgrid.com': 'email.sent', }, });
on: pull_request: pull_request_review: types: [submitted] jobs: outcomes: steps: - uses: actions/checkout@v7 - run: npm ci - uses: oodlc/oodle@v0
Your real database, inside the simulation
Point Oodle at your migrations and it runs Postgres in the same process as your app, compiled to WebAssembly. Your driver connects through DATABASE_URL as it does in production: pg, postgres.js, Drizzle, Kysely, Knex, Prisma. No Docker, no port, and it starts in about half a second.
Every run starts from your schema and the rows the outcome lists. Every row your app writes is recorded, so a promise can say “marks the order refunded”, and a refund that answers correctly but never writes it is broken. A new column only shows up as behavior.
Constraints see every table after every run, so “no paid order without a charge” holds on routes nobody described yet.
database: schema: prisma/migrations defaults: given: db: users: [{ id: u1, email: ada@example.com }]
- id: orders.refund-returns-money trigger: http: POST /orders/ord_9/refund given: db: orders: [{ id: ord_9, status: paid, charge_id: ch_9 }] expect: status: 200 effects: - { kind: payment.refund, count: 1 } - { kind: db.orders.updated, match: { status: refunded }, count: 1 }
When an agent is writing the code
An agent needs something to aim at that stays put while it rewrites everything else. The catalog is that target, and the agent can't move the parts of it that block a merge.
- Agents can only propose
- An agent writes a new outcome with
status: proposed. It runs and shows up in the diff, but blocks nothing until you delete that line. No agent tool can edit an outcome that has been approved. - It can't finish with something broken
- When Claude Code tries to stop, the plugin runs
oodle checkand sends it back to work while an outcome it broke is still broken. Anything only you can approve comes to you. - You find out which tests still matter
oodle mutateplants small bugs and lists the ones no outcome caught. With--testsit also shows which of your unit tests only catch what the outcomes already catch.- It gets probed like an attacker would
- Built-in conditions send requests with no credentials, injection payloads, extra privileged fields and replays. New routes are probed with them, and every constraint has to hold each time. Request input that ends up inside SQL is refused before it runs, and blocks the merge.
# hooks, MCP tools and a skill, in one plugin > /plugin marketplace add oodlc/oodle > /plugin install oodle@oodlc # or wire up any agent by hand $ claude mcp add oodle -- npx oodle mcp $ oodle draft brief.md | claude -p | oodle propose -
Survived no outcome or constraint noticed these bugs ✘ src/checkout.ts:9 literal 404 → 405 ✘ src/checkout.ts:29 statement removed customer.orders = … What caught them checkout.payment-confirmed 26 bugs · 8 unique checkout.payment-declined 21 bugs · 3 unique ▲ 65% of planted bugs caught 14 survived
The commands
Run from anywhere in the repo; Oodle walks up to the nearest oodlc/. Add --json and it prints exactly one JSON document, errors included. Exit codes: 0 ok, 1 blocking, 2 could not run.
- oodle run
- Run every outcome and behavior under every condition
- oodle check
- Outcome diff against a git ref. --approve accepts an intended change
- oodle diff
- Outcome diff between two checkouts
- oodle lint
- Validate the catalog and that every outcome traces to an intent
- oodle init
- Wrap the service already in this repo, stub its calls, find its database, propose a first catalog. --ci adds the workflow
- oodle doctor
- Confirm Oodle runs your code, the schema applies, every call is stubbed, nothing escapes, two runs agree
- oodle mutate
- Plant small bugs and see which ones the catalog catches
- oodle propose
- Add drafted entries as proposals, never touching existing ones. --routes drafts one per route
- oodle draft
- Print the prompt that drafts outcomes from a brief
- oodle mcp
- Serve Oodle to coding agents over MCP
Before you install
Runs on
- Node.js 20.11 or later, any package manager
- Next.js 15 and 16 route handlers, Express, Fastify, Koa, Hono, node:http
- Postgres, simulated in process with PGlite: pg, postgres.js, Drizzle, Kysely, Knex, Prisma
- Any CI that runs oodle check; comments and approvals on GitHub Actions
Not yet
- Services in other languages: Python, Go, Ruby, Java
- MySQL, SQLite, MongoDB, Redis
- Triggers other than HTTP: events, queues, schedules, gRPC
- UI outcomes, including Next.js pages and server actions
- Several services checked together
What leaves your machine
- Nothing. No account, no API key, no telemetry.
- Your app, its database and its stubs run inside the Oodle process, on your laptop or CI runner.
- The simulation is sealed: a call to the real network fails the run instead of going out.
- In GitHub Actions it talks to GitHub's API alone, with the workflow's own token, to read reviews and post the comment.
Oodle is v0: the catalog format and the CLI can still change between minor versions, and the changelog says when they do. The GitHub Action follows the newest release through @v0.