One ID everywhere.
A coach and a CRM point at the same Alice. Join their data in one query, if you’re allowed to.
A database for agents.
Built to evolve, made to extend.
Agents learn things your schema never planned for.
Usually that ends up in a JSON column nobody can query. In Substrate, the agent defines a real table for it: typed, permissioned, and joined to your existing users while the app keeps running.
Read the architectureA coaching agent learns Alice’s goal. It adds a table for goals and links it to her.
“Remember that I’m training for my first 10K.”
One new table. Alice’s other records don’t move.
An agent can add a goal to Alice’s existing identity.
The agent proposes a new table in the namespace it’s allowed to use. Depending on your policy, it’s approved automatically or by you. Goals then point at Alice’s existing ID. Person and Workout aren’t touched, because nothing was altered: a table was added.
How proposals are approvedEvery app keeps its own tables. They all point at the same people, projects and customers.
A coach and a CRM point at the same Alice. Join their data in one query, if you’re allowed to.
Agents read the schema, propose a new table, and start writing once it’s approved. No migrations.
Read, write and link are separate grants. Knowing someone’s ID gives you none of them.
A few things people build on one shared database.
A personal assistant starts with a name. Over months it adds tables for preferences, contacts and routines as it needs them, each one typed and queryable from day one.
assistant.Preferenceassistant.Contactassistant.RoutineTravel, finance and health agents each keep their own tables about the same user. Travel can read the budget if you grant it. Health stays private.
travel.Tripfinance.Budgethealth.MedicationprivateA plugin attaches reviews or deploy history to a project without adding a single column to Project. The core schema stays exactly as it was.
review.Feedbackdeploy.ReleaseAn agent builds a habit tracker for one user, at runtime. The app defines its own tables when it installs, against the users you already have.
habits.Habithabits.StreakSales, support and billing each keep their own records about the same customer. Anyone allowed to see all three can join them in one query.
sales.Dealsupport.Conversationbilling.InvoiceprivateGive Alice a goal, then read it back with her name. Typed in TypeScript and Rust, or plain SQL.
import {
defineModule, entity, relation,
ref, optionalProperty, s
} from "./sdk/typescript/schema.js";
const fitness = defineModule({
namespace: "fitness",
version: 1,
imports: { core: 1 },
entities: {
Workout: entity({
durationMinutes: s.int64()
})
},
relations: {
Performed: relation({
athlete: ref("core.Person"),
workout: ref("fitness.Workout")
}),
// Extend the existing Person.
TrainingGoal: optionalProperty(
"core.Person", s.string()
)
}
});import { Person, TrainingGoal } from "./generated/schema.js";
import { table, select, eq } from "./sdk/typescript/query.js";
// Give an existing person new, typed memory.
await client.insert(TrainingGoal, {
person: alice.id,
value: "Run my first 10K"
});
// Read it back together with their shared identity.
const person = table(Person, "p");
const goal = table(TrainingGoal, "g");
const memory = await select({
name: person.c.name,
goal: goal.c.value
})
.from(person)
.innerJoin(goal, eq(goal.c.person, person.c.id))
.where(eq(person.c.id, alice.id))
.execute(client);
// [{ name: "Alice", goal: "Run my first 10K" }]-- Combine identity, agent memory and activity.
SELECT p.name, g.value AS goal, w.durationMinutes
FROM core.Person p
JOIN fitness.TrainingGoal g ON g.person = p.id
JOIN fitness.Performed f ON f.athlete = p.id
JOIN fitness.Workout w ON w.id = f.workout
WHERE p.id = ?;
-- Bind Alice's existing UUID as the parameter.use substrate_liquid::{DataType::*, Module, Relation, Role};
let workout = Relation::new("fitness.Workout", vec![
Role::new("durationMinutes", Int),
]);
let performed = Relation::new("fitness.Performed", vec![
Role::new("athlete", Ref("core.Person".into())),
Role::new("workout", Ref("fitness.Workout".into())),
]);
let goal = Relation::new("fitness.TrainingGoal", vec![
Role::new("person", Ref("core.Person".into())),
Role::new("value", Text),
]).unique(&["person"]);
let fitness = Module::new(
"fitness", 1,
vec![("core".into(), 1)],
vec![workout, performed, goal],
);use substrate_liquid::{Result, RetryPolicy, Session, TypedId};
use generated::{core::Person, fitness::{Workout, Performed}};
fn log_workout(
fitness: &Session, alice: TypedId<Person>,
) -> Result<Option<Workout>> {
// The workout and its link commit together.
let workout = fitness.transact(RetryPolicy::default(), |tx| {
let workout = tx.insert_record(Workout {
durationMinutes: 45,
})?;
tx.insert_record(Performed {
athlete: alice,
workout,
})?;
Ok(workout)
})?;
// TypedId<Workout> selects the generated decoder.
fitness.snapshot().get_record(workout)
}Alice · Run my first 10K · 45Example with a 45-minute workout. Requires read access to Person, TrainingGoal, Performed and Workout, and reference access when writing links.
Each agent sees only what it’s been granted.
Coach can add goals and workouts. CRM’s private deals stay out of reach.
Example grants. Read, write and link rights are granted separately.
New tables are readable by any signed-in client by default. Mark one private to limit it to its owner and grantees, or public for anyone. Visibility never grants writes. A scoped credential is a hard limit, and handing it on can only narrow it. Every read and write is checked before results leave or changes commit.
Authorization modelOne million rows against SQLite and PostgreSQL, on the same CI runner.
Including the queries where we lose.
26 Oct 2026 · 1 million records · Ubuntu 24.04 runner · µs/query ↓
| Workload | Substrate SQL resident | Substrate SQL paged | SQLite embedded | PostgreSQL 18.3 · local socket* |
|---|---|---|---|---|
| Repeated point | 0.73 | 0.34 | 1.27 | 8.88 |
| Unique equality | 0.69 | 0.51 | 1.04 | 8.86 |
| Changing parameters, first pass | 24.32 | 24.00 | 122.18 | 22.14 |
| Same parameters, warm | 1.91 | 2.02 | 3.28 | 13.16 |
| Selective join | 1.05 | 0.98 | 0.70 | 10.99 |
| Full count/sum | 1,775 | 117,016 | 7,225 | 29,686 |
| Equality count/sum | 22.35 | 1.20 | 0.25 | 11.84 |
| Range aggregate, 1% | 44.66 | 6,041 | 84.23 | 266.72 |
| Broad aggregate, 90% | 1,480 | 104,669 | 6,311 | 34,174 |
| 32 groups | 4,811 | 121,391 | 366,894 | 45,976 |
| Filtered groups, 50% | 2,350 | 114,895 | 339,492 | 24,799 |
*Resident Substrate keeps current rows and indexes in memory; paged Substrate stores them on disk. PostgreSQL includes local IPC and decoding; the other modes are embedded.
500,000 Person records and 500,000 Score records across two namespaces. Three rounds per mode on the same GitHub Actions ubuntu-24.04 runner, with all results verified. Values are medians of round means, not request p50/p99. Every column measures SQL; native API timings are not shown. Grouping uses 32 buckets, the range aggregate selects 1% of scores, the broad aggregate selects 90%, and filtered grouping selects 50%.
GitHub Actions performance runsSQL runs on DataFusion, with Substrate’s own indexes and permission checks pushed into the query plan.
How the engine worksClone the repo and run the demo: two apps share one database, each with its own tables and permissions.
View on GitHubnpm ci && npm run demo:processesSubstrate is an early prototype. It runs on a single node and isn’t ready for production yet. The SDKs ship from the repository, not npm or crates.io. You’ll need Rust 1.94+ and Node 20+.