The Substrate Contract
You want agent interviews or persona-driven sessions against your
own application, but the identity plumbing — reconciling auth users
against a roster, picking a qualifying persona, minting a scoped JWT
and handing it to an agent — is generic work you should not rebuild.
The fit-terrain substrate verbs do all of it against
one documented interface: the Substrate Contract.
You map your schema onto the contract once; the verbs never read
your vendor tables.
This page is the normative definition of the contract. The verbs
that consume it ship with @forwardimpact/libterrain:
npx fit-terrain --help
The contract
Namespace
A Postgres schema named substrate, listed in your
Supabase API configuration (api.schemas in
supabase/config.toml). Every stack-facing verb builds a
client bound to db.schema = "substrate" and
never names another schema.
Relations
You implement the relations as views (or tables) over your own schema:
| Relation | Required | Columns |
|---|---|---|
substrate.people |
yes |
email (unique), name,
kind (human rows are personas),
manager_email, team_id,
team_name, discipline,
level, track
|
substrate.evidence |
no | email — one row per authored evidence item |
substrate.discovery |
no |
key, value — navigation ids copied
into .substrate.json
|
discipline, level, and
track are mandated columns — the engineering-standard
vocabulary is a stated opinion of this contract. A consumer from a
different domain maps its own role model onto the three columns
rather than renaming them. A clinical-research platform, for
example, maps staff roles like this:
create view substrate.people as
select s.email,
s.full_name as name,
'human' as kind,
s.supervisor_email as manager_email,
s.site_id as team_id,
si.name as team_name,
s.role as discipline, -- 'research_coordinator', ...
s.seniority as level, -- 'junior', 'senior', ...
'clinical' as track
from clinical.staff s
left join clinical.sites si on si.site_id = s.site_id;
Auth model
Supabase auth with email identities. Your product's row-level
security keys on auth.email(); provisioning and picking
use the service-role key. The substrate schema itself
should be readable by service_role only — it is
operator surface, not end-user surface.
Environment variables
| Variable | Needed by |
|---|---|
SUPABASE_URL |
every stack-facing verb (check,
provision, pick,
roster, issue)
|
SUPABASE_SERVICE_ROLE_KEY |
every stack-facing verb |
JWT_SECRET |
issue only — the HS256 secret your Supabase stack
verifies tokens against
|
substrate init is an offline scaffold and needs none of
them.
Degradation semantics
Absent optional relations degrade declaredly, never silently:
-
checkreports an absent optional relation as info, not failure. -
pickwithoutsubstrate.evidencedrops the evidence invariants (persona authors evidence; manages a direct who does) and keeps the structural ones (persona has a manager; manages at least one direct). The payload'sselection_metadata.applied_invariantsnames which sets ran. -
issuewithoutsubstrate.discoverywrites an identity-only.substrate.json(persona and manager email plus timestamp, no navigation ids).
The walkthrough
From an empty checkout to an issued persona credential:
# 1. Bring up the local stack and capture its URL/keys.
npx fit-terrain substrate up --cwd . --emit-env .env.local
# 2. One-time: scaffold the contract migration, then edit the commented
# example views to map your schema. Commit the result.
npx fit-terrain substrate init --cwd .
# 3. Apply your migrations (contract views included) and your own seed.
supabase db push
./scripts/seed.sh
# 4. Gate: one diagnostic per missing or malformed relation.
npx fit-terrain substrate check
# 5. Reconcile auth.users against substrate.people.
npx fit-terrain substrate provision
# 6. Pick one invariant-satisfying persona...
npx fit-terrain substrate pick --format json
# 7. ...and mint its credential set. The .env variable name is yours.
npx fit-terrain substrate issue --email persona@example.com --cwd . \
--token-env MY_APP_TOKEN
pick accepts --memory <path> to
diversify against recent picks recorded in a CSV it appends on
success (window size via --memory-window, default 5);
omit it for a stateless pick. issue accepts
--ttl (default 1h) and
--stash <path> for a bare copy of the JWT.
issue writes three things atomically (mode 0600): a
<NAME>=<jwt> line to .env, the
discovery key/values plus persona_email,
manager_email, and generated_at to
.substrate.json, and the bare token to
--stash when supplied. Those three field names are
reserved — a substrate.discovery row using one of them
is overridden by the identity values. --token-env is
required and has no default — the token's name belongs to your
application, not to this library.
Roster visibility
npx fit-terrain substrate roster lists every persona
satisfying the applicable invariants — the same query
pick runs, as an operator surface. Use it to see who
qualifies before wiring the pick into automation.