# Crossline

**Prove that no user can see another user's data — automatically, on every commit.**

```bash
npx crossline
```

No configuration. No staging environment. No auth profiles to set up.

```
Crossline 0.2.1  postgres://postgres:***@localhost:54329/app

  ✗ documents
      critical  anyone, signed out, can read, modify, delete  (2 rows reachable)
      critical  any signed-in user can read, modify, delete, forge ownership of

  ✗ tasks
      critical  anyone, signed out, can read, modify, delete  (2 rows reachable)

  57 checks, 14 failing · 8 of 9 tables checked · 11 owner checks passing

  ! 1 table(s) not fully checked:
      audit_events: Ownership could not be determined from the schema, so the
      cross-user rule was not asserted here. Confirm this table in the model.
```

---

## What it does

Crossline reads your schema, works out who is supposed to see what, then creates
two real users and systematically tries to make one reach the other's data —
through your API, and through your database directly, which is where row-level
security holes actually live.

Anything that succeeds is a failing check with the exact request that shouldn't
have worked, and a policy that closes it.

1. **Reads your schema.** Tables, columns, foreign keys, constraints, existing
   policies, and role grants.
2. **Infers ownership.** Which tables belong to one user, which belong to an org,
   which inherit ownership through a foreign key, and which are shared reference
   data. You confirm it once, in about a minute.
3. **Plants known data.** Two users in two different orgs, with rows carrying
   identifiers only Crossline knows.
4. **Crosses every line.** Read, modify, delete, and ownership forgery, as an
   anonymous client and as a signed-in stranger.
5. **Writes the fix.** Generates the policy, applies it to a throwaway copy,
   re-runs the whole suite, and only shows it to you if the holes closed *and*
   every owner still reached their own data. Where the honest answer depends on
   intent — is a note private to its author, or shared with the workspace? — it
   writes the private form and offers the shared one alongside it, both tested,
   and leaves the choice to you.

## Why the findings are trustworthy

The hard part of this problem isn't attempting the request. It's knowing whether
the response was a leak.

Crossline seeds its own data, so it always knows which primary key and which
canary string belong to whom. A finding means a request returned a row we planted
for the *other* user, matched by identifier. There is no similarity scoring, no
heuristic, and no judgement call — which is why the suite comes back completely
clean on a correctly-secured schema.

Four more things keep it quiet:

- **The two users are in different orgs.** The only invariant asserted is that
  two users with no relationship cannot reach each other's rows. That's true of
  every multi-user app. Whether *teammates* should see each other's data needs
  intent, so Crossline doesn't guess at it.
- **Visibility flags are forced private.** A `posts` table with a `published`
  column is legitimately world-readable for some rows, so the seeded row is
  planted unpublished. A row we marked private cannot leak "on purpose". With no
  database to write a column in, the resource is created a second time through
  the application with the flag it showed us set the other way; where the
  application refuses to make one private, nothing is claimed about it at all.
- **A privilege Crossline granted itself is taken back before anything is
  reported.** Seeding is what makes the oracle exact, and it is also the one way
  this tool can invent a hole. `platform_admins (user_id uuid primary key
  references users(id))` is shaped exactly like an ordinary user-owned table, so
  a row went in and promoted the test user to platform administrator before a
  single probe ran — and a correctly-guarded support function then handed over
  every tenant's rows, reported as `high` against a database that does not have
  that hole. So a crossing that succeeds is re-made with those rows withdrawn,
  and only reported if it succeeds again. What counts as "those rows" is settled
  by the database rather than by the table's name: the row is put back as the
  signed-in role, and if the grant or a policy refuses it, no request could have
  produced that state. If the test user *can* enrol themselves, the state is a
  real user's to reach and the crossing stays a finding — the self-enrolment
  being the more serious of the two bugs. Nothing is withdrawn quietly: the
  table, and every check that stopped failing, are printed on the face of the
  result. The persona's own user row, their org, and their membership of it are
  never withdrawn, because those are what make them an ordinary signed-up user
  rather than nobody at all.
- **Silence beats a guess.** When ownership can't be determined from the schema,
  Crossline says so and asserts nothing, rather than inventing a rule.

### When your users don't live in your database

Clerk, Auth0, Cognito and Firebase Auth all leave the same shape behind: the
owner column is a bare `text` holding the provider's `sub`, with no users table
for it to reference. There is no foreign key to follow, so every table would
degrade to "ownership could not be determined" — while the developer had already
written the answer down, in SQL, in the policy beside it:

```sql
create policy "User can view their own tasks" on public.tasks
  for select to authenticated
  using ((select auth.jwt()->>'sub') = (user_id)::text);
```

Crossline reads that. A foreign key is still the stronger evidence and still
wins; policy text fills the gap where there is no key, and where the two
disagree the disagreement is printed rather than silently resolved.

Believing a policy is a new way to be wrong, so what it takes to be believed is
narrow and structural. Permissive policies are OR-ed together by Postgres, so
**they have to be unanimous**: every `USING` and every `WITH CHECK` a signed-in
caller can reach must confine rows to the same single column, and one `using
(true)` beside a perfect policy means nothing is learned — because `true` is what
the database will actually honour. The expression has to *be* an equality between
a column of that table and the caller's own subject claim, after parentheses,
`( select … )` wrappers and casts are peeled; a membership subquery, a nested
claim, an `any(array[…])` or a shape we do not recognise yields nothing. And the
identity has to be proven to answer *who*: a helper qualifies only if its body
reads the `sub` claim, which is what separates `auth.uid()` from `auth.role()` —
they are found by the same structural rule and the second one returns
`authenticated` for everybody.

And one thing that matters just as much in the other direction: **a check that
couldn't be made is never reported as a check that passed.** An attempt that
failed for a reason unrelated to access control — a handler that returned 500, a
trigger that raised, a request that timed out — proves nothing, so it's counted
and displayed separately rather than folded into the pass count. Coverage is
reported in tables as well as checks, because "0 failing" across four tables of a
twelve-table schema is a very different claim from a clean run. A false positive
gets investigated; a false negative gets trusted.

### The share link problem, and what happens instead of a finding

There is one case where "a row we planted for the other user came back" is a
true fact and still the wrong finding. Against an endpoint whose only input is
the row's identifier — `GET /api/public/forms/{id}` — Crossline supplied that
identifier itself, and had it only because it planted the row. Every share link,
invite link, password reset and unsubscribe URL on the internet works exactly
that way. Reporting one as "anyone can read another user's data" is a false
positive against a feature somebody asked for on purpose.

The naive rule — stop counting a read whenever the caller supplied an id — would
suppress insecure direct object reference, which is the most common real
authorization bug there is. So the question is never *was an id supplied*. It is
**could a stranger have obtained it**, and Crossline demotes a granted read only
when all three of these are established from the run itself:

1. **Entropy, read off the schema.** The column's type and default, not the
   value we planted — a `gen_random_uuid()` default is 122 random bits; a
   sequence enumerates; `gen_random_bytes(2)` is 65,536 values and is treated as
   guessable.
2. **Reachability, read off this run.** No request this caller could make handed
   the identifier back. If the app gives the id out, it is not a secret however
   random it looks.
3. **Deliberateness, read off this run.** Another endpoint performs *the same
   lookup* and answers it differently by who asks — it serves the owner and
   refuses this caller. Two id-addressed doors onto one table, one gated and one
   not, is what sharing looks like from outside. A merely scoped *collection* is
   not enough, deliberately: a list showing only your rows beside an item lookup
   showing anybody's is the textbook broken object-level check.

All three, or it stays a finding. What it becomes is not silence either — the
endpoint, the caller and the reason are printed on the face of the result, on a
pass as prominently as anywhere else, because this is the one thing in a run
only the developer can settle:

```
  i 2 endpoint(s) return another user's row to anyone holding the link — confirm that is intended:
      GET /api/public/forms/:id → Form
        Served to anyone, signed out or signed in, who has the identifier.
        The link is the credential: id is a uuid — 122 random bits, which cannot be
        enumerated or guessed from another one.
        Nothing in this run handed that identifier to the caller, and GET /api/forms/:id
        does refuse them — so this reads as a share link rather than a hole. No policy is
        proposed for it.
```

The residual risk is a genuine hole on a resource guarded elsewhere — an
unprotected `/download` beside a protected `/:id` — and it is bounded by the
class being named rather than dropped. This rule is not theoretical: it is the
one that took our own corpus measurement from 17.8% to 6.7%, which is written up
in [`docs/corpus-wave-1.md`](docs/corpus-wave-1.md).

## Install

```bash
npx crossline init   # see the model, confirm it
npx crossline        # run the checks
npx crossline fix    # generate and verify the policies
```

On a Next.js + Supabase project this needs no arguments and no configuration:
Crossline reads `supabase/config.toml` for the port your local stack's Postgres
listens on, which is the only machine-readable database there is in a repository
whose `.env.local` holds the project URL and the anon key and no connection
string at all.

Otherwise, point it at your database with `DATABASE_URL`, `--db`, or
`crossline.config.json`. In precedence order, first one wins:

| | |
| --- | --- |
| `--db <url>` | the flag |
| `crossline.config.json` | the `db` key, usually `"env:DATABASE_URL"` |
| the environment | `CROSSLINE_DB_URL`, `DATABASE_URL`, `SUPABASE_DB_URL`, `POSTGRES_URL`, `POSTGRES_URL_NON_POOLING` |
| the repository | `supabase/config.toml`, then `.env.local`, `.env.development`, `.env` — searched in the current directory, up to the repository root, and one or two levels down into a workspace |

Every run prints which one it used, and where, before it touches anything:

```
Using DATABASE_URL from .env.local (localhost:5432/app_dev)
```

A database found in a file rather than named by you is not one that `--api` or
`fix --apply` will write to unless it is on this machine — their writes are
real and cannot be rolled back. Pass `--allow-remote-db` if you meant it.
Nothing found in a file is ever written into `crossline.config.json`: a file
path is not a name that would resolve the same way in CI.

