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, 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 walks through it on your own service.
In your own service:
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:
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) 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 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.tsimports 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/mailand others), go undereffects, each with a placeholder stub inoodlc/config.yaml. A host built from an environment variable can't be seen;oodle doctorlists any call that still escapes. - It finds your database. With
pg,postgresor Prisma's pg adapter inpackage.json, it addsdatabase:tooodlc/config.yaml, pointed at your migrations (prisma/migrations,drizzle,supabase/migrations,db/migrations, …). See A real database. - It writes a first catalog. Each route no outcome describes is probed in the simulation and saved to
oodlc/proposed.yamlas a proposed outcome: what it answered, and what it called. Approve one by sharpening its statement and deleting itsstatus: proposedline.oodle propose --routesdoes this again later, for routes added since.
// 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
effectsrule becomectx.effects.call(kind, payload): stubbed fromoodlc/config.yamland recorded. That'sfetchand every client built onnode:httpornode: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: 402answers 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 nodatabaseconfigured) is refused and blocks as anoodle.sealedviolation, so nothing slips through untested. - Time,
crypto.randomUUID, random bytes andMath.randomare deterministic while a request runs, so identical code gives identical output and the outcome diff shows only real changes.deterministic: falseturns 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.
// 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
nextpackage, socookies(),headers(),redirect(),notFound()andexport const dynamicbehave as in Next. Dynamic segments ([id],[...slug],[[...slug]]), route groups and private folders work as in Next. - The middleware runs first when its
matcherapplies: a response it returns is what the caller gets, andNextResponse.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.testand.env, the files Next loads in test mode, never.env.local, so a laptop and CI see the same values. Commit a.env.testwith 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 doctorlists the hosts. oodle mutatestarts 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, 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.
npm i -D @electric-sql/pglite
# 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 }
# 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 underdatabase.env) before your app loads, and routes the connection to the simulated database.pg,postgres.jsand what's built on them (Drizzle, Kysely, Knex, Prisma 7 with@prisma/adapter-pg) work unchanged, transactions included. ADATABASE_URLin.env.testcan'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.dblayers like the rest ofgiven, 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.<table>.inserted,db.<table>.updated(the row now, and inresultthe values it replaced),db.<table>.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 asoodle.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.sqlskipped), dbmate (the-- migrate:downhalf skipped), or apg_dump --schema-onlyfile. Extensions PGlite ships, such asuuid-ossp,pgcrypto,citext,pg_trgmandhstore, 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. 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). 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.
# 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 }
# 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
# 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
behaviorstooutcomes, giving it an intent and turningobservedintoexpect. An id can't be both. Promotion never blocks. Demoting removes an outcome, so it does block. - A behavior on any boundary other than
internalgets a lint warning asking for that decision. - The simulation is sealed. Reaching the real network instead of going through
ctx.effectsis anoodle.sealedviolation and blocks.sealed: { allow: [host] }lets named hosts through. See 0005. status: proposedon an intent, outcome or constraint means it runs and is reported, but never blocks until a human deletes that line.oodle proposewrites proposals, and only proposals. See 0006.
Full schema: spec/catalog.schema.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-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.
Day two
None of this is needed for a first outcome. Reach for it once one holds.
Conditions and constraints
# 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 (
givenstate, stubs or body) layered over the outcome or behavior:defaults→ item → condition. - Constraints are invariants written as a JS expression over
effects,state,response,requestand, with a database,db. Every constraint is checked on every run, and a check that throws counts as a violation. Theconstraints:list on an outcome is traceability only. See 0002. - Latency is real in-process time plus the simulated latency of stubbed calls, so
payment_provider_slowcosts 1.5s of simulated time and zero real time. whengives a condition its own expectations. Each field it names replaces that field ofexpect, e.g.when: { security.no-credentials: { status: 401 } }.statusalso takes a matcher such as{ gte: 400, lte: 499 }. See 0004.- 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) andsecurity.replayed.givenalso takesheaders,repeatandfuzz, and constraints see therequest.probe: { conditions: [...] }inoodlc/config.yamlprobes 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 and 0007.
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 <base> <head> 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 <file> Add drafted entries as proposals, never changing an existing one
oodle draft <brief> Print the prompt that drafts catalog entries from a brief
oodle mcp [project] Serve Oodle to coding agents over MCP
oodle hook <event> Answer a coding agent's hook (Claude Code)
oodle completion <shell> 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_COLORand--color. - Made for scripts and agents.
--json(orOODLE_FORMAT=json) prints exactly one JSON document, errors included.oodle help --jsondescribes 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@v0keeps 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.
0ok,1blocking,2could not run,130interrupted. Ctrl-C cleans up after itself.
The full reference is in docs/cli.md.
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.
Oodle checks itself, too. The root oodlc/ folder declares Oodle's own promises, and CI blocks any pull request that breaks one. See CONTRIBUTING.
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 |
- 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.
- Behaviors are what the runner sees the system doing. Agents may change them freely. To protect one, promote it to an outcome.
- 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. - Every change runs base and head, then classifies each outcome:
held,changed,broken,failing,new,removed,redefined. Any of those exceptheldand a passingnewblocks the merge until a human approves. - 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. - 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/.
The app contract
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,diffandcheck.oodle hellowaves. - 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.
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, 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 says when they do.
Contributing
See CONTRIBUTING.md. Changes to what blocks a merge or what the catalog means need a decision record.
License
Apache-2.0