Authority-aware review for TypeScript.
A code diff says what changed. An authority diff says what became possible.
Ambit puts the authority a change adds in front of a reviewer — whoever wrote the change. The case it is built for first is AI-assisted and agent-generated changes: an agent can widen what a function is able to do faster than a human can read the diff.
ambit diffreports the authority a change grants that the base commit did not — with no contracts written, from what the code is observed to do.- Contracts declare a function's authority, as JSDoc on ordinary TypeScript.
ambit checkfails code that exceeds the contract written today.
The first works on its own; the other two are worth adding where they pay for
themselves. Experimental, 0.x, and not a sandbox. Known blind spots are
documented in What Ambit does not guarantee.
Requires Node.js 24 and a git repository. No contracts, config or ledger:
$ npm i -D ambit-ts
$ npx ambit diff HEAD~1 src; echo "exit=$?"
base HEAD~1 (25b7b46) vs the working tree, over src
6 authorities increased without approval:
pricing.ts#priceOrder (pricing.ts:3)
+ network
-> applyTax (tax.ts:3)
-> currentRate (rates.ts:3)
operation: fetch (rates.ts:4)
...
exit=1The last commit added one fetch two calls below priceOrder. A function with
no contract is compared by what its body does, and a caller holds what its
callees reach. Exit 1 means authority increased, 0 that nothing did, and 2 that
the comparison could not be made.
Ambit reads the nearest tsconfig.json at or above the directory it is given,
and analyzes with the TypeScript it installs; a tsconfig written for TypeScript
5 needs no change.
Next: run it in CI without blocking (See), declare the boundaries authority enters through (Shape), or block merges on it (Enforce).
Three files. priceOrder declares pure; applyTax and currentRate declare
nothing at all.
// pricing.ts
/** @effects pure */
export function priceOrder(subtotal: number, region: string): number {
return applyTax(subtotal, region);
}
// tax.ts
export function applyTax(subtotal: number, region: string): number {
return Math.round(subtotal * (1 + currentRate(region)));
}
// rates.ts
const FALLBACK_RATE = 0.08;
export function currentRate(region: string): number {
void fetch(`https://rates.example.com/${region}`); // <- the agent's one added line
return FALLBACK_RATE;
}The added line is two calls away from the declaration it breaks. The next check fails, and prints the way from one to the other:
$ node src/cli/main.ts check test/fixtures/accident; echo "exit=$?"
error: priceOrder declares pure but calls currentRate which has effects [network] (pricing.ts:4)
-> applyTax (tax.ts:3)
-> currentRate (rates.ts:3)
operation: fetch (rates.ts:4)
files=3 functions=3 declared=1
exit=1No file here contains both the declaration and the fetch, and each is locally
unremarkable — a rates module fetching a rate. The violation exists only in the
path between the three, which is why a rule that reads one file at a time has
nothing to fire on.
Now the second edit — the one Ambit itself offers as a fix candidate, in the
--format json output an agent reads ("kind":"widen"):
-/** @effects pure */
+/** @effects network */
export function priceOrder(subtotal: number, region: string): number {$ node src/cli/main.ts check test/fixtures/accident; echo "exit=$?"
files=3 functions=3 declared=1
exit=0Green. Nothing about the code moved — priceOrder still reaches the same
fetch through the same two calls. What changed is that it is now allowed to.
check validates code against the contract currently written, so widening the
contract is always a way to pass it. That is what the second gate reads:
$ node src/cli/main.ts diff HEAD test/fixtures/accident; echo "exit=$?"
base HEAD (6d46282) vs the working tree, over test/fixtures/accident
1 authority increased without approval:
pricing.ts#priceOrder (pricing.ts:4)
+ network
-> applyTax (tax.ts:3)
-> currentRate (rates.ts:3)
operation: fetch (rates.ts:4)
- `test/fixtures/accident/pricing.ts#priceOrder` `effect:network` — <why this increase is correct>
Add each line above to ambit.approvals.md at the repository root,
with the reason, and commit it in the same change. An approval already in the base
grants nothing.
2 symbols unchanged, out of 3 symbols compared.
exit=1That is a real run, with test/fixtures/accident's declaration widened in the
working tree. The path is the one check printed, and the last line is what to
paste into ambit.approvals.md if the increase is the correct change. Only
increases are gated — narrowing is never taxed — and an approval that was
already in the base grants nothing, so the record is made in the change that
makes the increase.
TypeScript accepts both edits: the types line up either way. It tells you whether a value has the type you expect, not whether a function is allowed to do what it does.
Three stages, as the tooling works today. Stopping at any of them is fine.
# .github/workflows/ambit.yml
name: Ambit
on: pull_request
permissions:
contents: read
jobs:
ambit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 2 # `diff` needs the base commit; the default of 1 exits 2
- uses: actions/setup-node@v7
with:
node-version: 24
- run: npm ci
# Exit 1 (authority increased) passes; exit 2 (could not compare) still fails.
- run: npx ambit diff HEAD~1 src --format github || [ $? -eq 1 ]continue-on-error: true is not the same: it would pass exit 2 as well. The
job stays green, but increases still appear as GitHub error annotations with an
approval hint today. Ignore the hint until Enforce.
Declare only the functions authority enters through: the HTTP client, the
database wrapper, the file adapter. npx ambit init src lists what each
undeclared function was observed to do, and writes nothing. Write both tags,
with the effect names from DESIGN.md §4.2; init's
[none] is written pure.
/**
* @effects network
* @capabilities http:get:rates.example.com
*/
export function currentRate(region: string): number {Callers then see the contract. A change beneath it that stays within it is no longer reported again on every caller; widening the contract still is.
Add ambit check with the first contract, as a blocking step. A declared
function is compared by its contract, so diff no longer sees what its body
adds. check does:
$ npx ambit check src; echo "exit=$?"
error: currentRate declares network but performs [fs_write] directly (rates.ts:7)
operation: node:fs.appendFileSync (rates.ts:9)
files=3 functions=3 declared=1
exit=1In CI: - run: npx ambit check src --format github.
Drop || [ $? -eq 1 ] and make the job a required check:
- run: npx ambit check src --format github
- run: npx ambit diff HEAD~1 src --format githubWidening currentRate to @effects network, fs_write turns check green.
diff still fails, once for each symbol the widening reaches, until the same
change adds each line diff printed to ambit.approvals.md at the repository
root, with a reason:
- `src/rates.ts#currentRate` `effect:fs_write` — rate lookups are audit-loggedThe line names the symbol from the repository root. Ambit cannot tell who wrote
it, so name the file in CODEOWNERS. --strict and @boundary come last, if at
all; CLI and CI says when.
Contracts are JSDoc, so once the imports are gone npm remove ambit-ts leaves
ordinary TypeScript that still type-checks and runs —
Removing Ambit has the steps.
| Tool | Primary abstraction |
|---|---|
| TypeScript | the types of values |
| ESLint | code-level lint rules |
| dependency-cruiser | module dependency edges |
| Effect-TS | effects represented in program values and types |
| a runtime sandbox | isolation of the running process |
| Ambit | authority propagated across function calls, and the change in it |
Ambit's abstraction is the authority a function holds after propagation, which
is why a pure function calling an undeclared helper that calls fetch is an
error on the pure function, with the path reported — no single file contains the
violation. A module graph that is entirely legal can still contain a pure
helper that opens a socket. And where Effect-TS puts effects in the types of the
values you construct — so the code is written in that style throughout — Ambit's
static contracts are JSDoc comments on ordinary TypeScript: adding them changes
no runtime behavior, and removing Ambit is a small diff.
A sandbox is the other axis: it decides what a process may do while it runs, and knows nothing about which function asked. Ambit's runtime hooks are the narrow overlap, opt-in per entrypoint — Static check, runtime block, below.
Contracts are JSDoc tags on ordinary TypeScript. Two of them can also be declared by the runtime registration beside a handler instead — see Static check, runtime block below.
| Tag | Declares | Checked |
|---|---|---|
@effects |
what side effects a function may perform | statically, propagated through the call graph |
@capabilities |
which resources it may reach | statically — may only narrow from caller to callee — and at run time by four hooks |
@budget |
how much an entrypoint may spend | parsed and validated; of its three limits only timeMs is enforced while the code runs |
@entrypoint |
where a request enters | warned when it declares no capability set (AMB-W002) |
@boundary reason="…" |
that a body is not analysed, and its declared contract is trusted in its place | counted separately in --coverage |
Which tag is enforced where, tag by tag, is in docs/status.md.
Effects are inferred from bundled tables covering fetch/undici/ky, the
node:fs, node:http/https/net, and node:child_process builtins (with
or without the node: prefix), and six clients (pg, mysql2, knex,
@prisma/client, openai, @anthropic-ai/sdk). Everything else resolves to unknown — never to pure —
and --strict turns those warnings into errors.
For code you cannot edit — third party, generated, or not yours yet — declare
the same contracts in ambit.config.ts:
import { defineConfig } from "ambit-ts/config";
export default defineConfig({
effects: { payments: ["network", "db_write"] },
contracts: {
"src/legacy/billing.ts#charge": { effects: ["payments"] },
},
strict: ["src/app/**"],
});Where a symbol has both, the JSDoc contract is the one in force and the
difference is reported as a warning (AMB-W005). ambit init --config
proposes config entries for the declarations no comment can carry — accessors,
anonymous default exports, and a class with no constructor.
ambit check reads the source and nothing that runs, so adopting the static
check means writing the declarations and nothing more. Runtime enforcement is
the opposite: it is adopted per entrypoint. Every entrypoint needs its own
withAmbit or adapter registration, and a JSDoc tag alone never turns it on.
import { installFetchHook, withAmbit } from "ambit-ts/runtime";
installFetchHook();
/**
* @entrypoint
* @effects network
*/
async function refreshRates(currency: string): Promise<void> {
await fetch(`https://api.example.com/rates?base=${currency}`);
// await fetch("https://elsewhere.example/steal"); // AMB-E009 if this line is added
}
export const refresh = withAmbit(
{
capabilities: ["http:get:api.example.com"],
budget: { timeMs: 500, costUsd: 0.01, onExceed: "throw" },
},
refreshRates,
);That file passes ambit check as written; uncommenting the second fetch
fails it.
The capability list and the budget are written once, in the registration.
A literal spec whose handler names a declaration in the same file is that
handler's @capabilities and @budget, so the checker reads the values the
runtime will enforce, and the contract survives a build that strips comments.
@effects and @entrypoint stay in the JSDoc, because the runtime never reads
them. Writing the tags as well is allowed and still checked — AMB-E010 /
AMB-E011 fail on a disagreement. docs/DESIGN.md §4.1 has the rule, and §4.4
the two registrations it cannot read.
At run time withAmbit puts that capability set on the context, and four hooks
check operations against it — installFetchHook(), installFsHook(),
installChildProcessHook(), installPgHook(pg). An ungranted operation throws
AmbitCapabilityError before the socket, the file, or the process is reached,
every decision is recorded on the context's audit trail, and timeMs is
measured against the wall clock. Each install returns the function that
restores the original, so removing Ambit is one call.
A grant names http:<method>:<host>, fs:read: / fs:write:, proc:spawn:,
or db:read: / db:write:. What each target is taken from at the call, and why
a shell spawn names the shell rather than the program inside the command string,
is docs/DESIGN.md §4.4 "Target formats".
Two exist, and both carry the contract in the registration instead of a
hand-written withAmbit: ambitHandler on Hono
(docs/integrations/hono.md), and ambitRoute on
Next.js App Router, for Node.js Route Handlers in app/**/route.ts only —
Server Actions, middleware.ts, the Pages Router and the Edge runtime are
not enforced (docs/integrations/nextjs.md).
Express, BullMQ and the rest have no adapter. A route registered without one
establishes no context, and setUnscopedPolicy("allow" | "warn" | "deny")
decides what its operations do — allow by default, so adopting the runtime
does not break code that has no contracts yet.
| Command | What it does |
|---|---|
ambit check <dir> |
Static check. --coverage, --strict, --format json, --format github |
ambit init <dir> |
Proposes @effects for undeclared functions, writing nothing. --config for the ones no comment can carry |
ambit diff <ref> [dir] |
Compares the working tree's authority against a base ref and fails on an increase no approval covers. --strict also fails where the analysis reached less than it did |
Exit codes: 0 when nothing was reported, 1 on an error, 2 when the analysis itself could not run — never 0 for "could not tell". That exit code is the whole CI integration; the workflows are in Adoption path.
On a pull_request event the checkout is GitHub's merge of the branch into its
base, so HEAD~1 is the base branch's tip and diff compares exactly the
authority the pull request adds. That holds for the event, not for a push:
running the same step on push compares only against the previous commit,
which is the base only when each pull request lands as one commit — the comment
above the diff step in this repository's own
.github/workflows/ci.yml spells that out.
Leave --strict off both commands to begin with. It fails on any unknown —
a pure function that calls a package no bundled table covers (zod, for
one) is an error under it — and for a third-party package the only way to close
that is @boundary (limitations).
The warnings are printed at exit 0 either way.
--format github turns each diagnostic into a GitHub Actions annotation on the
declaration that broke, carrying the whole call path into the pull request:
$ node src/cli/main.ts check test/fixtures/accident --format github; echo "exit=$?"
::error file=test/fixtures/accident/pricing.ts,line=4,col=17,title=AMB-E001::priceOrder declares pure but calls currentRate which has effects [network]%0A-> applyTax (tax.ts:3)%0A-> currentRate (rates.ts:3)%0Aoperation: fetch (rates.ts:4)
files=3 functions=3 declared=1
exit=1diff annotates the same way, and an increase that was approved stays
visible as a ::notice carrying the reason that was given, rather than
disappearing — the point of the ledger is that no increase passes unseen.
Three rules decide what counts. A new symbol has no base to compare
against, so the authority it holds is an increase in full — added code is not
exempt for having no history. A capability is compared by containment, not
by text: http:get:* narrowing to http:get:api.example.com is not an
increase, and the reverse is. And each authority is approved separately, so
a function that gains both an effect and a capability needs two lines.
A change can also make Ambit see less than it did — a call through a client no
stub table covers, added to a function that was already unknown. That is not
authority and is not approved by a line; diff reports it in its own section
and exits 0, and diff --strict is what turns it into a failure. Leave the flag
off until the packages you call are covered by stubs — docs/limitations.md
says why.
check --format json emits NDJSON — one diagnostic per line, then a summary
line — meant to be piped into an agent loop. Where a diagnostic carries a
patch, the agent applies the edits and re-checks without a human in the loop;
AMB-E001 is the one that carries a patch today.
$ node src/cli/main.ts check src --format json
{"id":"AMB-E001","severity":"error","contract":{"declared":["pure"],"observed":["network"]},"fixes":[{"kind":"widen","consistentWithContract":false,"edits":[{"file":"tax.ts","range":[[0,4],[0,17]],"replacement":"@effects network"}]}], ...}
{"kind":"summary","filesAnalyzed":1,"functionsExtracted":1,"functionsDeclared":1}
# the agent applies fixes[0].edits — ranges are 0-based, end-exclusive
$ node src/cli/main.ts check src --format json # re-checkThe patch widens the contract to what the code actually does — the second edit
in The accident, above. It is marked consistentWithContract: false and
carries the callers it would affect, so the reader can tell "the contract was
wrong" from "the code was wrong". Ambit does not invent the other patch, the one
that keeps the contract and rewrites the code; ambit diff is what keeps the
widening one from being applied in silence.
The JSDoc contracts can stay. Without Ambit they are ordinary comments.
What has to go is every import from the package, the files Ambit's setup
created, and the package itself. Run the blocks below from the project root, in
order. test/e2e.removal.test.ts runs them as written against an installed
application.
The rewrites use ast-grep. They handle the forms
this README shows: .ts files, hook installs written as their own statements,
and handlers that return data rather than a Response. Review the diff before
you commit it.
-
Remove the Hono adapter. Each
ambitHandler(spec, handler, decode)is replaced with the plain Hono handler it stands for.npx --yes --package @ast-grep/cli@0.45.3 ast-grep scan --update-all --globs '!**/node_modules/**' --inline-rules ' id: unwrap-ambit-handler language: TypeScript rule: pattern: ambitHandler($SPEC, $HANDLER, $DECODE) fix: |- async (c) => { const decode: ( context: typeof c, ) => Readonly<Parameters<typeof $HANDLER>> | Promise<Readonly<Parameters<typeof $HANDLER>>> = $DECODE; return Response.json(await $HANDLER(...(await decode(c)))); } --- id: drop-ambit-hono-import language: TypeScript rule: kind: import_statement has: field: source regex: ^["\x27]ambit-ts/runtime/hono["\x27]$ fix: "" ' .
-
Remove the Next.js adapter. Each
ambitRoute(spec, handler, decode)is replaced with the plain Route Handler it stands for, typed with Next.js'sNextRequest.npx --yes --package @ast-grep/cli@0.45.3 ast-grep scan --update-all --globs '!**/node_modules/**' --inline-rules ' id: unwrap-ambit-route language: TypeScript rule: pattern: ambitRoute($SPEC, $HANDLER, $DECODE) fix: |- async (request: NextRequest, context: { params: Promise<Record<string, string | string[]>> }) => { const decode: ( request: NextRequest, context: { params: Promise<Record<string, string | string[]>> }, ) => Readonly<Parameters<typeof $HANDLER>> | Promise<Readonly<Parameters<typeof $HANDLER>>> = $DECODE; return Response.json(await $HANDLER(...(await decode(request, context)))); } --- id: replace-ambit-next-import language: TypeScript rule: kind: import_statement has: field: source regex: ^["\x27]ambit-ts/runtime/next["\x27]$ fix: import type { NextRequest } from "next/server" ' .
-
Remove
ambit-ts/runtime. EachwithAmbit(spec, handler)becomeshandler. The hook installs andsetUnscopedPolicycalls are deleted.npx --yes --package @ast-grep/cli@0.45.3 ast-grep scan --update-all --globs '!**/node_modules/**' --inline-rules ' id: unwrap-with-ambit language: TypeScript rule: pattern: withAmbit($SPEC, $HANDLER) fix: $HANDLER --- id: drop-ambit-hooks language: TypeScript rule: kind: expression_statement has: kind: call_expression has: field: function regex: ^(installFetchHook|installFsHook|installChildProcessHook|installPgHook|setUnscopedPolicy)$ fix: "" --- id: drop-ambit-runtime-import language: TypeScript rule: kind: import_statement has: field: source regex: ^["\x27]ambit-ts/runtime["\x27]$ fix: "" ' .
-
Remove the
ambit-tsimport. This deletes only the import statements.npx --yes --package @ast-grep/cli@0.45.3 ast-grep scan --update-all --globs '!**/node_modules/**' --inline-rules ' id: drop-ambit-types-import language: TypeScript rule: kind: import_statement has: field: source regex: ^["\x27]ambit-ts["\x27]$ fix: "" ' .
Code that used the diagnostic types now fails to type-check. Fix it by hand. Delete the code if it only read Ambit's output.
-
Delete the config file. Only the Ambit CLI reads it, through
ambit-ts/config.rm -f ambit.config.ts
-
Delete the CI workflow and the approval ledger. Both were created for Ambit.
rm -f .github/workflows/ambit.yml ambit.approvals.md
-
Remove the package.
npm remove ambit-ts
Ambit stops the violations it can detect and states the rest. It does not claim:
- Whole-program soundness. No alias analysis is performed: a locally created
value handed elsewhere and then mutated (
sink(out); out.push(x)) still reads as local mutation. Property and method calls resolve from the receiver's value, whichconstdoes not freeze. - That
unknownis safe. A call Ambit cannot resolve is reported and counted, never folded intopure.--strictmakes it an error. - Enforcement on the Edge runtime. Every hook Ambit installs is a Node.js one, so an Edge route has no capability checked at all.
- Interception beyond four hooks.
fetch,node:fs,node:child_processandpg.mysql2, Prisma and the LLM SDKs have static effects but no hook, so calling them is neither blocked nor recorded. Native addons, child processes, and otherworker_threadsworkers are outside every hook. - That a green
checkmeans the authority did not change.checkvalidates code against the contract currently written, so widening the contract makes it green again — The accident, above. Reviewing the increase isambit diff's job, and the next bullet is what that misses. - That
ambit diffnames the function every increase came from. It compares the symbols both sides extracted, and a handler written inline in argument position —router.post("/x", async (ctx) => { … })— has no name to be one. Every such handler in a file is compared together, underroutes.ts#<inline callbacks>, counting how many of them hold each authority — so an increase inside one is reported, but against the file, and the call path may point at a sibling that already held it. Authority moving between two of them changes no count and is not an increase; it is reported as authority the analysis cannot attribute, which--strictfails on. Binding the handler to a name makes it an ordinary symbol again. A function renamed within a file, or moved in a way git did not report as a rename, reads as a deletion plus a new symbol instead — an over-report, which is the direction the comparison is built to fail in (limitations).check --coverage'sunknown-rateis what says how much was visible in the first place; a greendiffon its own does not. - That an approved increase is a safe one. An approval line in
ambit.approvals.mdrecords that an increase was put in front of a reviewer, in the same pull request, where it can be read. It does not record that the reviewer was right, and Ambit cannot check that a person wrote the line at all — branch protection and aCODEOWNERSentry on the file are what make that true. - Targets finer than the resource. A database target names the database, not the table — Ambit does not read table names out of SQL — and a shell spawn names the shell, not the program inside the command string.
- That
costUsdandllmCallsare enforced. They are parsed and validated. Nothing increments them.
docs/limitations.md has all of this in detail.
Ambit is experimental and not production-ready. It is versioned 0.x, and
semver's 0.x rule is in force: a minor release may make a breaking change —
diagnostic ids, the NDJSON field shape, and everything else on the guaranteed
surface can still move. What that surface is, and what is explicitly not on it,
is DESIGN.md §9.2; every change to
it is announced in CHANGELOG.md. check src over Ambit's own source — 43 files,
422 functions — takes 1.31–1.75 s across five runs; diff HEAD src, which
analyzes two trees, takes 2.18–2.95 s across five runs. Nothing is cached, so a
re-check costs the same. The analysis backend has been measured on a
300-file project (458 ms, 348 MiB peak) as part of choosing it; the CLI on top
of it has not. What is implemented and what is not, milestone by milestone with
the measured numbers behind it, is in docs/status.md.
The analysis runs on the TypeScript Compiler API (typescript 6.0.3, the
JavaScript implementation) rather than the faster native TypeScript 7, for
reasons ADR-0001 records along with what
would reopen the decision.
There is no build step during development: .ts runs directly under Node's
type stripping.
git clone https://github.com/sano-suguru/ambit.git && cd ambit && pnpm install
node src/cli/main.ts check src --coverageThat last command needs nothing prepared — it checks Ambit's own source, and
exit 0 is the fastest evidence a change did what it claimed. It prints the
warnings, a files= functions= declared= summary, and with --coverage the
unknown rate, the skipped nodes and the unresolved names; the current figures are
in docs/status.md.
pnpm test, pnpm exec tsc --noEmit and biome ci . are the rest of the
gate; AGENTS.md is the working agreement, including what belongs
in which document.
- docs/DESIGN.md — the product specification.
- docs/adr/ — why each design is the one in the spec.
- docs/diagnostics/ — the diagnostic code ledger.
- CHANGELOG.md — every breaking change to the guaranteed surface.
- docs/limitations.md — where the analysis is narrower than the model suggests. Corner cases below that level are in docs/analysis-limitations.md.
- docs/status.md — the current measured numbers, and the verdict they support. The runs behind them are in docs/measurements/.
- docs/open-questions.md — what is undecided.
- ROADMAP.md — what has to be proved next.
- CONTRIBUTING.md — how a change is proposed and verified.
MIT licensed; see LICENSE. An ambit is the range of one's authority, which is the thing this tracks. Ambit is one person's experiment: no support commitment, no release schedule yet.