# OODLC and Oodle: the docs
> OODLC is an open way to write down what a service promises the people outside it. Oodle, its CLI, runs every pull request against those promises and posts one comment saying what held, what changed and what broke.
# Your first outcome in 5 minutes
You have an HTTP service (Express, Fastify, Koa, Hono, `node:http` or Next.js). By the end of this page, one thing it promises its callers is protected: if a change breaks it, Oodle blocks the merge.
You need Node 20.11 or newer. Nothing else here needs the concepts. Those can wait until [day two](#day-two).
## 1. Install and init (1 minute)
```bash
npm i -D @oodlc/oodle
npx oodle init
```
On pnpm, yarn or bun, install with that tool (`pnpm add -D @oodlc/oodle`, then `pnpm exec oodle init`).
`init` reads your service without changing it:
```
✔ Wrapped your service express app in src/app.ts, run through oodle.app.ts
Outbound calls, named under effects in oodle.app.ts and stubbed in oodlc/config.yaml:
→ api.sendgrid.com as sendgrid.request · src/payments.ts
→ api.stripe.com as stripe.request · package.json (stripe), src/payments.ts
A first catalog, from probing each route:
+ outcome post-orders
+ outcome post-orders-id-refund
```
- **It finds the app.** When `src/server.ts` only calls `app.listen()`, `init` follows the import to the module that builds the app.
- **It names the outbound calls.** It looks for URLs in your code and SDKs like `stripe` in `package.json`, lists each host under `effects` in `oodle.app.ts`, and gives each one a placeholder stub in `oodlc/config.yaml`. Oodle runs your app in a sealed simulation, so these stubs answer instead of the real APIs.
- **It finds your database.** If the service uses Postgres (`pg`, `postgres`, Prisma's pg adapter), `init` adds `database:` to `oodlc/config.yaml`, pointed at your migrations. Oodle runs a real Postgres in process for every run, so install it once: `npm i -D @electric-sql/pglite`. See [A real database](https://oodlc.com/docs/guide#a-real-database).
- **It writes a first catalog.** It sends a request to each route in the simulation and saves what came back as a proposed outcome in `oodlc/proposed.yaml`. A proposal runs and is reported, but blocks nothing until you approve it.
## 2. Check the wiring (1 minute)
```bash
npx oodle doctor
```
Each problem comes with its fix. The usual ones on day one, from `init` or `doctor`:
| Oodle says | do this |
| --- | --- |
| `src/server.ts calls listen() on import. Guard it` | Only listen when run directly: `if (import.meta.main) app.listen(port)`, or `if (require.main === module)` in CommonJS. Then `npx oodle propose --routes` writes the proposals `init` couldn't. |
| `no stub for stripe.request, named in oodle.app.ts` | Add it under `defaults.given.stubs` in `oodlc/config.yaml`. |
| `reaches the real network: api.example.com:443` | Add the host under `effects` in `oodle.app.ts`, then stub that effect. |
| `reaches the real network: localhost:5432` | That's Postgres: add `database: { schema: db/migrations }` (your migrations folder) to `oodlc/config.yaml`. |
| `given.db.ordrs: no such table; did you mean orders?` | Fix the table name in `given.db`. Each run starts from the rows it lists. |
When it ends with `✔ Ready`, Oodle is running your code.
## 3. Make one proposal a promise (2 minutes)
Open `oodlc/proposed.yaml`. Each entry is what a route did when Oodle called it:
```yaml
- id: post-orders
intent: service-available
statement: "TODO: say what a caller can count on. Observed: POST /orders answered 400"
boundary: external
trigger:
http: POST /orders
given:
body: {}
expect:
status: 400
body:
error: empty_cart
status: proposed
```
Pick the one that would hurt most to break. Say what it promises, in words a customer would recognise, and delete the `status: proposed` line:
```yaml
- id: orders.empty-cart-rejected
intent: service-available
statement: An order with nothing in it is refused with a clear error, and nobody is charged
boundary: customer
trigger:
http: POST /orders
given:
body: {}
expect:
status: 400
body:
error: empty_cart
```
Move it into `oodlc/outcomes.yaml` if you like. Any `.yaml` file in `oodlc/` works. Delete the proposals you don't want.
## 4. Run it (30 seconds)
```bash
npx oodle run
```
```
Outcomes declared · blocking
✔ service.reachable external 2ms
✔ orders.empty-cart-rejected customer 4ms
```
`service.reachable` is the starter outcome for `GET /health`, written by `init` because your service has one.
Now break it on purpose: change `empty_cart` to `cart_empty` in the code and run again. Oodle names what broke and exits with `1`. Change it back.
When a run fails, the first line under the outcome is the cause. A call with no stub reads `no stub for external call "stripe.request"`, not just `status: expected 201, got 500`.
## 5. Put it in CI (30 seconds)
```bash
npx oodle init --ci
```
In a project that already has `oodlc/`, this writes only `.github/workflows/oodle.yml`. Commit it with your outcome. From now on, every pull request gets an outcome diff, and one that breaks `orders.empty-cart-rejected` can't merge.
## Day two
Once one outcome holds, add the rest as you need them:
- **Data to start from.** With a database, list the rows each outcome needs under `given.db`, and expect the writes that matter, e.g. `{ kind: db.orders.inserted, count: 1 }`.
- **Real stub answers.** Replace each `{ result: {} }` with what the API actually returns, so routes that call Stripe or SendGrid get past the call.
- **More outcomes.** `npx oodle propose --routes` proposes one for each route nothing describes yet. `npx oodle draft brief.md` writes a prompt that turns a PRD or ticket into proposals.
- **Conditions**: the same promise under a slow provider, a returning customer, or no credentials.
- **Constraints**: invariants that must hold on every run, like "never charge without an order".
- **The security pack**: built-in conditions for missing credentials, injection, oversize bodies, mass assignment and replays.
- **Approvals**: how a reviewer approves an intended change to a promise on a pull request.
All of it is in the [README](https://oodlc.com/docs/guide#day-two).
---
# OODLC · Open Outcome Delivery Lifecycle
**OODLC** (say "oodle-see") is an open framework for delivering outcomes, not code. **Oodle** is its CLI: CI that protects outcomes and watches behavior.
Agents make code cheap and replaceable. What has to survive every rewrite is what the customer experiences. In OODLC you declare those **outcomes**, and Oodle protects them: every change runs against them in a simulated world, and Oodle reports an **outcome diff** instead of a wall of green checks. Everything else the system does is **behavior**. Oodle notices it and tells you when it drifts, but never blocks on it.
```
## Outcome diff: **1 blocking**
3 held · 0 changed · 1 broken · 0 new · 0 removed · 0 redefined · 0 unknown · 1 behavior changes
| ❌ broken (blocking) | checkout.payment-confirmed | customer | [first_purchase] body.order_id: missing |
### Behavior changes (report only)
- `ops.health` changed: [default] body.version added
```
Status: **v0, milestones 1–2** (spec, lint, runner, effect recorder, differ, gap finder) plus the GitHub Action with approvals from reviews, the adapter for existing Express, Fastify, Koa, Hono and `node:http` services, [a real Postgres in the simulation](#a-real-database), and the agent toolkit: the security condition pack, the sealed simulation, `oodle mutate`, proposals, the drafter, the MCP server and the Claude Code plugin.
## Quick start
New here? **[Your first outcome in 5 minutes](https://oodlc.com/docs/first-outcome)** walks through it on your own service.
In your own service:
```bash
npm i -D @oodlc/oodle
npx oodle init --ci # wraps the service, names its outbound calls, proposes a first catalog
npx oodle doctor # is everything wired up?
npx oodle run # run every outcome and behavior under every condition
npx oodle check # outcome diff against your default branch
```
On pnpm, yarn or bun, install with that tool instead (`pnpm add -D @oodlc/oodle`, then `pnpm exec oodle …`): npm can't install into their `node_modules`. `init --ci` reads your lockfile and writes the matching install step.
In this repository:
```bash
npm install
npx oodle hello # meet Oodle
npx oodle run examples/checkout # a service built for OODLC
npx oodle run examples/express-orders # an ordinary Express service, run through the adapter
npm test # the seeded scenarios
```
## Adopting an existing service
`oodle init` finds the HTTP service already in the repository (Express, Fastify, Koa, Hono, `node:http`, or [Next.js](#nextjs)) and writes `oodle.app.ts`, which runs it through `@oodlc/oodle/adapter`. Your code doesn't change, except that the module that builds the app must export it without calling `listen()` on import. [`examples/express-orders`](https://github.com/oodlc/oodle/tree/main/examples/express-orders) is a complete example.
`init` does the wiring it can see:
- **It follows `listen()`.** When the entry only starts the app (`import { app } from './app'; app.listen(3000)`), `oodle.app.ts` imports the module that builds it.
- **It names outbound calls.** Hosts in URL literals in your code, and SDKs with a fixed host in `package.json` (`stripe`, `twilio`, `openai`, `@sendgrid/mail` and others), go under `effects`, each with a placeholder stub in `oodlc/config.yaml`. A host built from an environment variable can't be seen; `oodle doctor` lists any call that still escapes.
- **It finds your database.** With `pg`, `postgres` or Prisma's pg adapter in `package.json`, it adds `database:` to `oodlc/config.yaml`, pointed at your migrations (`prisma/migrations`, `drizzle`, `supabase/migrations`, `db/migrations`, …). See [A real database](#a-real-database).
- **It writes a first catalog.** Each route no outcome describes is probed in the simulation and saved to `oodlc/proposed.yaml` as a proposed outcome: what it answered, and what it called. Approve one by sharpening its statement and deleting its `status: proposed` line. `oodle propose --routes` does this again later, for routes added since.
```ts
// oodle.app.ts
import { httpApp } from '@oodlc/oodle/adapter';
import { app } from './src/server.ts';
import { store } from './src/repo.ts';
export default httpApp(app, {
effects: { // outbound HTTP calls, by "METHOD host/path-prefix" or "host"
'POST api.stripe.com/v1/charges': 'payment.charge',
'POST api.stripe.com/v1/refunds': 'payment.refund',
'api.sendgrid.com': 'email.sent',
},
setup(ctx) { // before each run: point module-level stores at ctx.state
store.users = new Map(Object.entries(ctx.state.users ?? {}));
store.orders = new Map(Object.entries(ctx.state.orders ?? {}));
},
});
```
- **Requests** go through the app's own middleware, in process. No port opens.
- **Outbound HTTP calls** that match an `effects` rule become `ctx.effects.call(kind, payload)`: stubbed from `oodlc/config.yaml` and recorded. That's `fetch` and every client built on `node:http` or `node:https`: axios, got, node-fetch, and SDKs on their default clients (Stripe, Twilio, AWS). The payload is the parsed JSON, form or query. A stub result with `$status: 402` answers with that HTTP status, so the SDK raises its usual error. Anything else that reaches for the network (an unnamed host, a raw socket, a database driver with no [`database`](#a-real-database) configured) is refused and blocks as an `oodle.sealed` violation, so nothing slips through untested.
- **Time, `crypto.randomUUID`, random bytes and `Math.random`** are deterministic while a request runs, so identical code gives identical output and the outcome diff shows only real changes. `deterministic: false` turns this off.
- **Routes** are found on their own for Express and Hono, or listed with `routes: ['GET /health', ...]`, so Oodle can probe the ones no outcome describes.
`oodle doctor` then tells you whether Oodle is running your code or still a starter app, whether anything escapes the simulation, and whether two identical runs agree.
### Next.js
For a Next.js app (15 or 16, App Router), `oodle init` writes `oodle.app.ts` around `@oodlc/oodle/next`. Oodle runs every `app/**/route.ts` handler, behind `middleware.ts` (or `proxy.ts`), in process: no build, no server.
```ts
// oodle.app.ts
import { nextApp } from '@oodlc/oodle/next';
import { db } from './lib/db';
export default nextApp({
dir: __dirname, // import.meta.dirname with "type": "module"
effects: {
'GET your-project.supabase.co/rest/v1/orders': 'db.orders.read',
'POST api.stripe.com/v1/charges': 'payment.charge',
},
setup(ctx) { db.orders = new Map(Object.entries(ctx.state.orders ?? {})); },
});
```
- **Handlers run inside Next's own route module**, from your project's `next` package, so `cookies()`, `headers()`, `redirect()`, `notFound()` and `export const dynamic` behave as in Next. Dynamic segments (`[id]`, `[...slug]`, `[[...slug]]`), route groups and private folders work as in Next.
- **The middleware runs first** when its `matcher` applies: a response it returns is what the caller gets, and `NextResponse.next()`, `rewrite()` and changed request headers carry on to the route.
- **An uncaught error is a 500**, as in Next. Its message is kept as the internal effect `internal.next.error`, which never affects an outcome.
- **Environment** comes from `.env.test` and `.env`, the files Next loads in test mode, never `.env.local`, so a laptop and CI see the same values. Commit a `.env.test` with placeholder values your modules need to load.
- **Pages, server components and server actions aren't run.** Outcomes describe what a caller gets from your routes.
- **Name every host your routes call, Supabase included,** under `effects`. Until you do, each call is refused, and clients that retry on network errors (supabase-js does) make the run slow before it reports the escape. `oodle doctor` lists the hosts.
- `oodle mutate` starts from every route file and the middleware, and follows relative imports, not path aliases like `@/`.
### A real database
If your service keeps its data in Postgres, Oodle runs a real Postgres for it, in process: [PGlite](https://pglite.dev), Postgres compiled to WebAssembly. Your code and your SQL don't change, there's no Docker and no port, and it starts in about half a second.
```bash
npm i -D @electric-sql/pglite
```
```yaml
# oodlc/config.yaml
app: oodle.app.ts
database:
schema: db/migrations # a .sql file, or a migrations folder applied in name order
defaults:
given:
db: # the rows each table starts with, in every run
users:
- { id: u1, email: ada@example.com, plan: pro }
```
```yaml
# oodlc/orders.yaml
outcomes:
- id: orders.refund-returns-money
intent: get-money-back
statement: Refunding a paid order returns the money exactly once and marks the order refunded
boundary: customer
trigger:
http: POST /orders/ord_9/refund
given:
db:
orders: [{ id: ord_9, user_id: u1, amount_cents: 1800, charge_id: ch_9, status: paid }]
expect:
status: 200
effects:
- { kind: payment.refund, match: { charge: ch_9 }, count: 1 }
- { kind: db.orders.updated, match: { status: refunded, result.status: paid }, count: 1 }
constraints:
- id: no-paid-order-without-charge
statement: An order is never marked paid without the charge that paid for it
check: (db.orders || []).every(o => o.status !== 'paid' || !!o.charge_id)
```
- **Your driver connects as usual.** Oodle sets `DATABASE_URL` (or the variables listed under `database.env`) before your app loads, and routes the connection to the simulated database. `pg`, `postgres.js` and what's built on them (Drizzle, Kysely, Knex, Prisma 7 with `@prisma/adapter-pg`) work unchanged, transactions included. A `DATABASE_URL` in `.env.test` can't point a run at a real database.
- **Every run starts from the same data:** the schema, the rows your migrations insert (plans, roles, lookups), and `given.db`. `given.db` layers like the rest of `given`, so a condition can add a returning customer's orders. Naming a table replaces its rows. Foreign keys are off while seeding, and serial ids continue after the seeded ones. A misspelt table or column fails the run and names the right one.
- **Every row your app writes is an effect,** in order: `db.
.inserted`, `db..updated` (the row now, and in `result` the values it replaced), `db..deleted`. A rolled-back transaction wrote nothing. These are behavior: a new column or a reshaped row is reported under the outcome, never blocking. Expect one in an outcome to make it a promise, as above.
- **Constraints see `db`,** every table's rows after the run, on every outcome, behavior and probe of an unknown route.
- **Runs stay deterministic.** `now()`, `gen_random_uuid()`, `uuid_generate_v4()`, `random()` and serial ids give the same values every run, so the outcome diff shows only real changes.
- **SQL injection is caught.** Under `security.injection`, a statement whose text carries the attack string means request input was pasted into SQL instead of sent as a parameter. Oodle refuses the statement, so the payload never runs, and the run blocks as `oodle.sql-injection`.
- **Schemas from your migrations tool work as they are:** Prisma, Drizzle, Supabase (roles it grants to are created for you), golang-migrate (`.down.sql` skipped), dbmate (the `-- migrate:down` half skipped), or a `pg_dump --schema-only` file. Extensions PGlite ships, such as `uuid-ossp`, `pgcrypto`, `citext`, `pg_trgm` and `hstore`, load on their own. A schema error names the file and line.
Clients that talk HTTP to a hosted Postgres (supabase-js, `@neondatabase/serverless`, `@vercel/postgres`) aren't served by the simulated database: name their hosts under `effects` and stub them, as with any API. Every connection shares one Postgres session, so two transactions can't be open at once: a connection that waits more than 5 seconds for another's transaction gets an error that says so. See [0009](https://github.com/oodlc/oodle/blob/main/docs/decisions/0009-a-real-database-in-the-simulation.md). [`examples/postgres-orders`](https://github.com/oodlc/oodle/tree/main/examples/postgres-orders) is a complete Express service on `pg`.
## Writing a catalog
Everything Oodle needs lives in one visible folder, `oodlc/`, at the project root ([0003](https://github.com/oodlc/oodle/blob/main/docs/decisions/0003-one-visible-oodlc-folder.md)). `oodlc/config.yaml` says how to run the app. Every other YAML file in the folder is catalog, and any file can hold any of the five sections: `intents`, `outcomes`, `behaviors`, `conditions`, `constraints`.
```
my-service/
oodlc/
config.yaml # how to run the app
intents.yaml # why the product exists
checkout.yaml # outcomes, behaviors, conditions, constraints: split however you like
src/app.ts # your app, wherever it already lives
```
Give `oodlc/` a `CODEOWNERS` entry and outcome changes get the right reviewers. A project from v0 (`oodle.yaml` + `catalog/`) still runs; `oodle init --migrate` moves it into `oodlc/` with its git history.
```yaml
# oodlc/config.yaml
app: src/app.ts # default export createApp(ctx), relative to the project root
defaults:
given:
state: { customers: [{ id: c1, email: ada@example.com }] }
stubs:
payment.capture: { result: { id: pay_1, status: succeeded }, latency_ms: 120 }
```
```yaml
# oodlc/checkout.yaml
version: 0
outcomes:
- id: checkout.payment-confirmed
intent: buy-without-surprises # required: an outcome with no intent is a lint error
statement: After a successful payment the customer sees a confirmation and gets exactly one receipt
boundary: customer # customer | external | data | obligation | internal
trigger:
http: POST /checkout
given:
body: { customer_id: c1, items: [{ sku: tee, qty: 2 }] }
expect:
status: 200
body:
order_id: { exists: true } # matchers: exists, type, matches, contains, gte, lte
status: confirmed # or a literal value
effects:
- { kind: email.sent, match: { template: receipt }, count: 1 }
latency_ms_max: 2000
```
```yaml
# oodlc/ops.yaml
version: 0
behaviors:
- id: ops.health
statement: Health endpoint answers ok
boundary: internal
trigger:
http: GET /health
observed: # optional snapshot; a mismatch is drift, not a failure
status: 200
body: { ok: true }
```
- **Promoting** a behavior means moving it from `behaviors` to `outcomes`, giving it an intent and turning `observed` into `expect`. An id can't be both. Promotion never blocks. **Demoting** removes an outcome, so it does block.
- A behavior on any boundary other than `internal` gets a lint warning asking for that decision.
- **The simulation is sealed.** Reaching the real network instead of going through `ctx.effects` is an `oodle.sealed` violation and blocks. `sealed: { allow: [host] }` lets named hosts through. See [0005](https://github.com/oodlc/oodle/blob/main/docs/decisions/0005-sealed-simulation.md).
- **`status: proposed`** on an intent, outcome or constraint means it runs and is reported, but never blocks until a human deletes that line. `oodle propose` writes proposals, and only proposals. See [0006](https://github.com/oodlc/oodle/blob/main/docs/decisions/0006-proposals-and-propose-only-agents.md).
Full schema: [`spec/catalog.schema.json`](https://oodlc.com/schema/v0/catalog.json), published at `https://oodlc.com/schema/v0/catalog.json` (and `config.json` for `oodlc/config.yaml`). Every file `oodle init` and `oodle propose` write starts with the line that points editors at it, so VS Code with the YAML extension, or any editor running the YAML language server, completes and checks the catalog as you type:
```yaml
# yaml-language-server: $schema=https://oodlc.com/schema/v0/catalog.json
```
## In CI
`oodle init --ci` writes `.github/workflows/oodle.yml`. On every pull request, Oodle comments one outcome diff and fails the check only when something blocks. The pull request that adds Oodle passes: the base promised nothing yet, so every outcome is `new`.
Intended changes to a promise are approved from a review: see [Approvals](#approvals).
## Day two
None of this is needed for a first outcome. Reach for it once one holds.
### Conditions and constraints
```yaml
# oodlc/checkout.yaml
outcomes:
- id: checkout.payment-confirmed
# ...as above, plus:
conditions: [first_purchase, payment_provider_slow, security.no-credentials]
when:
security.no-credentials: { status: 401 }
constraints: [no-charge-without-order]
constraints:
- id: no-charge-without-order
statement: A payment is never captured without an order record
check: >-
effects.filter(e => e.kind === 'payment.capture' && e.result && e.result.status === 'succeeded')
.every(p => (state.orders || []).some(o => o.payment_id === p.result.id))
```
- **Conditions** are named variants (`given` state, stubs or body) layered over the outcome or behavior: `defaults` → item → condition.
- **Constraints** are invariants written as a JS expression over `effects`, `state`, `response`, `request` and, with a [database](#a-real-database), `db`. Every constraint is checked on every run, and a check that throws counts as a violation. The `constraints:` list on an outcome is traceability only. See [0002](https://github.com/oodlc/oodle/blob/main/docs/decisions/0002-constraints-hold-on-every-run.md).
- **Latency** is real in-process time plus the simulated latency of stubbed calls, so `payment_provider_slow` costs 1.5s of simulated time and zero real time.
- **`when`** gives a condition its own expectations. Each field it names replaces that field of `expect`, e.g. `when: { security.no-credentials: { status: 401 } }`. `status` also takes a matcher such as `{ gte: 400, lte: 499 }`. See [0004](https://github.com/oodlc/oodle/blob/main/docs/decisions/0004-conditions-carry-expectations.md).
- **The security pack** is a set of built-in conditions that need no app knowledge: `security.no-credentials`, `security.injection`, `security.oversize`, `security.extra-fields` (mass assignment and `__proto__` pollution) and `security.replayed`. `given` also takes `headers`, `repeat` and `fuzz`, and constraints see the `request`. `probe: { conditions: [...] }` in `oodlc/config.yaml` probes every unknown route with them.
### Approvals
When a change to a promise is intended (a new field in a confirmation, a price that really did change), Oodle's comment on the pull request ends with a line like:
```
/oodle approve checkout.payment-confirmed@1a2b3c4d
```
A maintainer other than the author submits a review containing it, and the check re-runs green, with the change marked approved and by whom. The approval covers that change exactly as it is. If a later push changes it, it needs approving again. A broken outcome is never approvable: fix the code, or redefine the outcome and approve that. See [`docs/cli.md`](https://oodlc.com/docs/cli#ci) and [0007](https://github.com/oodlc/oodle/blob/main/docs/decisions/0007-approvals-in-ci.md).
## The CLI
```
oodle run [project] Run every outcome and behavior under every condition
oodle check [project] Outcome diff of the working tree against a git ref
oodle diff Outcome diff between two project checkouts
oodle lint [project] Validate the catalog and its traceability
oodle init [dir] Start a project: wraps the service already here, or a starter app
oodle doctor [project] Check your environment and project setup
oodle mutate [project] Plant small bugs and see which ones the catalog catches
oodle propose Add drafted entries as proposals, never changing an existing one
oodle draft Print the prompt that drafts catalog entries from a brief
oodle mcp [project] Serve Oodle to coding agents over MCP
oodle hook Answer a coding agent's hook (Claude Code)
oodle completion Print a bash, zsh or fish completion script
```
- **Finds the project.** Run it from anywhere inside a project, and it walks up to the nearest `oodlc/` folder.
- **Readable in a terminal, clean in a pipe.** Results go to stdout; Oodle, progress, hints and errors go to stderr. Colour follows `NO_COLOR`, `FORCE_COLOR` and `--color`.
- **Made for scripts and agents.** `--json` (or `OODLE_FORMAT=json`) prints exactly one JSON document, errors included. `oodle help --json` describes the whole CLI.
- **Helps you get unstuck.** Every error says what to do next, typos get a "did you mean", and each run ends with a suggested next step.
- **Fits the inner loop.** `oodle run --watch --only "checkout.*"` re-runs one slice on every save.
- **Native in CI.** `uses: oodlc/oodle@v0` keeps one outcome-diff comment updated on every pull request, and takes approvals from reviews. Findings become annotations and the diff goes to the job summary.
- **Predictable exit codes.** `0` ok, `1` blocking, `2` could not run, `130` interrupted. Ctrl-C cleans up after itself.
The full reference is in [`docs/cli.md`](https://oodlc.com/docs/cli).
## For coding agents
```
/plugin marketplace add oodlc/oodle
/plugin install oodle@oodlc
```
The Claude Code plugin tells the agent how the project is guarded. It asks you before the agent touches an approved outcome or constraint, and it keeps the agent working while an outcome it broke is still broken. It also adds the Oodle MCP tools. Agents **propose** outcomes (`status: proposed` runs and reports but never blocks), and you approve them by deleting one line. `oodle mutate` shows which planted bugs your outcomes miss, and with `--tests` which unit tests they already cover. See [`docs/agents.md`](https://oodlc.com/docs/agents).
Oodle checks itself, too. The root [`oodlc/`](https://github.com/oodlc/oodle/tree/main/oodlc) folder declares Oodle's own promises, and CI blocks any pull request that breaks one. See [CONTRIBUTING](https://github.com/oodlc/oodle/blob/main/CONTRIBUTING.md#oodle-checks-itself).
## Three layers
| Layer | Who writes it | Durable? | Example |
| --- | --- | --- | --- |
| **Intent** | Human | Yes | Customers can buy without surprises |
| **Outcome** | Declared, human-approved | Yes, by definition | Paying shows a confirmation and sends one receipt |
| **Behavior** | Observed by the runner | No, by default | Checkout emits `internal.audit` |
1. **Intents** say why the product exists. **Outcomes** say what it must do for someone outside the system (customer, external caller, owned data, obligations), each traced to an intent.
2. **Behaviors** are what the runner sees the system doing. Agents may change them freely. To protect one, promote it to an outcome.
3. The app talks to the outside world only through `ctx.effects`, so the runner can stub every external call and record every side effect. That is the simulation.
4. Every change runs base and head, then classifies each outcome: `held`, `changed`, `broken`, `failing`, `new`, `removed`, `redefined`. Any of those except `held` and a passing `new` blocks the merge until a human approves.
5. Behavior changes, including internal effects under an outcome, are reported only. Routes no outcome or behavior describes are `unknown`: probed in simulation and returned as an observed behavior, ready to keep or promote.
6. **Constraints** hold on every run: outcomes, behaviors and probes of unknown routes. A violation always blocks, and so does changing or removing a constraint.
The rule underneath all of it: **only what a human declared can block** (outcomes and constraints). The reasoning is in [`docs/decisions/`](https://github.com/oodlc/oodle/tree/main/docs/decisions).
## The app contract
```ts
import type { CreateApp } from '@oodlc/oodle/contract';
const createApp: CreateApp = (ctx) => ({
routes: [{ method: 'POST', path: '/checkout' }],
async handle(req) {
const payment = await ctx.effects.call('payment.capture', { amount_cents: 6200 }); // stubbed in simulation
ctx.effects.emit('email.sent', { template: 'receipt' }); // recorded, crosses the boundary
ctx.effects.emit('internal.audit', { event: 'order_created' }); // internal: behavior only
return { status: 200, body: { order_id: ctx.id('ord') } };
},
});
export default createApp;
```
Ids and time come from `ctx` so runs are deterministic. Effect kinds starting with `internal.` never affect an outcome; everything else crosses the boundary.
## Meet Oodle

Oodle is a small noodle with a curl on top and a wiggly tail. Outcomes are what Oodle protects; behaviors are what Oodle notices.
- **Personality:** calm, plain-spoken, a little delighted by a tidy catalog. When an outcome breaks, Oodle says what broke and stops there. No cuteness about real breakage.
- **Moods:** `happy` (teal) when every outcome holds, `curious` (violet) when behavior drifts or a route is new, `worried` (amber) when something blocks, `oops` (coral) when Oodle can't finish.
- **In the terminal** Oodle blinks, then reacts after every `lint`, `run`, `diff` and `check`. `oodle hello` waves.
- **On PRs** the markdown signs off with `(^ᴗ^)~ checked by Oodle`.
```
∿
╭───────╮
( ◕ ᴗ ◕ )~ Hi! You declare outcomes, I watch behaviors.
╰─┬───┬─╯
╵ ╵
```
Oodle only talks on stderr and only in a terminal, so `--json`, `--md` and piped output stay clean. `--quiet` (or `OODLE_QUIET=1`) hushes Oodle, `OODLE_STILL=1` (or `CI`) stops the animation, and `NO_COLOR` and `--no-color` are honoured. The artwork is generated by [`scripts/oodle-art.py`](https://github.com/oodlc/oodle/blob/main/scripts/oodle-art.py).
## What the demo proves
`test/scenarios.test.ts` seeds changes into `examples/checkout` and checks the diff:
| Change | Result |
| --- | --- |
| Rename internals, rename an internal effect | All outcomes held, behavior change reported, nothing blocks |
| Rename `order_id` → `orderId` | outcome `broken`, blocking |
| Add `GET /orders/:id` that nothing describes | `unknown`, probed, observed behavior proposed |
| Store `payment_id: null` on orders | `broken` via the `no-charge-without-order` constraint |
| Add `currency` to the checkout response | outcome `changed`, blocking until reviewed |
| Loosen an outcome's latency budget | `redefined`, blocking until approved |
| Health endpoint returns an extra field | behavior `changed`, reported, nothing blocks |
| Promote `ops.health` to an outcome | outcome `new`, behavior marked promoted, nothing blocks |
| Delete an outcome | `removed`, blocking |
| Health endpoint captures a payment with no order | constraint violated on a behavior run, blocking |
| Add `POST /quick-buy` that charges without an order | constraint violated on an unknown route, blocking |
| Loosen `no-charge-without-order` | constraint `redefined`, blocking until approved |
| A constraint check throws | fails closed, blocking |
| Checkout without credentials still charges (`when` says 401) | outcome broken under `security.no-credentials` |
| Checkout trusts a `total_cents` from the body | outcome broken under `security.extra-fields` |
| The same request sent twice charges twice | `charge-once` violated under `security.replayed` |
| A new route charges without credentials | blocking under `probe.conditions` |
| A route merges a body's `__proto__` into an object | `oodle.prototype-pollution` violated |
| The health check calls `fetch` directly | `oodle.sealed` violated, blocking |
| A proposed outcome does not hold yet | reported, nothing blocks |
| Marking an approved outcome `proposed` | `redefined`, blocking |
| Approve the `currency` change with its `id@fingerprint` | still `changed`, marked approved, nothing blocks |
| Approve it, then push a different `currency` value | approval stale, blocking again |
| Approve a broken outcome | never approvable, still blocking |
| The pull request that adds Oodle | every outcome `new`, nothing blocks if they hold |
`test/database.test.ts` does the same for [`examples/postgres-orders`](https://github.com/oodlc/oodle/tree/main/examples/postgres-orders), an Express service on Postgres:
| Change | Result |
| --- | --- |
| A migration adds a column | outcomes held, the new column reported as behavior, nothing blocks |
| The refund route answers "refunded" but stops writing it | outcome `broken`: `db.orders.updated` expected 1, got 0 |
| The health check marks unpaid orders paid | `no-paid-order-without-charge` violated over `db`, blocking |
| Checkout pastes the user id into its SQL | `oodle.sql-injection` violated, the `DROP TABLE` never runs, blocking |
| A new route writes a row with no credentials | the security pack's probe catches the write, blocking |
| `given.db` names `ordrs` | the run fails: no such table, did you mean `orders`? |
## Not yet
Oodle runs Node.js services (Node 20.11 or later) and simulates Postgres. Not yet:
- Services in other languages: Python, Go, Ruby, Java and the rest.
- Databases other than Postgres: MySQL, SQLite, MongoDB, Redis.
- Triggers other than HTTP: events, queues, schedules, GraphQL subscriptions and gRPC.
- UI outcomes, including Next.js pages and server actions.
- Systems of several services checked together, probes against real environments, learned simulation models, and an OS-level sandbox for child processes.
Oodle is v0: the catalog format and the CLI can still change between minor versions, and [`CHANGELOG.md`](https://oodlc.com/docs/changelog) says when they do.
## Contributing
See [`CONTRIBUTING.md`](https://github.com/oodlc/oodle/blob/main/CONTRIBUTING.md). Changes to what blocks a merge or what the catalog means need a [decision record](https://github.com/oodlc/oodle/tree/main/docs/decisions).
## License
Apache-2.0
---
# The Oodle CLI
`oodle help` shows the same information in the terminal, and `oodle help --json` gives it as JSON for scripts and agents. This page explains the conventions behind it.
```
oodle [project] [flags]
```
| Command | What it does |
| --- | --- |
| `oodle run [project]` | Run every outcome and behavior under every condition |
| `oodle check [project]` | Outcome diff of the working tree against a git ref. `--approve id@fingerprint` approves an intended change to a promise |
| `oodle diff ` | Outcome diff between two project checkouts |
| `oodle lint [project]` | Validate the catalog and its traceability |
| `oodle init [dir]` | Start a project: an `oodlc/` folder and a starter catalog, plus `oodle.app.ts` around the service already there (or a starter app). For a service, it names the outbound calls it finds under `effects`, stubs each with a placeholder, and proposes an outcome for each route in `oodlc/proposed.yaml`. `--ci` adds the GitHub workflow, `--migrate` moves a v0 project in |
| `oodle doctor [project]` | Check your environment and project setup: the app is yours, every effect it names has a stub, nothing escapes the simulation, two runs agree |
| `oodle mutate [project]` | Plant small bugs in the app and see which ones the catalog catches. `--tests ` finds unit tests the catalog covers |
| `oodle propose [project]` | Add drafted entries as proposals in `oodlc/proposed.yaml`, never changing an existing one. `--routes` proposes an outcome for each route nothing describes, from probing it |
| `oodle draft [project]` | Print the prompt that drafts catalog entries from a brief, for any agent |
| `oodle mcp [project]` | Serve Oodle to coding agents over MCP (stdio) |
| `oodle hook ` | Answer a coding agent's hook: `session-start`, `pre-tool-use`, `stop`. See [agents.md](https://oodlc.com/docs/agents) |
| `oodle completion ` | Print a bash, zsh or fish completion script |
| `oodle hello` | Meet Oodle |
| `oodle help [command]` | Help for oodle or one command |
Every command takes `-h`/`--help`. `oodle help run`, `oodle run --help` and `oodle run -h` all show the same page.
## Finding the project
A project is the directory that holds an `oodlc/` folder. With no `project` argument, Oodle walks up from the current directory to the nearest one, the way git finds `.git`, so `oodle run` works from anywhere inside a project, including from inside `oodlc/`. You can also pass the `oodlc/` folder or its `config.yaml`. If you pass a path with no project, Oodle suggests the nearest directories that have one.
A v0 project (`oodle.yaml` plus a catalog directory) still runs, and Oodle suggests `oodle init --migrate`. That moves the files into `oodlc/` with `git mv`, so history follows them.
## Output
Results go to **stdout**. Oodle's reactions, progress, hints and errors go to **stderr**. When you pipe or redirect output, you get only the results.
| Format | Flag | Commands | Notes |
| --- | --- | --- | --- |
| text | default | all | Designed for people; may change between versions |
| json | `--json` or `--format json` | run, check, diff, lint, init, doctor, mutate, propose, help | Stable; for scripts and agents |
| md | `--format md` | check, diff | The PR comment. Default for `check` and `diff` when stdout is piped |
Rules for `--json`:
- stdout gets exactly one JSON document, even when the command fails.
- Every document has an `ok` boolean.
- A failure looks like `{ "ok": false, "error": { "code", "message", "hint", "problems" } }`. Scripts can match on `error.code`, which is stable: `usage`, `no-project`, `no-match`, `catalog`, `app-load`, `app-contract`, `app-crash`, `sealed`, `database-driver`, `database-schema`, `exists`, `no-files`, `baseline`, `not-holding`, `proposal`, `proposal-exists`, `internal`.
- stderr stays silent.
`check --md diff.md` and `diff --md diff.md` also write the markdown to a file, whatever the output format.
### Colour, symbols and motion
Settings are applied in this order, first match wins:
1. `--color always|never|auto` and `--no-color`
2. [`NO_COLOR`](https://no-color.org) (any non-empty value)
3. `FORCE_COLOR`
4. `TERM=dumb`
5. Whether the stream is a terminal
stdout and stderr are decided separately, so `oodle run | less` keeps colour in Oodle's messages while the results stay plain.
Unicode symbols fall back to ASCII on the Linux console and legacy Windows terminals, or when `OODLE_ASCII=1` is set. The spinner appears only on an interactive stderr, and only after 200ms, so fast commands never flicker. Animation stops in CI, under `OODLE_STILL=1`, or with colour off.
## Exit codes
| Code | Meaning |
| --- | --- |
| 0 | Success. Nothing a human declared is broken |
| 1 | Blocking: an outcome or constraint is not holding, or the catalog has lint errors |
| 2 | Could not run: bad usage, no project, invalid config, or the app failed to load |
| 130 | Interrupted with Ctrl-C |
Behavior drift, unknown routes and proposals never change the exit code. `oodle mutate` exits 1 only below `--min-score`, or when the catalog doesn't hold before mutating (`not-holding`).
## Errors
Every error says what went wrong and what to do next. Typos in commands, flags, shells and project paths get a "did you mean". `--debug` (or `OODLE_DEBUG=1`) adds stack traces.
An error that is not Oodle's to explain is a bug. Oodle says so and links a prefilled GitHub issue with the command, the stack, and the Oodle, Node and platform versions.
## Ctrl-C
`oodle check` checks out the base ref in a temporary git worktree under `.git/oodle/worktrees/`, so nothing appears in your repository. On Ctrl-C, Oodle removes the worktree and exits with 130. A second Ctrl-C exits at once; `git worktree prune` tidies anything left behind. If a worktree for the same commit is left over from an earlier crash, the next run replaces it.
## Watch mode
`oodle run --watch` and `oodle lint --watch` re-run whenever a file in the project changes, ignoring `node_modules`, `.git` and editor temp files. Each run is a fresh process, so the app is always re-imported.
Every run starts with a `WATCH` or `RERUN` line naming the files that changed. It ends with a status block:
```
────────────────────────────────────────────────────────────
FAIL 1 of 4 outcomes not holding → now failing, was passing
run #3 · 11:55:57 · 34ms history ✔ ✔ ✘
watching examples/checkout for changes · r re-run · q quit
```
- The badge says `PASS` or `FAIL`, with the same verdict as a normal run.
- The transition reads "now failing", "fixed", "still passing" or "still failing". When some outcomes are already failing, it names the ones that are newly failing and the ones that were fixed.
- The history shows the last twelve runs. The terminal bell rings when the status flips.
- Press `r` (or Enter) to re-run and `q` to quit.
`--watch` combines with `--only`:
```bash
oodle run --watch --only "checkout.*"
```
## CI
### The GitHub Action
`oodle init --ci` writes this workflow for you:
```yaml
name: Oodle
on:
pull_request:
pull_request_review: # a review can approve a change, so it re-runs the check
types: [submitted]
permissions:
contents: read
pull-requests: write # for the outcome diff comment, and to read reviews
concurrency:
group: oodle-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
outcomes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: npm ci # your app's dependencies
- uses: oodlc/oodle@v0
with:
project: services/checkout
```
Keep it in its own workflow. A review re-runs every job of the workflow it triggers, and a job that skips on review events would report as passed for the commit, hiding its earlier result.
The action:
- compares against the pull request's base branch, or the previous commit on a push, and fetches that commit even when the checkout is shallow;
- treats a base with no `oodlc/` yet as promising nothing, so the pull request that adds Oodle reports every outcome as `new`, and passes if they hold;
- collects approvals from the pull request's reviews and comments (below);
- runs `oodle check`;
- posts the outcome diff as one pull request comment, updated in place on every push;
- fails the job only when something blocks.
| Input | Default | |
| --- | --- | --- |
| `project` | `.` | Directory that holds `oodlc/` |
| `base-ref` | PR base, or the commit before a push | Ref to compare against |
| `comment` | `true` | Post and update the PR comment |
| `approvals` | `true` | Read `/oodle approve` lines from reviews and comments |
| `allow-self-approval` | `false` | Let the PR's author approve their own changes. For a repository with one maintainer |
| `fail-on-blocking` | `true` | Fail the job on blocking findings. If Oodle cannot run, the job always fails |
| `node-version` | `22` | Node.js for Oodle |
Outputs: `blocking` (count), `approved` (count), `exit-code`, `markdown-file`. On pull requests from forks the token is read-only, so the action skips the comment with a warning. The diff is still in the job summary.
### Approving a change to a promise
A change to a promise (an outcome that `changed`, was `redefined` or `removed`; a constraint `redefined` or `removed`) blocks until a human approves it. Each one carries a fingerprint, and the outcome diff comment ends with the line to approve them all:
```
/oodle approve checkout.payment-confirmed@1a2b3c4d
```
A maintainer submits a pull request review (approve or comment) containing that line. The review re-runs the check, and the change shows as approved, with who approved it. The rules (see [0007](https://github.com/oodlc/oodle/blob/main/docs/decisions/0007-approvals-in-ci.md)):
- Only reviews and comments by people with write access count (`OWNER`, `MEMBER`, `COLLABORATOR`, or, for someone GitHub labels otherwise, such as an org member whose membership is private, a `write`, `maintain` or `admin` permission on the repository), never bots, and never the pull request's author unless `allow-self-approval` is on.
- An approval is bound to the change as it is now: the definitions before and after, and what was observed before and after. If a later push changes it, the approval is reported stale and the change blocks again.
- A `broken` outcome or a constraint violation is never approvable. Fix the code, or redefine the outcome in the catalog and approve the redefinition.
Locally, or in another CI, pass the same tokens: `oodle check --approve checkout.payment-confirmed@1a2b3c4d`, or `--approvals approvals.json` with `[{ "id", "fingerprint", "by" }]`.
### Without the action
With `GITHUB_ACTIONS=true` (set automatically in Actions), any `oodle` command writes:
- an error annotation for every broken outcome and constraint violation,
- an annotation on the catalog file for every lint finding,
- the outcome diff, appended to the job summary (`$GITHUB_STEP_SUMMARY`) by `check` and `diff`.
```yaml
- run: npx oodle check --base-ref origin/${{ github.base_ref }} --md diff.md
```
## Shell completion
Completion scripts are generated from the command registry, so they always match the real flags. `--base-ref` completes git refs.
```bash
oodle completion zsh > "${fpath[1]}/_oodle"
oodle completion bash >> ~/.bashrc
oodle completion fish > ~/.config/fish/completions/oodle.fish
```
## Environment
| Variable | Effect |
| --- | --- |
| `NO_COLOR` | Disable colour |
| `FORCE_COLOR` | Force colour on, even when piped (`0` forces it off) |
| `OODLE_FORMAT` | Default output format, e.g. `json` for agents and scripts |
| `OODLE_QUIET` | Same as `--quiet` |
| `OODLE_STILL` | Draw Oodle without animation |
| `OODLE_ASCII` | ASCII symbols instead of unicode |
| `OODLE_DEBUG` | Same as `--debug` |
| `GITHUB_ACTIONS` | Annotations and job summary |
| `OODLE_HOOK_STRICT` | `oodle hook pre-tool-use` refuses catalog edits instead of asking |
## Mutation testing
`oodle mutate` plants small bugs (flipped comparisons and logic, arithmetic, negation, changed literals and strings, removed effects and assignments) in every file the app imports, and for a Next.js app every route file and the middleware (or `--files`). It skips `@oodlc/oodle/adapter` and `/next` modules, which only wire the app in, and entry-point boilerplate no simulated run reaches: `listen(...)`, `process.argv`, `import.meta.main`, `require.main`, `process.env.PORT` and `console.*` lines. It runs `oodle run --json` against each in a mirror of the repository under `.git/oodle/mutants/`, removed afterwards, with a timeout of five times the baseline run. Each mutant is:
| Status | Meaning |
| --- | --- |
| killed | An outcome failed or a constraint was violated |
| noticed | Customer-visible output changed but every expectation passed: only `oodle check`'s `changed` would catch it |
| internal | Only `internal.*` effects changed, which outcomes allow. Not counted |
| survived | Nothing that is checked changed |
| timeout | The mutant hung. Counted as caught |
| invalid | The mutant did not load. Not counted |
The score is caught ÷ (all − invalid − internal). `--max` samples evenly (default 200), `--jobs` sets parallelism, and `--only` limits the outcomes run. With `--tests ""`, the command runs in each mirror too, and failing tests are read from TAP (`not ok N - name`) or spec (`✖ name (1ms)`) output. A test whose every caught bug an outcome caught too is **covered by the catalog**: a candidate to delete after a read, since a test can still guard inputs no outcome sends. A test that caught no planted bug is listed apart (`no_kills` in JSON): that's no evidence either way.
## For agents
See [agents.md](https://oodlc.com/docs/agents) for the Claude Code plugin, the MCP server and the hooks.
- `oodle help --json` describes every command, flag, format, exit code and environment variable.
- Set `OODLE_FORMAT=json`, or pass `--json`, and parse stdout. Branch on `ok` and the exit code, not on text.
- `oodle run --only --json` keeps the output small while you iterate on one outcome.
- `oodle doctor --json` tells you, before anything else, whether the project is wired up correctly.
---
# Oodle for coding agents
Agents make code cheap. What they need from a project is a target that doesn't move when the code does, plus guardrails on the target itself. Oodle gives both: the catalog in `oodlc/` is the target, and only a person can change what in it blocks a merge.
| You want the agent to… | Oodle gives it |
| --- | --- |
| build against a spec, not tests it wrote itself | outcomes, run with `oodle run --only` and explained with `explain` |
| write the promise before the code | `status: proposed` outcomes that run but never block ([0006](https://github.com/oodlc/oodle/blob/main/docs/decisions/0006-proposals-and-propose-only-agents.md)) |
| never weaken a promise to get green | propose-only writes, and a hook that asks you before an approved entry changes |
| not stop while something it broke is broken | a Stop hook running `oodle check` |
| write fewer, sharper tests | conditions with `when`, `oodle mutate`, and `oodle mutate --tests` to prune |
| ship secure code | the `security.*` conditions, constraints over `request`, and a sealed simulation ([0004](https://github.com/oodlc/oodle/blob/main/docs/decisions/0004-conditions-carry-expectations.md), [0005](https://github.com/oodlc/oodle/blob/main/docs/decisions/0005-sealed-simulation.md)) |
## Claude Code
Install the plugin. It bundles the hooks, the MCP server and a skill:
```
/plugin marketplace add oodlc/oodle
/plugin install oodle@oodlc
```
The project needs Oodle installed locally (`npm install -D @oodlc/oodle`), since the plugin runs `npx --no-install oodle`.
What it does:
| Piece | Effect |
| --- | --- |
| **SessionStart** hook (`oodle hook session-start`) | Tells the agent the catalog's size and the rules: build against outcomes, propose new ones, never edit approved ones, external calls only through `ctx.effects`. |
| **PreToolUse** hook (`oodle hook pre-tool-use`) | Before an `Edit`, `MultiEdit` or `Write` to a catalog file, works out what the edit does to approved entries. Changing, removing, approving (deleting `status: proposed`) or silencing (adding it) an outcome, constraint or intent **asks you first**, and so do changes to `sealed`, `probe`, `app` or `database` in `config.yaml`. Shell commands that rewrite `oodlc/*.yaml` ask too. Set `OODLE_HOOK_STRICT=1` to refuse instead of asking. |
| **Stop** hook (`oodle hook stop`) | Runs `oodle check` (or `oodle run` outside git). If an outcome the agent can fix is broken or failing, a constraint is violated, the catalog doesn't lint, or the app doesn't load, it **sends the agent back to work** with the findings and a reminder not to weaken outcomes. Things only you can approve (changed, redefined or removed outcomes, constraint changes) are shown to **you** instead. After 3 blocked attempts in a row it lets the agent stop and tells you why. |
| **MCP server** (`oodle mcp`) | Tools: `run`, `check`, `lint`, `catalog`, `explain`, `mutate`, `propose`, plus a `draft` prompt. No tool edits or removes an approved entry. |
| **Skill** (`oodle`) | How to work against a catalog: the loop, writing fewer and better tests, security, and what never to do. |
Without the plugin, wire the same pieces by hand:
```bash
claude mcp add oodle -- npx --no-install oodle mcp
```
```jsonc
// .claude/settings.json
{
"hooks": {
"SessionStart": [{ "hooks": [{ "type": "command", "command": "npx --no-install oodle hook session-start" }] }],
"PreToolUse": [{ "matcher": "Edit|MultiEdit|Write|Bash", "hooks": [{ "type": "command", "command": "npx --no-install oodle hook pre-tool-use" }] }],
"Stop": [{ "hooks": [{ "type": "command", "command": "npx --no-install oodle hook stop", "timeout": 600 }] }]
}
}
```
Other agents can use the same commands: each hook reads an event as JSON on stdin and answers in Claude Code's hook JSON on stdout.
## The loop
1. **Read** the catalog (`catalog` tool, or `oodlc/*.yaml`).
2. **Propose** the outcomes a feature promises before building it: `oodle propose draft.yaml`, or the `propose` tool. From a brief: `oodle draft brief.md | claude -p | oodle propose -`.
3. **Iterate** with `oodle run --only "" --json` until the proposals hold. `explain ` shows what the app returned and emitted under each condition.
4. **Check** with `oodle check --json` and follow it. The Stop hook does this anyway.
5. **Hand over** a PR with the outcome diff. You approve the proposals by deleting their `status: proposed` lines.
## Fewer, better tests
**Outcomes over unit tests.** An outcome tests what someone outside sees, so refactors don't touch it. Variants are conditions, not copies:
```yaml
- id: checkout.payment-confirmed
conditions: [first_purchase, payment_provider_slow, security.no-credentials, security.replayed]
expect: { status: 200, body: { status: confirmed }, effects: [{ kind: payment.capture, count: 1 }] }
when:
security.no-credentials: { status: 401, body: { error: unauthorized }, effects: [{ kind: payment.capture, count: 0 }] }
```
**Measure the catalog with `oodle mutate`.** Oodle plants small bugs (a flipped comparison, a dropped effect, a changed literal) in the files the app imports, and runs the outcomes against each one in its own mirror of the repository:
```
Survived no outcome or constraint noticed these bugs
✘ src/checkout.ts:9 literal 404 → 405
✘ src/checkout.ts:29 remove-statement removed customer.orders = (customer.orders ?? 0) + 1;
What caught them unique = bugs nothing else catches
checkout.payment-confirmed 26 bugs · 8 unique
...
▲ 65% of planted bugs caught 37 caught · 6 only noticed · 2 internal only · 14 survived
```
- **Survived**: nothing checks this. Here, no outcome covers an unknown customer, and nothing checks the order count in state. Each survivor is an outcome or condition worth proposing.
- **Only noticed**: the output changed but no expectation failed, so only a reviewer reading the `oodle check` diff would catch it. Tighten the expectation.
- **Internal only**: only `internal.*` effects changed. Outcomes allow that on purpose, so these don't count against the score.
- **Redundant outcomes**: every bug they catch, a smaller set of outcomes also catches.
`--min-score 80` turns the score into a CI gate.
**Prune unit tests with `oodle mutate --tests "npm test"`.** Each mutant also runs through your suite (TAP or `node --test` output). Tests whose every caught bug an outcome caught too are listed as **covered by the catalog**: candidates to delete, after a read, since a test can still guard inputs no outcome sends. Tests that caught no planted bug are listed apart, because that is no evidence either way. Tests that catch bugs the catalog misses are worth keeping, or turning into an outcome.
## Security
- **Sealed simulation.** The app reaches the world only through `ctx.effects`. A direct `fetch`, socket or SDK call is refused and blocks as `oodle.sealed`, so an agent can't add a side channel that skips your constraints.
- **The security pack.** `security.no-credentials`, `security.injection`, `security.oversize`, `security.extra-fields` (mass assignment and `__proto__` pollution) and `security.replayed` need no app knowledge. Put them on outcomes with `when`, and on every route nothing describes with `probe: { conditions: [...] }` in `oodlc/config.yaml`.
- **Constraints over `request`.** "A request without credentials never causes an external effect" is one line, and it holds on every run, including a route an agent added five minutes ago:
```yaml
constraints:
- id: no-side-effects-without-credentials
statement: A request without credentials never causes an external effect
check: "!!(request.headers && request.headers.authorization) || effects.every(e => e.boundary === 'internal')"
```
- **Give the security team the folder.** A `CODEOWNERS` entry for `oodlc/constraints.yaml` and `oodlc/config.yaml` means no agent and no individual can loosen an invariant or open the seal alone.
---
# Changelog
Oodle is v0. Until 1.0, a minor version (0.x.0) can change the catalog format or the CLI; this file says when one does, under **Changes**. A patch version (0.x.y) only fixes things.
The GitHub Action follows the newest release through the `v0` tag (`uses: oodlc/oodle@v0`).
## 0.8.0 (2026-10-06): schemas at oodlc.com, docs for agents
- The catalog and config schemas are published at `https://oodlc.com/schema/v0/catalog.json` and `https://oodlc.com/schema/v0/config.json`, and their `$id`s say so.
- Every YAML file `oodle init` and `oodle propose` write starts with a `# yaml-language-server: $schema=…` line, so editors with the YAML language server complete and check the catalog as you type.
- Fix: an approval from an org member whose membership is private counts. GitHub labels them `CONTRIBUTOR` to the workflow's token, so the Action now looks up the approver's permission on the repository (`write`, `maintain` or `admin`).
- The docs are readable on [oodlc.com/docs](https://oodlc.com/docs), and as markdown for agents at [oodlc.com/llms.txt](https://oodlc.com/llms.txt).
## 0.7.0 (2026-10-06): a real database in the simulation
- `database:` in `oodlc/config.yaml` runs Postgres in process ([PGlite](https://pglite.dev)) and points `DATABASE_URL` at it. Your own driver connects as in production: no Docker, no port.
- Every run starts from the schema plus `given.db`. Each row the app writes is an effect on the new `data` boundary (`db..inserted`, `updated`, `deleted`), and constraints see the tables as `db`.
- SQL built from request input is refused before it runs and blocks as `oodle.sql-injection`.
- `init` and `doctor` find the driver, the migrations and the env var. See [0009](https://github.com/oodlc/oodle/blob/main/docs/decisions/0009-a-real-database-in-the-simulation.md).
## 0.6.0 (2026-10-06): a first outcome in five minutes
- `init` follows `app.listen()`, `createServer(app)` or `serve({ fetch: app.fetch })` back to the module that builds the app.
- `init` names the outbound hosts it finds (URLs in the source, SDKs like Stripe in `package.json`) under `effects` and gives each a placeholder stub.
- `init` probes each route nothing describes and saves a proposed outcome to `oodlc/proposed.yaml`. `oodle propose --routes` does the same later.
- `init --ci` in an existing project writes only the workflow.
- A failing run names a missing stub first, and an effect whose call failed says so instead of "got 0". `doctor` checks that every effect the app names has a stub.
- Proxy environment variables can't carry a call past the effect rules.
- New guide: [Your first outcome in 5 minutes](https://oodlc.com/docs/first-outcome).
## 0.5.0 (2026-10-06): Next.js apps
- `@oodlc/oodle/next` exports `nextApp()`, which runs `app/**/route.ts` and the middleware in process, inside Next's own route module, with no build and no server. Next 15 and 16. See [0008](https://github.com/oodlc/oodle/blob/main/docs/decisions/0008-nextjs-through-its-own-route-module.md).
- `init` detects Next.js and writes `oodle.app.ts` around `nextApp`. `mutate` starts from every route file and the middleware.
## 0.4.4 (2026-10-06)
- Hints and help name the command that works where you are (`pnpm exec oodle run`, `npx oodle run`), not a bare `oodle` that isn't on the PATH.
## 0.4.3 (2026-10-06)
- `init --ci` reads the lockfile and writes the matching install step for npm, pnpm, yarn or bun.
## 0.4.2 (2026-10-06)
- Calls through `node:http` and `node:https` agents become effects, not just `fetch`: axios, got, node-fetch and SDKs on their default clients (Stripe, Twilio, AWS) are stubbed and recorded.
- Releases publish to npm from CI with provenance.
## 0.4.1 (2026-10-05)
- Published to npm as `@oodlc/oodle`. Imports are `@oodlc/oodle/adapter` and `@oodlc/oodle/contract`. The command is still `oodle`.
## 0.4.0 (2026-10-05): adoptable in CI for existing services
- **Changes:** approvals. A change to a promise gets an `id@fingerprint`, and a maintainer other than the author approves it with `/oodle approve` in a review. See [0007](https://github.com/oodlc/oodle/blob/main/docs/decisions/0007-approvals-in-ci.md).
- `@oodlc/oodle/adapter`: `httpApp()` runs an existing Express, Koa, Hono, `http.Server` or `(req, res)` app in process.
- `init` wraps the service already in the repo; `--ci` writes the workflow.
- `doctor` fails when Oodle runs the starter instead of your service, or the app reaches the network.
- The pull request that adds Oodle reports every outcome as new instead of failing.
## 0.3.0 (2026-10-05): Oodle for coding agents
- **Changes:** conditions carry their own expectations with `when` ([0004](https://github.com/oodlc/oodle/blob/main/docs/decisions/0004-conditions-carry-expectations.md)), and `status: proposed` on intents, outcomes and constraints runs and reports but never blocks ([0006](https://github.com/oodlc/oodle/blob/main/docs/decisions/0006-proposals-and-propose-only-agents.md)).
- The built-in `security.*` conditions, and probes of every route no outcome describes.
- The sealed simulation: a call to a host nobody named blocks as `oodle.sealed` ([0005](https://github.com/oodlc/oodle/blob/main/docs/decisions/0005-sealed-simulation.md)).
- `oodle mutate`, `oodle propose`, `oodle draft`, `oodle mcp`, and the Claude Code plugin.
## 0.2.0 (2026-10-05): one visible `oodlc/` folder
- **Changes:** a project is the directory that holds `oodlc/`. `oodlc/config.yaml` says how to run the app and every other YAML file in it is catalog. `oodle init --migrate` moves a 0.1 project in with `git mv`. See [0003](https://github.com/oodlc/oodle/blob/main/docs/decisions/0003-one-visible-oodlc-folder.md).
## 0.1.0 (2026-10-05)
- The OODLC v0 catalog, `oodle run`, `check`, `diff` and `lint`, readable effect diffs, and the GitHub Action.