crossline
v0.2.2
Published
Prove that no user can see another user's data — automatically, on every commit.
Maintainers
Readme
Crossline
Prove that no user can see another user's data — automatically, on every commit.
npx crosslineNo 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.
- Reads your schema. Tables, columns, foreign keys, constraints, existing policies, and role grants.
- 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.
- Plants known data. Two users in two different orgs, with rows carrying identifiers only Crossline knows.
- Crosses every line. Read, modify, delete, and ownership forgery, as an anonymous client and as a signed-in stranger.
- 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
poststable with apublishedcolumn 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 ashighagainst 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:
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:
- 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. - 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.
- 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.
Install
npx crossline init # see the model, confirm it
npx crossline # run the checks
npx crossline fix # generate and verify the policiesOn 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.
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.
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_catalogand neverinformation_schema, so it works for a role that holds nothing butCONNECT.
That last one is the point. Make a role that cannot read one row:
create role crossline_verify login password '…'; -- and nothing elseselect * 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-onlydisables 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--apiat 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.
npx crossline schedule --every dailyThat 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:
// 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
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
npx crossline ciThat 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:
- uses: jdora09769-bot/crossline@v1
with:
database-url: postgres://postgres:postgres@localhost:5432/postgres
fail-on: highResults are uploaded as SARIF, so findings appear in GitHub's Security tab, and
posted as a pull request comment. See
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.
{
"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. 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_checkbefore 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.
Configuration
crossline.config.json is optional. It exists for two things: correcting the
inferred model, and pointing at a running app.
{
"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:
{
"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:
npx crosslineThat 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:
{
"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:
// 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" 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, 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, shapes from the published OpenAPI spec. 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_ZXhhbXBsZS5hY2NvdW50cy5kZXYkdecodes toexample.accounts.dev$), and FAPI carries no key in any header or query param — its own client sends none. Email verification is not the obstacle either: Clerk documents test addresses (…+clerk_test@…) that verify with a fixed code and need no credential. The obstacle is bot sign-up protection: when it is on,POST /v1/client/sign_upsanswersrequires_captcha/captcha_invalid, and the only sanctioned way past it is a Testing Token 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 needsCLERK_SECRET_KEY— which is not a thing to go and find:@clerk/nextjscannot start without it, so it is already in.env.localand 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/signupgenuinely needs only the connection name and the publicclient_id. Getting a token for that user does not follow: Auth0 creates public applications withimplicit,authorization_codeandrefresh_tokenonly, 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, is browser- and third-party-cookie-bound. A user we cannot become is no use. - Cognito — sign-up is public, confirmation is not.
SignUpis explicitly an unauthenticated operation and works with a public app client and no secret. But the new user isUNCONFIRMED, and "Users who sign themselves up must be confirmed before they can sign in" — 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:signUpwith the Web API key returns anidTokenand alocalIdimmediately, 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:
{ "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:
{
"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:
{
"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
