v0.8.0Pre-1.0: what changed in each release

Your agents rewrite the code. Your customers shouldn’t notice.

Write down what your service promises the people outside it. Oodle checks every pull request against those promises and tells you which held, which changed and which broke.

npm i -D @oodlc/oodle && npx oodle init --ci

For the Next.js, Express, Fastify, Koa, Hono or node:http service you already run, Postgres included. Open source, Apache-2.0, no account and no telemetry. Your first outcome in 5 minutes · What it runs on

Oodle, the OODLC mascot, waving

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.

An example outcome diff for four pull requests
Oodle, looking worried

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.

  1. 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 }]
    
  2. 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
    
  3. 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.

    StatusMeansBlocks the merge
    heldPasses, and the customer sees exactly what they saw before.No
    changedStill passes, but what the customer sees is different.Until approved
    brokenPassed on the base branch, fails on this one.Yes, and can't be approved
    failingFails here and was already failing before.Yes
    newThe base branch didn't promise this yet. It passes.No
    removedTaken out of the catalog.Until approved
    redefinedIts definition in the catalog was edited.Until approved
    proposedDrafted, usually by an agent. Runs and reports.No
  4. 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.

LayerWritten bySurvives a rewriteExample
IntentA personYesCustomers can buy without surprises
OutcomeA person declares or approves itYes, by definitionPaying shows a confirmation and sends one receipt
BehaviorOodle, by watchingNo, agents may change itCheckout 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 haveIt checksWhat Oodle adds
Unit testsFunctions 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 testsThe 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 testsThat 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 testsA 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

oodle.app.ts
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',
  },
});
.github/workflows/oodle.yml
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.

A real databaseThe Postgres exampleWhy it works this way

oodlc/config.yaml
database:
  schema: prisma/migrations
defaults:
  given:
    db:
      users: [{ id: u1, email: ada@example.com }]
oodlc/orders.yaml
  - 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 check and 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 mutate plants small bugs and lists the ones no outcome caught. With --tests it 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.
Oodle for coding agents
claude
# 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 -
oodle mutate
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.

Full CLI reference
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.