If **none** of those names a database, and your application does not have one
for Crossline to read — a Rails app on MySQL, a service on DynamoDB —
`npx crossline --api http://localhost:3000` checks it through its own endpoints
instead and needs no connection string. See
[If there is no database Crossline can read at all](#if-there-is-no-database-crossline-can-read-at-all).
A database that *is* named and merely unreachable is an error, never a quieter
check.

### The whole command surface

Nine commands, and no hidden ones:

| | |
| --- | --- |
| `crossline` (or `crossline test`) | run the checks. The default command. |
| `crossline verify` | check a **running** database against what was proved. Read-only — safe to point at production |
| `crossline init` | print the inferred model and write the config file |
| `crossline ci` | write the GitHub Actions workflow that runs this on every pull request |
| `crossline fix` | generate policies, verify each on a throwaway copy, write the ones that passed to `.crossline/fix.sql`. `--apply` also applies them to the database |
| `crossline report` | re-render the last run from `.crossline/last-run.json` |
| `crossline schedule` | run `verify` on a clock, so a change nobody deployed is still caught. `--every <hourly\|daily\|weekly\|cron>`, `--remove`, or nothing to see what is scheduled |
| `crossline enroll` | connect this installation to the hosted service. **The service does not exist yet** — the command reports that it could not reach it |
| `crossline mcp` | run as an MCP server on stdio, for a coding agent to call |

Shared flags: `--db <url>`, `--schemas <list>`, `--read-only`, `--api <url>`,
`--allow-remote-db`.
`test` adds `--json`, `--sarif <file>`, `--markdown <file>`, `--verbose`, and
`--fail-on <severity>` (default `medium`; `none` never fails the build).
`verify` takes only `--db`, `--schemas` and `--json`; it has nothing to
`--read-only`, because it is nothing else.
`fix` adds `--apply` and `--out <file>` (default `.crossline/fix.sql`).
`report` re-renders with `--markdown` or `--sarif` to stdout.
`ci` takes neither a database nor the shared flags — it only reads files — and
adds `--yes` and `--migrations <supabase|prisma|drizzle|sql|none>`.
`schedule`, `enroll` and `mcp` take no shared flags either; `schedule` takes
`--every` and `--remove`.

Exit codes: **0** pass, **1** findings at or above `--fail-on`, **2**
*inconclusive* — and **2** is also what any run that errors out exits with, so
treat it as "no answer" rather than specifically "not established".

Every run writes `.crossline/last-run.json`: the whole report, which is what
`crossline report` and the GitHub Action read back.

## Is the database you deployed still the one you proved?

`crossline` proves isolation **before it ships**, against the schema as written.
It cannot be pointed at production: it plants rows to do its work.

`crossline verify` is the other half, and it is read-only.

```bash
npx crossline verify --db "$PRODUCTION_DATABASE_URL"
```

```
  ✓ The authorization model running here is the one that was proved.
      Every table's row-level security, every policy expression, every grant and every
      SECURITY DEFINER function matches crossline.lock, recorded 2026-08-04 22:41.

  ✓ Nothing in the catalogue is open on its face.

  9 table(s) examined in public, auth. No row was read.
```

This exists because a schema proved correct in CI and a schema running in
production are not the same object. One real database we looked at had **seven
migrations applied by hand through a SQL editor and never recorded** — so a green
build and a leaking database could coexist, and nothing would say so.

**Where the record lives.** A passing `crossline` writes `crossline.lock` into
your repository. Commit it; your agent's next `git add .` already will. It is a
statement about your schema, and your schema is in git — the same reason
`package-lock.json` lives beside `package.json`. That is also what makes it work
on three different machines: CI is not your laptop and production is neither, and
anything written into `.crossline/` (which is gitignored) would be invisible to
the other two. There is no file to copy anywhere.

The side benefit is the one people notice: when a migration drops a policy, the
lock changes **in the pull request**. Nobody reads a migration for the policy that
is no longer there. Everybody reads a removed line in a lockfile.

**What it checks, in two claims of different strength.**

*Has it drifted?* Per table — row-level security enabled and forced, every policy
expression verbatim, every grant, and which roles can execute each
`SECURITY DEFINER` function. Anything that moved is named: a dropped policy, a
widened grant, a table that appeared with no policy at all. Nothing else is in
there, so adding a column or an index reports nothing, by construction.

*Is anything open on its face?* Row-level security off on a table that holds one
user's rows while an ordinary role holds SELECT; a policy whose `USING` is the
constant `true`; a policy whose expression names no column of its own table and
therefore cannot tell one user's rows from another's. Each one is gated on the
schema proving who owns the rows — which is why the `plans` price list in our own
fixture, with `USING (true)` to `anon`, is correct and stays silent.

**What it will not claim.** It cannot prove isolation. It reads the catalogue; it
never plants a row and never crosses a line, so it confirms the model is intact
and nothing is open on its face, and it says exactly that every time it runs. It
also says what it declined to settle: a `SECURITY DEFINER` function reachable by
`anon` runs outside row-level security, and whether it checks its caller is not
readable from the catalogue — `crossline` settles that by calling it with planted
rows, and this command names it and stops.

Exit codes: **0** clean, or no lock recorded yet (a fact about your repository,
not your database — a gate that fires on it gets switched off). **1** for
findings, or for any drift at all, including drift that narrows access: "the
model we proved is the model running" is false either way, and re-running
`crossline` re-records the lock.

### It is read-only, and you can check

Three guards, any one of which would do:

- every statement it sends must begin with `select` — anything else throws before
  it reaches the wire;
- the session runs inside `set transaction read only`, so the **server** refuses a
  write even if the first guard were deleted. Nothing is written and rolled back;
  nothing is attempted at all;
- it reads `pg_catalog` and never `information_schema`, so it works for a role
  that holds nothing but `CONNECT`.

That last one is the point. Make a role that cannot read one row:

```sql
create role crossline_verify login password '…';   -- and nothing else
```

`select * from documents` as that role is `permission denied`. `crossline verify`
run as that role returns byte-for-byte what it returns as the owner. The suite
asserts exactly that.

### Is it safe to run?

The data-plane checks happen inside a single transaction that is **rolled back**.
Seeded rows, attempted updates, attempted deletes — none of it reaches disk.
Crossline leaves your database exactly as it found it.

Three caveats worth stating plainly:

- A rollback does not un-fire a trigger. If your tables have triggers with
  outside effects, use `--read-only`.
- Some tables refuse every row anyone could invent — a format check, a trigger
  enforcing a business rule. Where that happens, and only where it happens,
  Crossline briefly **drops that CHECK constraint or disables that trigger**
  inside the same rolled-back transaction, plants its row, and puts it back
  before it asks the schema a single question. It never touches row-level
  security, a policy, a grant, or a foreign key — those are the thing being
  tested, and no code path exists that could express it. Every table this
  happened to is named in the output. A lock it cannot take immediately, or a
  database user that does not own the table, costs that one table and is
  reported; `--read-only` disables it entirely.
- The **API-plane** checks (`--api`) cannot use that trick, because your app is a
  separate process that has to see committed rows. Those writes are real, and
  they include the session row Crossline inserts to become a logged-in user when
  your app keeps its sessions in the database. Point `--api` at a preview
  deployment or a database branch, never production. Crossline removes
  everything it created — seeded rows, test users, and the session — and reports
  anything it couldn't.

## On a clock

A hole introduced by code shows up when you deploy, and the pull request check
catches it. Somebody running SQL by hand in the Supabase dashboard is invisible
to every deploy hook there is, and only something on a timer ever finds it. One
real database we examined had seven migrations applied exactly that way, none of
them recorded.

```bash
npx crossline schedule --every daily
```

That writes `.github/workflows/crossline-watch.yml`, which runs `crossline
verify` — the read-only half — against the database you point it at. Two things
it cannot do for you, and it says so: commit the file, and add your production
connection string as a repository secret named `DATABASE_URL`. `crossline
schedule` on its own says what is scheduled; `--remove` stops it. A workflow you
have edited is yours, and neither writing nor removing will touch it.

### Or from inside your own application

If you would rather not put your production connection string into CI, the check
can run where the connection already is:

```ts
// app/api/crossline/route.ts
import { watch } from "crossline/runtime";

export const GET = watch();
```

with a cron entry in `vercel.json` pointing at it. The handler is a plain
`(Request) => Promise<Response>`, so Remix, Hono, SvelteKit and Cloudflare
Workers take it unchanged. It needs `CRON_SECRET` set in your deployment and
**refuses to run without one** rather than answering unauthenticated, and it
replies with a verdict and nothing else — no findings, no table names, nothing
about the database when it cannot connect. [docs/inside-your-app.md](docs/inside-your-app.md)
has the detail.

There is a `crossline enroll` command, which connects an installation to a
hosted service that records these results over time. That service is **not
available yet**: the command exists, the exchange is built and tested against a
stub, and running it today tells you it could not reach anything. Everything
above works without it.

## In CI

```bash
npx crossline ci
```

That writes the whole workflow — the throwaway database, your migration step,
`permissions`, checkout, Node, and the Crossline step — into
`.github/workflows/crossline.yml`, and tells you what it read your repository as
so you can check it in the diff:

```
  Run Crossline on every pull request.

    Migrations   Supabase — supabase/config.toml; supabase/migrations/ holds 14 .sql files
    Node         22 — from .nvmrc
    Branch       main — from origin/HEAD

  ✓ Wrote .github/workflows/crossline.yml.
```

It knows four shapes — `supabase/migrations` beside a `supabase/config.toml`,
`prisma/schema.prisma`, `drizzle.config.*`, and plain `.sql` migrations — and
tells the two variants of each apart, because `prisma migrate deploy` on a
project with no `prisma/migrations` exits 0 having applied nothing, and an empty
database is a check with nothing to check. Where it cannot tell, it leaves the
step commented and marked `FILL THIS IN` rather than guessing: an inferred
migration step is the one thing in that file which cannot be verified without
running it, and every finding is a claim about the schema it applies. Pass
`--migrations <tool>` to overrule the guess.

It never overwrites. A workflow that already runs Crossline is left alone and
named; a `crossline.yml` belonging to somebody else is written alongside rather
than over; with nowhere free it prints the file and leaves the writing to you.
Coding agents can do the same through the `crossline_setup_ci` MCP tool.

The step itself, for a workflow you would rather assemble by hand:

```yaml
- uses: jdora09769-bot/crossline@v1
  with:
    database-url: postgres://postgres:postgres@localhost:5432/postgres
    fail-on: high
```

Results are uploaded as SARIF, so findings appear in GitHub's Security tab, and
posted as a pull request comment. See
[`docs/example-usage.yml`](docs/example-usage.yml) for the fully assembled
version.

`fail-on` is the one knob that decides whether a build goes red, so the ranking
behind it is worth stating rather than leaving to be discovered:

| | |
| --- | --- |
| `critical` | anyone signed out reaching a row that belongs to someone, and any signed-out write at all; a signed-in stranger modifying or deleting a row |
| `high` | a signed-in stranger *reading* a row that belongs to someone; forging ownership on insert; a signed-out read of data that belongs to nobody in particular |

`medium` exists as a floor and nothing currently lands there: the only
classifications left below `high` are ones whose cross-user reads are the
intended behaviour, so they never become findings at all.

The consequence that matters: **a row that belongs to an org, or that is reached
through a membership roster, counts as belonging to someone.** A cross-tenant
read is ranked `high`, not `medium`, so it fails a `fail-on: high` build. That
is deliberate, and it is the kind of thing that quietly stops working — there is
a test whose whole job is to assert that no cross-user read is ranked below
`high` anywhere in the model.

## For coding agents

Crossline ships an MCP server, so the agent that wrote your access control can
check it before it commits.

```json
{
  "mcpServers": {
    "crossline": {
      "command": "npx",
      "args": ["-y", "-p", "crossline", "crossline-mcp"],
      "env": { "DATABASE_URL": "postgres://..." }
    }
  }
}
```

Nine tools. Five are about a check: `crossline_check`, `crossline_explain`,
`crossline_fix`, `crossline_declare`, and `crossline_verify` — the last asks
whether the running database still matches what was proved, without re-proving
it, and it is the only one that may be aimed at production.

Two set things up. `crossline_setup_ci` writes the GitHub Actions workflow
described above, so an agent asked to set this up does it properly rather than
improvising YAML; it returns the workflow as text by default, only saves it with
`write: true`, and will not overwrite an existing workflow either way.
`crossline_schedule` does the same for the clock — show, set, remove — and
refuses to touch a workflow it did not write.

The last two, `crossline_enroll` and `crossline_enroll_status`, connect an
installation to the hosted service. **That service does not exist yet**, so they
report that they could not reach it; their descriptions say so, and say to use
`crossline_schedule` instead, which needs no service at all.

`crossline_declare` is the one that reads the *agent* rather than the
repository. It wrote the endpoint, so it knows three things no file records: the
route, a body the handler accepts, and — for a Next.js server action — the
argument list, which `next build` does not write down. Those are the two
surfaces that were named and unchecked without it, and both are described under
[a framework Crossline can't read](#a-framework-crossline-cant-read). What is
declared is treated as data, not truth: joined to what discovery found, reported
when it contradicts the parser, and unable to move the bar for a finding.

The first three read `crossline.config.json` from the project directory, so an agent gets
the same two planes a human does: the database always, and the running app when
`api.target` is set (or passed as the `api` argument, with `appDir` if the route
handlers live somewhere other than the project root). That second plane is the
whole answer for a Prisma, Drizzle or Express app, where authorization lives in
route handlers and there may be no row-level security at all — and its requests
write for real, so it wants a dev server or a preview deployment, never
production. With nothing configured the run stays database-only and says so
rather than guessing a URL.

**Sign-in credentials** — the JWT secret, the Supabase secret key, the header
template — are only ever read from the config file. There is no tool argument
that carries them, so an agent cannot hand one over even by mistake, and they do
not appear in a response: the tests assert that a signing secret, a replayable
session token, and a password in a target URL are all absent from every tool
result. The database connection string is the one exception, because `db` *is* a
tool argument; a password in it is masked on the way out, but if you would
rather it never reached the tool at all, put it in `crossline.config.json` as
`env:DATABASE_URL` and leave the argument unset.

Add this to your `CLAUDE.md` or equivalent:

> After changing any migration, RLS policy, or data-access code, call
> `crossline_check` before committing.

Writing that config file by hand is the step that stops this being a default.
`server.json` at the repository root is the official MCP Registry entry that
removes it; what the registries require, and what has to be true before the
entry can be published, is in
[`docs/mcp-registry.md`](docs/mcp-registry.md).

## Configuration

`crossline.config.json` is optional. It exists for two things: correcting the
inferred model, and pointing at a running app.

```json
{
  "db": "env:DATABASE_URL",
  "schemas": ["public"],
  "overrides": {
    "public.posts": { "classification": "public" }
  },
  "roles": { "anonymous": "web_anon", "authenticated": "app_user" },
  "api": {
    "target": "http://localhost:3000",
    "auth": { "kind": "hs256_jwt", "secret": "env:SUPABASE_JWT_SECRET" }
  }
}
```

Every key under `api` is optional too, including `api` itself. On a database
that enforces nothing of its own, Crossline starts the application and detects
the auth provider without any of this; `target`, `server` and `auth` are for
correcting what it worked out, or for a stack it could not.

Only tables Crossline was unsure about get written into `overrides`, so inference
stays live for everything else and a table added next week is still classified.

`userTable` names the table that holds your application's users, e.g.
`"core.app_users"`. Normally it is found without help: `auth.users` when
present, otherwise the table that other tables point owner-shaped columns at
(`user_id`, `author_id`, `created_by`, …), corroborated by name, email, and
credential columns. `init` states which table it chose and why, and writes the
answer into the config so correcting it is a one-line edit rather than a
setting you'd have to know existed. A configured name that doesn't exist is an
error, never a silent fallback.

This is the decision everything else rests on: with the wrong user table,
ownership can't be inferred for anything, every table degrades to shared or
unknown, and the run has nothing left to assert. So a run that can't identify
one is **inconclusive** rather than clean — as is any run that ends with zero
tables actually checked.

`roles` is the database roles your requests actually run as. These are inferred
from grants and existing policies — `anon`/`authenticated` on Supabase,
`web_anon`/`app_user` on a stock PostgREST deployment — and only need setting
when the names are unconventional enough that inference can't tell which is
which. When that happens Crossline says so and lists the roles it found, rather
than guessing and testing as the wrong identity.

Classifications: `user_owned`, `org_scoped`, `child_owned`, `membership`,
`reference`, `public`, `unknown`.

`"mode": "read_only"` is `--read-only` written down, for a repository where it
should always apply: no write, delete or forgery probe is attempted on either
plane, and the report says `[read-only]` on its own header line so nobody reads
the narrower run as the full one.

Two more keys exist for the awkward corners of `api.auth`, and are worth knowing
before you conclude something is unsupported: `hs256_jwt` takes an optional
`anonKey` sent alongside the token, and `authjs_session` takes `columns`
(`token`, `user`, `expires`) for an adapter that named them differently, plus a
`template` if the session travels somewhere other than the default cookie.

One more key, for the rare table that genuinely cannot be checked — a primary
key no oracle can point back at, a constraint no generated row can satisfy:

```json
{
  "acceptUnchecked": ["public.pulses"]
}
```

A private table that goes unchecked makes the whole run **inconclusive** (exit
2), because "no user can reach another user's data" was not established for it.
Listing it here records that a human saw the gap and chose to proceed: the
verdict can pass again, but the table stays in the coverage list on every run.

**One case is deliberately not that**, and it used to be the commonest reason
anyone ever reached for this key. On a run where the API plane is the only thing
that could settle anything, a private table that **no discovered endpoint
addresses** is not a check that failed — it is outside the surface the claim is
about, and the tick already says *through the application*. Every auth library
brings tables like that (`users`, `sessions`, `accounts`), no application has
CRUD endpoints for them, and on Supabase they never showed at all because
`auth.users` lives outside the scanned schemas.

So the run says it instead, on the face of the pass, every time:

```
  ! What this run did not establish
      Nothing was asked of public.accounts, public.sessions, public.users. None of
      the 4 endpoint(s) discovered addresses those tables, so there was no request
      to make and no crossing was settled there either way — public.sessions and
      public.users are owned by your sign-in mechanism rather than by the
      application, which is why no route serves them. If an endpoint does serve one
      under a path that does not name it, the route list below shows which
      endpoints resolved to no table; naming it under `routes` in
      crossline.config.json gets it checked like any other.
```

That is stricter than what it replaced, not looser. Blocking pushed developers
towards `acceptUnchecked`, and a table named there is silenced permanently — so
an `invoices` table served by `/api/billing/:id`, a path no table name matches,
would have been written into the config once and never asked about again. The
statement above is re-read on every run and points at the key that gets the table
*checked*. It applies only where the data plane structurally could not run, the
API plane did carry the claim as a signed-in user, route discovery read every
registration it saw, and no discovered route resolves to the table. A table an
endpoint *does* serve, whose checks then collapsed, blocks exactly as before.

## What it supports

| | | Exercised in the suite against |
| --- | --- | --- |
| Databases | Postgres | plain Postgres, a Supabase-shaped schema, and a real pgbouncer in transaction mode |
| Schema shapes | composite primary keys, keys that are not `uuid`, tables outside `public`, arrays and enum arrays, `CHECK` constraints, foreign-key cycles, `SECURITY DEFINER` functions, and **partitioned tables** — range- and list-partitioned, where the policy belongs on the parent | a fixture per shape, each in a leaky and a corrected form: the leak fully caught, the corrected twin completely clean, and the generated policy applied to a throwaway copy and re-tested before it is offered. Every table that cannot be seeded is named as unchecked rather than counted as passing |
| Auth | Supabase Auth with no secret key at all (publishable key only), Supabase Auth via the Admin API (asymmetric or legacy secret), any HS256 bearer token, Auth.js database sessions, Clerk, Auth0, Cognito, Firebase Auth, custom headers — detected from `package.json` and `.env` where possible | a real `gotrue` in Docker, three times, once of them with `MAILER_AUTOCONFIRM=false` as a hosted project is; a real Auth.js session table; the four external providers against stubs of their documented endpoints, end to end through a leaky app and its corrected twin, but never against a live tenant; detection covered per provider, including the refusals (a Clerk production instance, Auth.js's JWT strategy); the header template is implemented but has no test of its own |
| API routes | Next.js App Router, Next.js Pages Router, SvelteKit, Nuxt/Nitro, anything emitting OpenAPI 3.x (Nest, FastAPI, Django REST, Go), Express-style, plus routes you list — read out of each framework's own route table, so `basePath`, router prefixes and global prefixes are included | the real `next` and `express` packages; a checked-in build from Next 15.5.22, plus 13.5.11 and 14.2.35 manifest shapes; real artifacts from @sveltejs/kit 2.63.0, nuxt 4.5.1 / nitropack 2.13.4, FastAPI 0.141.1, @nestjs/core 11.1.28 with @nestjs/swagger 11.4.6 |
| API routes Crossline declines | Rails, Laravel, Django URLconfs, Remix — each named in the report with the command that answers and where to put the result, because the only exact answer boots your application | a fixture per framework asserting it is named, never silently zero |
| Monorepos | workspace packages, from `pnpm-workspace.yaml`, `package.json` `workspaces`, or `turbo.json` | a repository root that finds exactly what the app's own directory finds, end to end against the running app |
| Starting your app | `dev`, or `start` with no build step; any command you name | three skeleton projects — Next + Prisma + Auth.js, Next + Drizzle + Supabase, Express + raw `pg` — each started by the binary from a bare `crossline --db …` and each reaching exit 0 with no configuration file at all |
| Output | terminal, JSON, SARIF, markdown | all four |

Neon and RDS are not in that right-hand column and are not claimed as tested.
What is tested is the mechanism they need — TLS negotiation against a server
that may or may not speak it, and a pooled connection string — because those are
the two places connecting to managed Postgres actually failed.

The data plane works against any Postgres, with or without Supabase. Role
impersonation uses `SET LOCAL ROLE` plus the `request.jwt.claims` setting, which
is exactly how PostgREST evaluates a request — so policies are tested as they
actually run, not approximated.

### If your authorization lives in application code

Prisma, Drizzle and raw `pg` all default to one connection string, one trusted
database role, and no row-level security — every decision is a
`where: { userId: session.user.id }` in a route handler. Crossline does **not**
report that as a pile of exposed tables. There is no second database principal
in that architecture, so "user A" and "user B" are not database identities and
the data plane has no line to cross; announcing a hole there would be reporting
something that isn't true as stated.

The claim is still perfectly testable, just on the other plane — asking as one
user for another user's row is a fact whichever layer enforces the answer. So:

```bash
npx crossline
```

That is the whole command. On a database with no authorization model of its own,
Crossline **starts your application itself** rather than asking you to: it reads
the start command off `package.json`, runs it, waits for a port it chose, and
stops it afterwards. It works out how to be a logged-in user the same way — from
your dependencies and your `.env` — because unlike the connection string, the
auth provider genuinely is written down on disk. Both are stated on the face of
the result, because running a command out of somebody's `package.json` is not a
thing to do silently:

```
  App: started your application with `npm run dev` (package.json has a `dev` script)
       and waited for http://127.0.0.1:52324.
  API: 4 of 4 discovered route(s) checked · 2 of 5 tables reached through the app
  Signed in as the test user with an Auth.js database session planted in
  public.sessions — next-auth is a dependency and the adapter's session table is
  in the schema.
```

Nothing is started when the database *does* enforce its own authorization: the
data plane can answer there, so no command is run. Nothing is started for a
project that does not serve HTTP either — a web framework has to be a runtime
dependency, not a `devDependency`. `--api http://localhost:3000` still points at
a server you are already running, and one already listening at a configured
`api.target` is reused rather than duplicated.

A run that could neither reach nor start an application comes back
**inconclusive** (exit 2) and says why, because it genuinely established nothing.
A run that reached your endpoints and settled the crossings can pass — and says,
on the face of the passing result, exactly which claim it did not make:

```
  ✓ No user can reach another user's data through the application.

  26 checks, 0 failing · 3 tables checked · 5 owner checks passing

  ! What this run did not establish
      The database enforces nothing itself: no row-level security, and no
      database role for a request to run as other than the application's own.
      So nothing here is a claim about a direct database connection — anything
      holding this connection string reads and writes every row, whether that
      is a leaked .env file, an edge function, or an analytics job. What was
      checked, and what the result above is about, is the application: asking
      as one user for another user's rows.
```

That is deliberately not a finding and deliberately not silence. If roles with
real access to your tables *do* exist and Crossline can't tell which is which,
that is a different situation — the data plane applies and did not run, so the
run stays inconclusive no matter how clean the API result was.

#### Starting your app

Playwright's `webServer`, to the letter, because it is the proven shape for this
and because a developer who has configured one has configured this:

```jsonc
{
  "api": {
    "server": {
      "command": "pnpm dev",              // read off package.json when omitted
      "url": "http://localhost:3000",     // a free port is chosen and passed as $PORT when omitted
      "timeout": 120000,
      "reuseExistingServer": true
    }
  }
}
```

Every field is optional, and `"server": false` refuses to start anything at all.

The command is inferred only where the answer is unambiguous: a `dev` script, or
a `start` script in a project with no `build` script — which is what an Express
service looks like, and where `start` cannot mean "serve a build that does not
exist yet". A `start` beside a `build` is exactly the ambiguous case, so it is
asked rather than guessed at: running somebody's build because a test wanted a
server is not on offer. Whatever is chosen is printed with the reason it was
chosen, and a command that dies is reported with its own last output rather than
as a timeout.

#### Being a logged-in user

The check that matters most is one signed-in customer reaching another's rows,
so Crossline has to be able to *be* somebody. It will not ask you for a
credential and it will not forge one — but it will look, because which provider
an application uses is written down in `package.json` and in the `.env` beside
it:

| found on disk | what happens |
| --- | --- |
| `next-auth` / `@auth/*` **and** a session table in the schema | a session row is planted; no secret needed |
| `next-auth` and *no* session table | refused, naming `AUTH_SECRET` — that is the JWT strategy, whose cookie Crossline will not forge |
| `@supabase/supabase-js` and `SUPABASE_JWT_SECRET` | an HS256 token is minted |
| `@supabase/supabase-js`, `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_ANON_KEY` | your project's Auth server issues a real session — **no secret key needed**, and this is preferred even when a secret key is also set |
| the same, but your users are not in `auth.users`, plus `SUPABASE_SECRET_KEY` | your project's Auth server is asked for a real session through the Admin API |
| `@clerk/*` and `CLERK_SECRET_KEY` | Clerk's Backend API is asked for a session — see below. A `sk_live_…` key is refused, naming why |
| `@auth0/*` with `AUTH0_DOMAIN`, `AUTH0_CLIENT_ID`, `AUTH0_CLIENT_SECRET`, `AUTH0_AUDIENCE` | a user is created through the Management API and a token obtained for them |
| a Cognito SDK with `COGNITO_USER_POOL_ID`, `COGNITO_CLIENT_ID`, and AWS credentials exported **or** in `~/.aws/credentials` | `AdminCreateUser` then `AdminInitiateAuth` |
| `firebase` and a Web API key | an account is created through the Identity Toolkit; no secret needed at all |
| no auth package, but a session table storing its token in plaintext | a session row is planted anyway — that is a fact off the schema, not a guess about the stack |
| none of the above | refused, naming what was looked at |

**Nothing has to be exported.** The `.env` files searched are the ones Next.js
itself loads — `.env.local`, `.env.development.local`, `.env.development`,
`.env` — and they are searched the same way the connection string is: the
directory you ran in, then out to the repository root, then back down into it.
So `crossline` typed at a monorepo root finds the key in `apps/web/.env.local`,
and the run prints which file it came from. Amazon's is the one credential that
conventionally lives outside the repository, so `~/.aws/credentials` and
`~/.aws/config` are read too, under `$AWS_PROFILE` or `default`; a profile that
authenticates through SSO, `role_arn` or `credential_process` is skipped rather
than half-read.

A live environment variable beats the same name in a `.env` file, which is what
CI relies on, and both beat the shared AWS files. Detection never invents
anything: a provider found without its material is a refusal that names the
missing variable, never a fallback to something weaker that would send
meaningless requests under a signed-in label.

Nothing found this way is ever written down. It is not recorded in
`crossline.config.json`, in `.crossline/last-run.json`, in a report, in a
reproduction command or in a log line — only the *name of the file* it came
from is. That is why `crossline.config.json` still refuses a literal secret and
takes only `env:NAME`: that file is committed, and a value in it is a value
pushed to GitHub, whereas a gitignored `.env.local` your own application already
reads is not a secret this tool moved anywhere.

Where detection is wrong, or where none applies, say so explicitly:

```jsonc
// crossline.config.json
{
  "api": {
    "target": "http://localhost:3000",
    "auth": { "kind": "authjs_session" }        // Auth.js / NextAuth, database sessions
    // "auth": { "kind": "supabase_anon", "url": "env:NEXT_PUBLIC_SUPABASE_URL",
    //           "publishableKey": "env:NEXT_PUBLIC_SUPABASE_ANON_KEY" }   // no secret key
    // "auth": { "kind": "supabase_admin", "url": "env:SUPABASE_URL",
    //           "secretKey": "env:SUPABASE_SECRET_KEY",
    //           "publishableKey": "env:SUPABASE_PUBLISHABLE_KEY" }
    // "auth": { "kind": "hs256_jwt", "secret": "env:JWT_SECRET" }
    // "auth": { "kind": "header", "template": { "authorization": "Bearer {{userId}}" } }
  }
}
```

`hs256_jwt` used to be called `supabase_jwt`, which told a Prisma or Drizzle
developer it was somebody else's setting when it is the only strategy that fits
them — an ordinary bearer token whose `sub` is the user id. The old name still
works and behaves identically.

`{{userId}}` and `{{email}}` are substituted in any header template. The
`header` kind is the escape hatch for a stack none of the others fit; it is
implemented and it is the same substitution path `authjs_session` uses for its
own template, but it has no test of its own, so treat it as the least-proven of
the four.

`hs256_jwt` is the only place Crossline mints a token. The registered claims are
the ordinary ones — `sub` is the user id, which is what every HS256 stack reads —
and three Supabase-specific ones (`role` and `aud` of `authenticated`, plus
`app_metadata`) ride along beside them, because PostgREST needs them and nothing
else looks at them. So it is not a Supabase-only mode: it is a standard token
that also satisfies Supabase, and it is the right answer for a hand-rolled
Prisma or Drizzle app that validates a bearer token. An application that signs
HS256 and reads *different* claims than `sub` needs `header` instead.

**Which Supabase one?** Projects created from 1 October 2025 sign sessions with
an asymmetric key (ES256 by default); the shared JWT secret is legacy. Crossline
cannot sign an ES256 token and would not take your private key if offered it, so
on those projects `hs256_jwt` produces a token the project rejects outright —
a real Auth server answers `403` with `bad_jwt`, every request fails at the
door, and the run says it established nothing. Use
`supabase_admin` there. It signs nothing: it asks your project's Auth server to
issue a real session for a test user Crossline created, and sends that. The
identity stays exact because the token grant's own response names the user it
was issued for, and a session issued for anyone else is refused rather than
used. `supabase_admin` works on legacy projects too, so it is the safe choice if
you are unsure.

##### Supabase without a secret key

**`supabase_anon` is the mode to use, and it asks for nothing secret.** The two
values it needs — your project URL and its publishable (`anon`) key — are
already in `.env.local` and already shipped in the JavaScript bundle your app
sends to every visitor. If they are set, Crossline picks this mode on its own,
ahead of a secret key that is also present.

It works because the `service_role` key was only ever buying two facts: that the
test persona has a password Crossline knows, and that its email counts as
confirmed. Both of those are *columns on `auth.users`* — and Crossline is
already connected to the database that holds them, with enough privilege to have
planted the persona rows there in the first place. A connection string is
strictly more powerful than a `service_role` key, so asking for the key as well
bought nothing. Crossline writes the two columns (bcrypt, via pgcrypto, on the
two rows it planted and nowhere else) and then signs in at
`POST /auth/v1/token?grant_type=password` carrying only the publishable key —
the same request, with the same key, that your own sign-in page makes.

The identity bar does not move: that response names the user the session belongs
to, and a session issued for anybody else is refused rather than used.

What it deliberately does *not* do is call `POST /auth/v1/signup`. That endpoint
does work with the publishable key alone, but it assigns the user id itself — so
that user would own none of the planted rows — and on a hosted project ["Confirm
email"](https://supabase.com/docs/guides/auth/passwords) is on by default, so it
returns a user and *no session*. It would also spend the built-in mailer's
default budget of [two emails per
hour](https://supabase.com/docs/guides/auth/rate-limits), which a test that runs
on every commit cannot afford. All three of those are proved rather than
asserted: the suite runs a real `gotrue` with `MAILER_AUTOCONFIRM=false` and
checks that the signup endpoint returns 200 with no `access_token`.

Where it cannot do the work — no database connection, users that do not live in
`auth.users`, no `encrypted_password` column, or no pgcrypto and no right to
install one — it declines and names which, rather than falling back to something
weaker. Each of those refusals has a test.

##### Supabase with one, when the users are elsewhere

`supabase_admin` remains for the case the above cannot cover: an application
whose users are its own table rather than `auth.users`, where there is no
Supabase password column to write. It is then the one place Crossline takes a
credential: `secretKey` is your project's secret (`service_role`) key, and it
must be written as `env:NAME` — Crossline will not read it out of a file meant
to be committed. It is sent to your Supabase Auth server and to nothing else:
never to your application, so it cannot reach a reproduction command,
`.crossline/last-run.json` or a report, which are built only from the headers
the probes actually sent.

`authjs_session` needs no secret and no cryptography. An Auth.js database
session *is* a row: the adapter stores the session token in plaintext and that
stored value is the cookie your browser sends back, so Crossline inserts a
session for a user it already created and makes requests as that user. Same
guarantee as everywhere else — we created the session, so "this request is
Alice" is a fact rather than a guess. The session table is found from your
schema; name it under `api.auth.table` if it can't be identified with certainty,
and Crossline says so rather than picking one. Two of those refusals are proved
by tests — no session table at all, and a table whose user column points
somewhere else. The branch for *two* plausible session tables is written but no
fixture reaches it, so treat "it will name the ambiguity rather than guess" as
implemented and unproven.

One case is out of scope by design, because it'd mean handling your real signing
key: Auth.js's *JWT* strategy, whose cookie is encrypted with `AUTH_SECRET`. It
is detected, and the failure message names `AUTH_SECRET` and says Crossline will
not forge it.

#### When the users are somebody else's

Clerk, Auth0, Cognito and Firebase Auth sign with keys that never leave the
provider, so nothing can be *minted* for them. All four will *issue* a session
for a user you create, which is what Crossline does:

| provider | the flow |
| --- | --- |
| Clerk | `POST /v1/users` → `POST /v1/sessions` → `POST /v1/sessions/{id}/tokens` → `Authorization: Bearer <jwt>`. [Testing docs](https://clerk.com/docs/guides/development/testing/overview), shapes from the published [OpenAPI spec](https://github.com/clerk/openapi-specs). `POST /v1/sessions` is *"intended only for use in testing, and is not available for production instances"* — so a `sk_live_…` key is refused up front rather than failing halfway |
| Auth0 | client-credentials token → `POST /api/v2/users` → password-realm grant → `GET /userinfo` |
| Cognito | `AdminCreateUser` → `AdminSetUserPassword` → `AdminInitiateAuth` (`ADMIN_USER_PASSWORD_AUTH`) → `GetUser` |
| Firebase | `accounts:signUp` → `accounts:lookup`. The Web API key is public — this one needs no secret |

**The order of the run changes for these, and that is the whole difficulty.**
Every one of them assigns the user id. Crossline normally generates the persona
ids and then plants rows under them; done that way the provider's session would
belong to a user who owns none of the planted rows, every probe would come back
empty, and empty is what a correctly-secured app returns — a green tick over a
check that never ran. So the provider's users are created **first**, their ids
*become* the personas' ids, and only then is anything seeded. `becomePersonas`
refuses these strategies outright, because by the time it is called the ids are
already wrong.

**The identity is the provider's own statement.** Clerk's session object names
a `user_id`, Auth0's `/userinfo` a `sub`, Cognito's `GetUser` a `sub`
attribute, Firebase's `accounts:lookup` a `localId` — and if any of them names
anybody but the account we asked for, nothing is claimed and the run says so. A
200 is not proof.

**What this is tested against.** A stub implementing each provider's documented
endpoint shapes, end to end: a leaky app behind it is caught as two genuinely
different signed-in users and its corrected twin is completely silent; a
provider that refuses to issue a session checks nothing and says why. It has
**not** run against a live Clerk instance, Auth0 tenant, Cognito pool or
Firebase project, and two questions are open: whether a Bearer-only request
with no `__client` cookie resolves to signed-in at Clerk rather than triggering
its handshake redirect, and whether Clerk honours or caps `expires_in_seconds`.
Both would surface as the owner check failing, not as a false finding.

Every admin key is `env:NAME` only, is sent to its own provider and to nothing
else, and never reaches a report, a reproduction command or `last-run.json`.

##### Why three of these still need a key, and Supabase and Firebase do not

Asking a founder to go and find an admin key is the thing that stops a tool
being installed, so the obvious question is whether the *public* key each of
these providers already hands the browser — the one in `NEXT_PUBLIC_…`, shipped
in the bundle, designed to be seen — is enough to sign two test users up the
same way the app's own sign-up page does. It was investigated properly. The
answer is yes for Firebase, yes for Supabase by a different route, and **no**
for Clerk, Auth0 and Cognito. The reasons are specific and worth writing down,
because they are the kind of thing that looks solvable until you check:

- **Clerk — blocked by bot protection, and we will not defeat a bot check.**
  The publishable key is not a credential at all: it is your Frontend API
  hostname in base64 (`pk_test_ZXhhbXBsZS5hY2NvdW50cy5kZXYk` decodes to
  `example.accounts.dev$`), and [FAPI carries no key in any header or query
  param](https://clerk.com/docs/guides/how-clerk-works/overview) — its own
  client sends none. Email verification is not the obstacle either: Clerk
  documents [test addresses](https://clerk.com/docs/guides/development/testing/test-emails-and-phones)
  (`…+clerk_test@…`) that verify with a fixed code and need no credential. The
  obstacle is [bot sign-up protection](https://clerk.com/docs/guides/secure/bot-protection):
  when it is on, `POST /v1/client/sign_ups` answers `requires_captcha` /
  `captcha_invalid`, and the only sanctioned way past it is a [Testing
  Token](https://clerk.com/docs/guides/development/testing/overview) minted
  through the Backend API — which needs the secret key, putting us back where we
  started. Solving the Turnstile challenge instead would be defeating a bot
  check, which is not something this project will do. So Clerk needs
  `CLERK_SECRET_KEY` — which is not a thing to go and find: `@clerk/nextjs`
  cannot start without it, so it is already in `.env.local` and Crossline reads
  it from there. On a Clerk app whose authorization lives in the database rather
  than in handlers, the data plane covers it without one.
- **Auth0 — user creation is public, tokens are not.**
  [`POST /dbconnections/signup`](https://auth0.com/docs/api/authentication/signup/create-a-new-user)
  genuinely needs only the connection name and the public `client_id`. Getting a
  *token* for that user does not follow: Auth0 [creates public applications
  with](https://auth0.com/docs/get-started/applications/application-grant-types)
  `implicit`, `authorization_code` and `refresh_token` only, so the password
  grant is off by default and can be turned on only from the dashboard or the
  Management API. The alternative, [cross-origin
  authentication](https://auth0.com/docs/authenticate/login/cross-origin-authentication),
  is browser- and third-party-cookie-bound. A user we cannot become is no use.
- **Cognito — sign-up is public, confirmation is not.**
  [`SignUp`](https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_SignUp.html)
  is explicitly an unauthenticated operation and works with a public app client
  and no secret. But the new user is `UNCONFIRMED`, and ["Users who sign
  themselves up must be confirmed before they can sign
  in"](https://docs.aws.amazon.com/cognito/latest/developerguide/signing-up-users-in-your-app.html) —
  every route from there to a token (`AdminConfirmSignUp`, a pre-sign-up Lambda,
  turning off Cognito-assisted verification) needs admin access, and the emailed
  code is not something a test can read.
- **Firebase — already needs nothing.** [`accounts:signUp`](https://firebase.google.com/docs/reference/rest/auth)
  with the Web API key returns an `idToken` and a `localId` immediately, with no
  verification step in the path. This is the shape the others were checked
  against.

This was settled from the providers' own documentation and, for Clerk, from its
open-source client. It has **not** been run against a live Clerk instance, Auth0
tenant or Cognito pool, so treat the three "no"s as well-evidenced rather than
observed. If Clerk's bot protection turns out to be off on a given instance, the
sign-up path there would open up — the instance reports that itself, in
`display_config.captcha_widget_type` on the unauthenticated `/v1/environment`
response, so it is a fact that could be read rather than guessed at. Nothing in
Crossline reads it today.

Given none of these, Crossline **does not send the signed-out request under a
signed-in label**. It emits no signed-in probes at all, says so, and the run is
inconclusive — half a suite that looks like a whole one is worse than a missing
one.

#### A framework Crossline can't read

Discovery asks each framework for the route table it already wrote down —
Next.js's build manifest, SvelteKit's `route_meta_data.json`, Nitro's resolved
route types, or an `openapi.json` from Nest, FastAPI or Django REST — and reads
Express-style registration out of source, best-effort, because Express writes
nothing down.

The frameworks that will only answer by being run are declined **by name** in
the report, with the command that answers and where to put the result: `rails
routes`, `php artisan route:list` and `manage.py show_urls` each boot the
application, run its initializers and connect to whatever database the
environment points at, and a test meant to run on every commit does not do that
to the project it is checking. For those, and for anything else discovery
misses, list the endpoints and they are checked like any others:

```jsonc
{ "routes": ["GET /api/documents/:id", "PATCH /api/documents/:id", "DELETE /api/tasks/:id"] }
```

They're added to whatever discovery finds, not swapped for it. A malformed entry
is an error, never a silent skip.

Two things a path alone can't carry, for the endpoints that need them:

```jsonc
{
  "routes": [
    // The URL doesn't name the table it serves, so nothing tells the run whose
    // row to ask for. Without `resource` this endpoint is reported unreached.
    { "method": "GET", "path": "/v2/records/:id", "resource": "public.documents" },
    // The handler validates its body. Without a body it accepts, the *owner's*
    // own write is rejected too — and an endpoint whose owner-proof failed
    // settles nothing, correctly and uselessly.
    { "method": "PUT", "path": "/v2/records/:id", "resource": "public.documents",
      "example": { "kind": "note" } }
  ]
}
```

`example` is merged *underneath* the fields a probe needs — the identifier it is
crossing with and the marker it plants — so it can satisfy a validator and
cannot overwrite what the check is about. A `resource` no table in the schema
answers to is an error, not a hint: falling back to the path would check a
different resource than the one that was declared.

#### Next.js server actions

A server action is a POST to its page's own URL carrying an opaque id in a
`Next-Action` header. Nothing in `app/` describes one, so route discovery finds
none of them — and on an application whose mutations are all server actions,
that is the entire write surface. Crossline reads the ids out of
`.next/server/server-reference-manifest.json`, so it can name every one exactly,
with its URL, its source file and its export name.

What the build does **not** record is the argument list, and that is the one
thing needed to call one. Guessing it is not on offer: a wrong shape is rejected
by the framework before the action's own body decides anything, and reading that
rejection as a refusal would score an exception as authorization. So the shape
is told to Crossline by whoever wrote the action:

```jsonc
{
  "serverActions": [
    // getDocument(id)
    { "exportedName": "getDocument", "source": "app/documents/actions.js",
      "args": ["{id}"] },
    // renameDocument(id, title)
    { "exportedName": "renameDocument", "source": "app/documents/actions.js",
      "args": ["{id}", "{marker}"] }
  ]
}
```

`{id}` is replaced with the identifier of the row being crossed to, `{marker}`
with a value the run mints and then looks for in the database — so an action
carrying `{marker}` is checked as a write and settled against the rows, never
against the reply. Both are substituted anywhere they appear, including inside a
nested object. An action with no `{id}` is checked as a collection read. Keyed
on the export name and file rather than the id, because the id is a hash of the
built module and changes on every build.

Coding agents declare both of these through the `crossline_declare` MCP tool
rather than by editing the file — the agent that wrote the handler is the one
that knows the route, the body and the argument list. It is saved to
`crossline.config.json` either way, so the same knowledge serves the next run,
the human, and CI.

**A declaration widens reach and does not lower the bar.** It is joined to what
discovery found rather than replacing it: a duplicate is probed once, a declared
method at a path the parser read differently is reported, and a declaration
naming an export the build doesn't have is reported and not called. Everything
that decides whether a finding exists is unchanged — the owner's own identical
request must land first, the evidence is still a row Crossline planted, and a
write is still settled against the database.

An action nobody described stays named and openly unchecked. That is the right
answer, not a gap to fill with a guess: an action taking a `FormData` can't be
expressed as a JSON argument list at all, and a guessed shape produces a 500 the
run would have to score as nothing.

**The oracle is narrower here, by construction.** A server action's reply is a
flight stream carrying the re-rendered page, so the identifier that was sent
comes back inside it on every call — on a leaky app and a correct one alike. An
oracle that accepted "an identifier belonging to the other user appeared in the
response" would report a leak against every server action ever written. So on
this surface the only admissible evidence is the seeded canary, checked for
absence from the whole outgoing request by plain containment; the primary key is
never consulted, and there is no branch in which it could be. A table with no
canary column settles nothing here and says so.

### If there is no database Crossline can read at all

A Rails app on MySQL. A Django app on Mongo. A Go service on DynamoDB. A Next.js
app whose state lives in a hosted service with no connection string anywhere.
None of those has a Postgres schema to introspect, and every load-bearing input
the prober used to take came from one — so the honest answer for that whole
population used to be nothing at all.

```bash
npx crossline --api http://localhost:3000
```

That is the whole command, and it needs no connection string. The check becomes
the application's own endpoints, end to end:

1. **Two accounts**, created through the app's own signup endpoint, each with
   its credential confirmed by the application naming it back. A 200 is not
   proof of identity — an app that ignores credentials entirely answers 200 to
   both, and a run built on that would compare one anonymous user against
   themselves. Where nothing confirms it, nothing is planted and nothing is
   claimed.
2. **A resource each**, created by POSTing to each collection, with the body
   corrected from the application's own validation errors until it is accepted,
   and a 128-bit marker in a field it asked for.
3. **Every line crossed between them**, with the same exact oracle as
   everywhere else: the marker came back, or it did not.

**What that settles, and what it deliberately does not.** A stranger's write or
delete that lands is a finding — no sharing model in existence makes an
unrelated account's write to somebody else's resource correct. A stranger's
*read* that comes back depends on one further fact, and the default is silence.

Where nothing establishes who the resource belongs to, the read is **an
observation, not a finding**: a leak and a document the application shares on
purpose are byte-identical from out here, and asserting one would be exactly the
false positive that gets a test uninstalled. Observations are printed in their
own register, on a pass as prominently as anywhere else, and they are never
counted as passing checks and never folded into the green tick:

```
  i 2 endpoint(s) returned another account's resource — confirm that is intended:
      GET /api/notes/:id → app.notes, to an unrelated signed-in account
      GET /api/notes → app.notes, to an unrelated signed-in account
      Facts, not findings. Each response carried a 128-bit marker that only this run's
      creation request as the other account could have put there, so the resource really
      did come back. What no part of this run established is who it belongs to.
```

**Where the run does establish ownership, the read is a finding like any other.**
The evidence has to come from the application itself: each account's own
resource, read back by that account, carries the same field holding exactly that
account's own identifier — and those identifiers carry at least 64 unpredictable
bits, so a match means something. Field names are never read; the value match is
the evidence. Then every guard the schema-backed path applies still applies, the
share-link rule included.

A resource the application hands back **flagged published** is a different
question, and the third structural invariant is what settles it. With a schema,
the visibility column is written to its most private value before the row is
planted, so a cross-user read can never be explained away as "that one was
published on purpose". Through an endpoint there is no column to write — so the
create is made again with that field set the other way, using the field the
application itself showed us and the value its own name implies: `published:
true` becomes `false`, `visibility: "public"` becomes `"private"`. The
replacement carries a fresh marker, because the published copy is still there
and leaving the oracle's marker on it would turn a public feed into a reported
leak. Where the retry takes, the resource being probed is private and the read
is convicted like any other crossing. Where the application publishes by policy
and ignores the flag, nothing is claimed and the read stays an observation —
the recall this loses is deliberate.

**Two things only you know**, for the two cases the run cannot get to on its own.
Both live under `api` in `crossline.config.json`, and the MCP tool
`crossline_declare` writes either of them:

```json
{
  "api": {
    "target": "http://localhost:3000",
    "owners": { "/api/notes": "ownerId" },
    "accounts": [
      { "identifier": "alice@example.test", "password": "env:CROSSLINE_ALICE_PW" },
      { "identifier": "bob@example.test", "password": "env:CROSSLINE_BOB_PW" }
    ]
  }
}
```

`owners` names the field that carries a resource's owner, keyed by the
collection it is created in. It is the answer for an application whose account
identifiers are `u_1` and `u_2`, where a field holding one is as consistent with
a counter as with ownership and the run refuses to guess. It is **data, not
truth**: the run still has to see that field on each account's own resource
holding that account's own identifier, and a declaration it can see is wrong is
refused with the reason printed. A declaration widens what can be established;
it cannot buy a finding the application's own answers contradict.

`accounts` is for an application nobody can sign up to. Two accounts that
already exist are logged in to instead, held to exactly the bar a signup is —
the application has to name the account back before anything is planted — and
they must be two *unrelated* accounts, which is the one premise supplying
credentials moves out of Crossline's hands. The password must be written
`env:NAME`, never a literal: it is read from the environment when the check
runs, and it reaches no report, no reproduction command, no
`.crossline/last-run.json` and nothing the MCP server returns.

Both halves are stated on the face of every result, whatever the verdict:

```
  ! What this run did not establish
      Cross-user *reads* were not settled. This run created every resource through the
      application's own endpoints and never learned who any of them belongs to, so a
      deliberately shared resource and a leaked one look exactly alike. …
      No database was read. Nothing here is a claim about a direct connection to whatever
      this application stores its data in …
```

**When this path is taken, and when it is not.** Only when *no source names a
database* — no `--db`, no `db` in the config file, none of the environment
variables, nothing in the repository — and the application plane was asked for
by `--api`, `api.target`, or an `api.server` block. That is decided from what is
written down, before anything connects. A Postgres application whose database
happens to be down still resolves its connection string, still takes the
database path, and **still fails loudly**: there is no code path from a refused
connection to the weaker check, because a narrower run reported as a pass is the
one outcome this tool exists to prevent. With neither a database nor an
application named, the error says so and names both ways out.

**What it leaves behind.** These writes are real and there is no transaction to
roll them back. Where the run has watched the owner's own delete land through a
discovered endpoint, it uses that endpoint to take its own resources back out
and confirms each one is gone by asking for it again — a 200 from a DELETE
proves nothing. Where it has not watched that, **nothing is deleted**: deleting
on the strength of an endpoint whose correctness is the thing under test is not
on offer. Everything left is named, with the identifier that finds it:

```
  ! Left behind in your application — these requests were real and there was no
    transaction to roll back:
      2 account(s) created through POST /api/auth/signup: nothing was deleted, because no
      endpoint was found that removes one and none was shown to work. Find them by the
      marker each was registered with — CROSSLINE_8215…, CROSSLINE_cbb5…
```

`--read-only` is **refused** on this path rather than quietly ignored. Every
question here is answered by something the run created, so a read-only run that
went ahead would sign two accounts up and POST two resources into a database the
developer has just said not to write to. It says that and establishes nothing.

`crossline ci` writes the matching workflow — no service container, no migration
step, the check pointed at the application — for a repository whose
`crossline.config.json` names an API target or a server block and no `db`. The
GitHub Action takes `api-url` with no `database-url` for the same case. And
`crossline_check` over MCP takes the same path, so the agent that wrote the
handler can check it; `crossline_fix` and `crossline_explain` refuse, because
there is no schema to read a model out of and no policy to write.

### If half your tables are secured

The common real shape is neither of the two above: the tables you were thinking
about have policies, and the ones added two commits later do not. Crossline
treats each table on its own evidence, so a bare table is caught whatever its
neighbours look like — row-level security elsewhere in the schema never counts
in a table's favour.

Two shapes look bare but are not leaks, and both are reported as facts rather
than findings, on the face of the result whatever the verdict:

```
  35 checks, 0 failing · 5 tables checked · 4 owner checks passing
  1 table is closed by its grants rather than by a policy:
      drafts: "authenticated" is granted nothing on drafts, so no request
      running as that role reaches it at all — the table is closed by its
      grants, not by a policy. Nothing is wrong with that, but it means no
      policy was exercised here: a single GRANT would expose every row.
```

A table granted to no role is the most firmly closed shape there is, so it is
never a finding and never blocks a pass — but it is one `GRANT` away from the
table beside it, so it is named. A table with row-level security enabled and no
policy at all is called out more sharply, because Postgres then denies every
row to everybody including you, and nobody enables it meaning that.

Neither is ever reduced to a count. A table the run could not exercise is named
in the terminal, in the PR comment, and in the agent summary.

## Known limits

Stated rather than buried, because a testing tool that oversells its coverage is
worse than one that doesn't:

- **Intra-org sharing is not tested.** Deciding whether two teammates should see
  each other's rows requires knowing intent. This is also the one judgement a
  generated policy can make that the re-run cannot check: an org-scoped policy
  widens access from the row's owner to every member of the org, and because the
  two test users are always in *different* orgs, verifying it proves strangers
  stay out and says nothing about teammates. Every org-scoped fix says so on its
  own face. Relatedly, a table is only treated as an org roster when the database
  itself says a person appears at most once per org — as a primary key or unique
  constraint on the pair — because `orders(user_id, company_id)` looks exactly
  like a roster otherwise, and mistaking one for the other is what silently
  widens a policy.
- **API writes are confirmed against the database, like deletes.** The request
  carries a value Crossline chose, and the row is read back afterwards: the
  other user's row holding that value is a fact, and the caller's own row
  holding it means the handler scoped the write correctly. When neither row
  holds it, two further facts usually settle the attempt anyway — whether the
  other user's row is byte-for-byte what it was before the request, and whether
  the *owner's* identical write through the same endpoint landed a moment
  earlier. With both, the status code says which of the two safe behaviours
  happened: the request was rejected outright, or it was answered with a success
  status and the field silently dropped. Both refused the crossing; the second is
  named separately in the output, because nothing in that reply tells the caller
  it was refused. Without the owner's proof nothing is claimed — an endpoint
  that applies the field for nobody refuses a stranger for reasons that have
  nothing to do with authorization, and scoring that as protection would be
  scoring an outage as security. A row that moved in some way this request
  cannot account for is unsettled too, and never a finding: the evidence for a
  finding is always a value only that request could have written.
- **A delete is only counted as refused if the owner's own delete lands.** A
  delete has no third fact to read — the row is gone or it is not — so "their
  row survived" is satisfied just as well by an endpoint that deletes nothing
  for anybody: a handler wired to the wrong predicate, a soft delete, a stub
  nobody finished. The owner's identical request through the same route runs
  first, and where it removed nothing the crossing is reported as unsettled with
  the reason rather than as a refusal. A row that is genuinely gone is still a
  finding either way. One consequence is worth stating rather than hiding: where
  the model asserts nothing about the owner's own delete — shared reference data,
  or a table whose ownership could not be determined — no such proof can exist,
  so a DELETE route on those tables settles nothing and is named as unchecked.
  That is a real reduction in settled coverage, and it is the honest reading:
  nothing showed the endpoint deletes anything for anyone.
- **Being a logged-in user needs one of four things.** A symmetric JWT secret
  you already have, a session your provider will issue on request
  (`supabase_admin`, `clerk_admin`, `auth0_admin`, `cognito_admin`,
  `firebase_auth`), a database session Crossline can plant, or a header
  template you write. Auth.js's JWT strategy encrypts its cookie with
  `AUTH_SECRET` — forging that would mean handling your real signing key, which
  this tool does not do. The four provider strategies are exercised against
  stubs of their documented endpoints and not against a live tenant, which is
  written up above. Given none of the
  four, the signed-in half of the API suite does not run, and the run says so
  and stays inconclusive rather than sending an anonymous request under a
  signed-in label. The signed-out half still runs, and is still evidence about
  what a stranger with no credentials can reach.
- **`supabase_admin` is tested against a real Auth server, but not a hosted
  project.** The suite runs Supabase's own `gotrue` image in Docker twice — once
  on the legacy shared secret and once configured with an ES256 signing key, the
  way a project created today is — and every claim below is checked against it:
  that a minted HS256 token is rejected outright on the asymmetric one, that the
  session Crossline obtains belongs to the persona it asked for, and that the
  secret key reaches no request to your application. What no local test can
  cover is hosted Supabase's own edge: Kong in front of the Auth server, and the
  newer `sb_publishable_…` / `sb_secret_…` keys. The requests are the documented
  ones and the key is sent in both `apikey` and `Authorization` — the one shape
  both key generations accept — but that edge is unexercised here. Relatedly,
  the strategy is exercised at the seam rather than through a whole `--api` run;
  the seam itself is covered end to end by the Auth.js tests. The same split
  applies to the sentence above about `hs256_jwt` on an asymmetric project:
  the *rejection* is proved against the real Auth server, while "and the run
  says it established nothing" follows from the general rule that a run where no
  owner check passed establishes nothing — which is tested, but on other paths,
  not on this one.
- **A persona seeded into `auth.users` is repaired before it can sign in.** A
  row inserted with SQL leaves every nullable column NULL, and the Auth server
  reads several of them into plain strings — so it cannot load the user at all
  until they hold `''`. Crossline fills those from your live schema, sets the
  audience and role the Auth server looks users up by, and then *asks the
  provider* whether it worked rather than assuming. If a future version of the
  Auth server wants more than that, the run reports that it could not become a
  signed-in user and says why; it does not carry on and report a result it did
  not establish.
- **A planted session is a real credential until the run ends.** It lives for an
  hour, is deleted at the end of the phase, and is redacted out of the `curl`
  reproductions the way a JWT is. Anything Crossline could not delete is named,
  and this is one more reason `--api` belongs on an ephemeral environment.
- **An endpoint is matched to a table by name.** `/api/documents/:id` serves
  `documents`, and the deepest matching segment wins, with singular and plural
  and Prisma's model-case spellings all tried. `/api/billing/:id` serving
  `invoices` matches nothing, and that has a consequence worth stating plainly
  now that an unserved table no longer blocks an API-only run: such a table is
  reported as one the route surface does not reach, when in fact the surface
  does reach it under another name. Two things sit against that, both printed on
  every run — the table is named in the coverage list and in "what this run did
  not establish", and the endpoint is named in the route list as one that
  resolved to no table. Putting them side by side is deliberate; naming that
  endpoint under `routes` closes it. What was there before was worse rather than
  better: the run blocked, and the message told the developer to name the table
  in `acceptUnchecked`, which silences it for good.
- **Starting your app runs a command from your `package.json`.** Only where the
  database exposes no authorization model of its own, only where a web framework
  is a *runtime* dependency, and only where the script is unambiguous — a `dev`
  script, or a `start` with no `build` beside it. Whatever is chosen is printed.
  A monorepo whose root `package.json` starts something other than the app being
  checked is the shape to watch: set `api.server.command`, or `api.server:
  false` to switch it off entirely.
- **Express route discovery reads source.** Express-style registration is
  best-effort — it resolves `app.use("/api/v1", router)` mounts across files and
  through nested routers, reads `router.route("/:id").get().put()` chains and
  arrays of handlers, and ignores commented-out registrations, but a router
  mounted through a value it cannot follow statically will still be missed.
  Because it is best-effort, the API phase reports its own coverage — how many
  discovered routes produced a settled result, and how many tables were reached
  through the app — and a run given `--api` that settled *nothing* is
  inconclusive rather than clean. Routes that check nothing are named
  individually; some legitimately serve no table at all, so those are reported
  without failing the run. Anything not parsed at all can be listed under
  `routes` in the config file.
- **A registration whose path is computed is named, not dropped.**
  `router.get(`/${resource}/:id`, …)` inside a helper builds its URL at runtime,
  and so does `app.get(path, handler)`. Reading the source cannot say what those
  serve. They are reported with the file and the call — in the terminal and in
  the agent summary, though **not** yet in the pull request comment, which is a
  gap worth knowing about — because the failure that matters is not
  missing an endpoint, it is missing one quietly: an endpoint nobody probed
  otherwise looks exactly like an endpoint with nothing wrong with it. One line
  under `routes` in the config file checks it.
- **Next.js routing is asked of the build, and parsed only when there is none.**
  `next build` writes its complete route table to `.next/routes-manifest.json`
  and `.next/app-path-routes-manifest.json`, and that table knows the things the
  file tree cannot: `basePath`, which silently prefixes every URL in the
  application, and rewrites, which give a handler a second URL that middleware's
  `matcher` may not be guarding. Both are read, and a rewrite's alias is probed
  alongside the canonical path. This was a Known Limit until recently — the
  file-tree path was probed, it 404'd, and the endpoint behind the rewrite went
  unchecked. Three things bound what is claimed here. A `.next` left by
  `next dev` is *not* used: it carries the real `basePath` and no route table at
  all, so preferring it would replace a working route list with an empty one,
  and a production build is required (`BUILD_ID`, plus the route arrays). An
  endpoint added since the last build is not in the table, so it is named as
  absent rather than probed — rebuild to have it checked. And i18n locales are
  deliberately not applied: built with `locales: ["en","fr"]`, Next's own table
  still records `/api/notes/[id]` unprefixed while the pages gain `/en` and
  `/fr`, because locale routing does not reach API routes. With no build beside
  the source, `app/` and `pages/api/` are read from the tree exactly as before,
  including route groups, catch-alls and `index` — and then `basePath` and
  rewrites are once again unseen.
- **Other frameworks are asked, not parsed — and the ones that will only answer
  by being run are declined by name.** SvelteKit's `svelte-kit sync` writes
  `.svelte-kit/types/route_meta_data.json`; Nitro writes
  `.nuxt/types/nitro-routes.d.ts`, in which it has already resolved every
  handler to the URL *and* the method it serves; Nest, FastAPI, Django REST and
  most Go frameworks emit an OpenAPI document. Those are read directly, and each
  carries the thing a source parser cannot see: SvelteKit's layout groups and
  optional segments, Nitro's method-in-the-filename, FastAPI's
  `APIRouter(prefix=…)`, Nest's `setGlobalPrefix`. Four bounds. SvelteKit's
  route table is a cache the framework never prunes, so every entry is checked
  against the file system before it becomes an endpoint. SvelteKit's
  `paths.base` lives in `svelte.config.js`, which is code and is not in any
  artifact — if a config file mentions it, the run says so rather than guessing.
  A Nitro handler registered without a method (`[id].ts`) answers all of them
  and is requested with GET only, because a blind DELETE at a handler whose body
  does not check the method is a destructive request nobody asked for. And only
  JSON OpenAPI is read; a `openapi.yaml` is named with the one-line command that
  converts it. **Rails, Laravel, Django URLconfs and Remix are declined**: the
  only exact answer for each — `bin/rails routes`, `php artisan route:list`,
  `manage.py show_urls` — boots the application, runs its initializers and opens
  a connection to whatever database the environment points at, and a test meant
  to run on every commit does not do that to the project it is checking. Nothing
  in discovery executes the project. Each of those frameworks is instead named
  in the report with the command to run and where to put the result, because a
  report that found nothing and said nothing about why is the silence this whole
  area exists to remove.
- **Monorepos are walked, from the declaration rather than from a guess.** When
  the directory Crossline is pointed at declares a workspace —
  `pnpm-workspace.yaml`, `workspaces` in `package.json`, or `turbo.json` — each
  package is searched for endpoints too, `node_modules` and build output
  excluded, and a candidate needs a `package.json` to count. This was worth more
  than it sounds: measured on one application, one server and one database with
  nothing changed but the working directory, the app's own directory yielded its
  full route list and the repository root yielded **zero**, with nothing in the
  output to say the API plane had gone silent. `turbo.json` names no packages of
  its own — Turborepo delegates that to the package manager — so its presence
  alone falls back to the conventional `apps/*` and `packages/*`. If two
  packages both have endpoints, all of them are kept and the run says so: one
  base URL serves one application, so the others' routes cannot answer and are
  reported as having settled nothing rather than dropped. Run Crossline once per
  application to check each of them.
- **Next.js Server Actions are named, and none of them is called.** A server
  action is not a route. It is a POST to the page's own URL carrying an opaque
  `Next-Action` id in a header, with the arguments in the request body, and no
  file in the tree describes it — so route discovery, which reads
  `app/**/route.ts` and `pages/api/**`, finds none of them. On an application
  whose mutations are all server actions, which is an increasing share of modern
  Next.js, that meant Crossline probed the read surface, found nothing wrong
  with it, and returned a clean API verdict having tried no mutation at all,
  with nothing in the output to distinguish that from mutations that were
  checked and passed.

  What has changed is the naming, not the checking. The ids are in the build
  output — `.next/server/server-reference-manifest.json` — and the URL each one
  dispatches from comes out of Next's own `app-path-routes-manifest.json`, so
  every action can be named exactly: its id, the page it is reachable on, and
  from Next 15.5 onwards the file and function it runs. They are printed on
  every run, in the terminal and in the agent summary, and a passing run
  explicitly withholds any claim about them. They are never counted as routes
  discovered or settled.

  What is *not* on offer is calling one, and the reason is worth being exact
  about because it decides what would have to be true to close this. The
  manifest records no argument types. A server action takes typed positional
  arguments, not a JSON body, and the framework decodes them before the
  action's own code runs: a call built on a guessed signature is rejected there,
  not answered, and reading that rejection as a refusal would be scoring an
  exception as authorization. The encoding itself is not the obstacle — a POST
  with `Content-Type: text/plain;charset=UTF-8` and a JSON array of arguments is
  accepted, and so is a `multipart/form-data` post carrying `$ACTION_ID_<id>`
  for a form action — but knowing the shape of the call is not knowing the
  arguments, and the arguments are what the manifest does not have. So nothing
  is asserted. The way to close it is to be told: an agent that just wrote the
  action knows its signature, and that is what the MCP server is for.

  Four narrower limits sit under this, all of them observed rather than assumed,
  by building the same app across Next 13.5 to 16.2 and reading the output.
  Below **Next 15.5** the manifest records no filename and no export name at
  all, so an action is named by its id and URL and the run says outright that
  which function it runs was not recorded — three actions in one file are three
  ids with nothing to tell them apart. Under **Turbopack**, the default from
  Next 16, the recorded path is relative to the inferred workspace root rather
  than the project, so it is trimmed until it names a file that is actually
  there. Action ids are **not portable between bundlers or across source
  changes** — they are derived from the built module, so they are reproducible
  for a given build and meaningless from a different one. And all of this
  requires a build: on a source tree that has not been built there is no
  manifest and no ids, which would be indistinguishable from an app with no
  actions, so the source is checked for `"use server"` separately and the
  disagreement is reported rather than resolved.
- **`SECURITY DEFINER` functions are called, but not all of them.** A definer
  function runs with its owner's privileges, so it evaluates outside the
  caller's row-level security entirely — which makes it the one cross-tenant
  path a table probe structurally cannot see. Crossline calls the ones it can:
  arguments are filled with the *other* persona's planted identifiers, the call
  runs inside a read-only transaction so Postgres itself refuses any write from
  inside the body, and a finding still means a planted identifier came back.
  What it will not do is invoke a function written in a language that can reach
  the network, or one whose name says it acts rather than answers —
  `build_tenant_archive` might ship an archive somewhere a rollback cannot
  follow. Those are reported as **reachable by a stranger, not called**, which
  is a gap in the run and is printed as one. Functions that neither tested role
  can execute are not holes and are not reported. `--read-only` skips the plane
  entirely.
- **Partitions are checked through their parent, not directly.** A partition is
  storage for the partitioned table, so Crossline seeds and probes the parent.
  Postgres applies a leaf partition's *own* policies when it is queried by name,
  so a partition granted directly and left without its own policy is not
  something this covers yet.
- **A destructive API probe re-plants the row it removed.** API-plane deletes
  are real, and several endpoints usually serve the same table. Once one genuine
  hole removes the seeded row, the next endpoint's delete finds it already
  missing — so the row is put back, using the values it was planted with, before
  each delete is attempted. Absence only counts as evidence when the row was
  demonstrably there beforehand. Where it cannot be put back — a stored
  generated column refuses the value it generated — the remaining delete checks
  on that table are reported as checks that could not be made, and are never
  scored as passing or as findings.
- **A share link is reported rather than asserted about.** The rule described
  under [the share link problem](#the-share-link-problem-and-what-happens-instead-of-a-finding)
  demotes a granted read to a named observation when the identifier is
  unguessable from the schema, was never handed out during the run, and the same
  lookup is guarded elsewhere. The residual risk is a real hole on a resource
  that happens to be guarded on a *different* route — an open `/download` beside
  a closed `/:id` — which would be printed as a share link to confirm rather
  than as a finding. That trade is deliberate: the alternative shape of the rule
  suppresses insecure direct object reference, and a demoted-but-printed
  endpoint is recoverable where a suppressed one is not. A capability URL is
  neither a pass nor a failure — it cannot settle its own route, and it cannot
  make a table read as checked.
- **A withdrawn privilege trades some recall for the precision it buys.** The
  rule above is exact about *whether* a row can be created by a request, and it
  is deliberately blunt about what happens next: where the state genuinely
  cannot be reached by a signed-in user, the crossing is not reported. That is
  right for a staff table, and it is a real gap for a row every user gets by
  some route the database does not describe — a `profiles` row written by a
  service key at sign-up, say, on a schema with no `on_auth_user_created`
  trigger. If a policy gates on the mere existence of such a row, the crossing
  would be withdrawn rather than reported. It is never withdrawn silently: the
  table and the checks it closed are printed on a passing run, which is what
  makes that gap recoverable. Withdrawal also cannot happen at all where the
  row's removal would break a foreign key — the crossing is reported as it
  stands, because nothing was established.
- **Tables that can't be seeded are skipped and named.** Never silently dropped.
- **Tables we can't point back at are skipped too.** Before checking a table,
  Crossline proves it can re-select the exact row it planted. If it can't — a
  primary key containing a timestamp cannot be matched from a value round-tripped
  through a driver, for instance — then every attempt would come back empty
  whether or not the table is protected. Rather than score that as a passing
  check, the table is named as unchecked.

## Development

```bash
pnpm install
pnpm db:up      # throwaway Postgres in Docker
pnpm test
```

The suite runs against real Postgres. Every schema fixture is one of three
variants — a schema with no policies, a correctly-hardened one, and one that
*looks* secured but isn't — instantiated across a good many schemas under
`fixtures/sql/`. The hardened variant must come back completely clean whichever
schema it is applied to: a single finding there is a release blocker, because
false positives are what get a test uninstalled.

`fixtures/app-prisma` is the same pair for app-layer enforcement: one app whose
handlers trust a client-supplied id, and the corrected version of the identical
endpoints, both running against a database with no RLS at all. The first must be
fully caught; the second must come back silent. It is worth being precise about
what that fixture is, because the section below argues the opposite case: it is
**not** a real Next.js app. It is a small `node:http` server that reimplements
the App Router's segment rules, so it is exactly the kind of stand-in that
cannot disconfirm our own parser. It earns its place by testing the *judging*
of app-layer results — leaky, corrected, and a third `unsettleable` variant
whose handlers change nothing for anybody, or move a column no request supplied,
so that a refusal there proves nothing and has to be reported as unsettled —
rather than by testing route discovery, which is what the real-framework
fixtures below are for.

### The API fixtures run on the real frameworks

`fixtures/app-mounted` is a real Express 5 app and `fixtures/app-pages` is a
real Next.js Pages Router app, because a fixture we wrote ourselves to match our
own parser cannot disconfirm it — both sides encode the same assumption and
agree by construction. Putting the real packages underneath them turned up two
defects the hand-written stand-ins had hidden: `router.route("/:id").get().put()`
was not read at all, and an interpolated template literal was captured as though
it were a literal path.

Neither fixture pays a framework build. Express is simply used; the Pages Router
app is served by Next's own `absolutePathToPage`, `getRouteRegex`,
`getRouteMatcher` and `apiResolver`, which are the parts of Next that decide the
path and run the handler, and which run standalone. A `next build` per test file
would be tens of seconds and `next dev` compiles on demand, and this suite has
to stay in single-digit seconds. The cost of the trade is that `next.config.js`
is not involved, which is stated under **Known limits** above rather than
implied away.

`fixtures/app-actions` is the server-action pair — one app whose actions trust a
client-supplied id and the corrected version of the same three actions, both
covering the `"use server"` file form and the inline form. It is a real Next.js
app and the two manifests under each variant's `.next/` are a real `next build`'s
own output, checked in verbatim apart from `encryptionKey`, which is a build
secret and does not belong in a repository. The build itself is not run by the
suite — it is tens of seconds and ~48 MB per variant, against a suite that has
to stay in single digits — so those two files are regenerated by hand with
`next build` when the fixture's actions change, and the ids moving is the
expected consequence of that. No test pins an id as a literal for exactly that
reason. Both variants are named in the output: disclosure is not a finding, and
a list that were empty on correct apps would teach a reader to skim past it.

`fixtures/app-actions/server.js` runs that pair. It builds its dispatch table by
reading the same two manifests a second time, independently of Crossline's own
reader, so a discovery bug shows up as a mismatch rather than an agreement — and
the handlers it dispatches to are the fixture's real action modules, unchanged.
What stands in for the framework is the runtime: `Next-Action` dispatch, a
request context, and a reply. That reply **echoes the arguments back**, because
a real flight stream does, and that echo is the false-positive trap this surface
sets — it is armed on the corrected variant too, so its silence means something.
Identity reaches the actions through an `AsyncLocalStorage`, which is what
`next/headers` is underneath; the fixture uses its own so the app can be *run*
rather than only built and read.

`fixtures/app-declared` is the endpoint pair for declarations: a bare `http`
server with no registration call for any parser to find, serving
`/v2/records/:id` over `documents` — so the URL names no table — behind a
validator that rejects a body without `kind`, so the owner's own write fails
until an `example` is declared.

Both fixtures derive their routing table independently of Crossline's
discovery — the Pages Router one from Next's own matcher, the Express one from
Express's own router stack — so a discovery bug shows up as a mismatch instead
of an agreement.

## Licence

MIT
