Modeling a back-office domain
This guide is for coding agents, and the people directing them, who are deciding whether and how to model a back-office domain in Triplex. It is prescriptive on purpose: follow the rules unless you have a specific reason not to, and each section links to the reference behind it.
Start with @triplex-build/triplex/database: declare your model, publish it, then open the schema-bound client. The examples below come from the checked, executable client site-safety scenario. CI type-checks and runs it alongside the lower-level site-safety scenario. This page is also available as Markdown, and the full documentation is available as /llms-full.txt.
Agents can also inspect and write a database through the experimental MCP server. Start with its describe tool to discover the live release and example transactions, then use the same CLI operations through MCP with an actor and command ID on every write.
Decide whether Triplex fits
Use Triplex when the domain has one or more of these properties:
- People ask what was true, or what the system believed, at an earlier date.
- Policies, schemas, or forms change, and earlier decisions must still be explained under the rules that applied then.
- Work follows from the data: missing evidence, expiring certifications, cases waiting for review.
- Several actors write, whether people, services, or agents, and every change must be attributed and safe to retry.
Do not use it for high-volume telemetry, blobs, caches, queues, or tables that never need history or provenance. Those belong in your existing database, and Triplex can live beside it.
Check these constraints with the people you work for before adopting it:
| Constraint | Current contract |
|---|---|
| Maturity | Pre-1.0. See Current state |
| Effect | Requires effect@4.1.1. Effect 3 is not compatible |
| Runtime | ESM-only, Node.js 22+, modern browsers and edge runtimes for the core |
| Durable stores | SQLite is supported. PostgreSQL is a production candidate |
| Host duties | Authentication, authorization, task lifecycle, timers, and retries are yours |
Map the domain
| In your domain | In Triplex |
|---|---|
| Things: workers, sites, cases, accounts | Stable EntityIds and an EntityType per kind |
| Properties and relationships | Global Attribute keys, with Attribute.ref for relationships |
| Schemas, policies, forms | Config nodes committed as an immutable release, pointed to by live |
| User and system actions | One db.command call per action |
| Evidence that starts or expires | validFrom and validTo on the assertion |
| Corrections | tx.correct, tx.end or tx.replace inside one command |
| Obligations and to-dos | A Derivation whose candidates you reconcile |
| Tickets, notifications, SLAs | Your code, driven by reconciliation diffs |
| Audit questions | A temporal basis, command receipts, and the journal |
1. Declare entity types and attributes
- Give every entity a stable, typed ID such as
worker:mariaorcase:01J9…. Construct IDs once at the application boundary withEntityId.make. Don't cast strings deep inside domain code. - Attribute keys are global keywords of the form
:namespace/name, matching^:[a-z][a-z0-9_-]*/[a-z][a-z0-9_-]*$. An attribute owns its identity and value type. - An entity type owns how it uses an attribute:
required,cardinality("one"or"many"), andunique. The same attribute can be required in one type and optional in another. Declare a use with.required(),.many(), or.unique(), chaining in any order, such as.required().unique()or.many().required(). A bare attribute is optional and single-valued. - Model relationships as
Attribute.ref(key, TargetType). There are no join tables. Datalog joins on shared values. - Available value types:
text,number,integer,boolean,date,instant,enumOf, andref.dateis a calendar string that ordered predicates (<,>) cannot compare; useinstant(epoch milliseconds or aDate) for anything you compare or expire. See the value-type table. - The client's
tx.createandtx.putsetentityTypeand encode values through the declared attributes so type-level reads and constraints can find the entity.
const Site = EntityType.make("Site", {
name: Attribute.text(":site/name").required(),
});
const Worker = EntityType.make("Worker", {
name: Attribute.text(":worker/name").required(),
trainedFor: Attribute.ref(":worker/trained-for", Site).many(),
});
const Placement = EntityType.make("Placement", {
worker: Attribute.ref(":placement/worker", Worker).required(),
site: Attribute.ref(":placement/site", Site).required(),
});
const Model = { types: [Site, Worker, Placement] } as const;Read more in Typed configuration.
2. Publish rules as releases
- Commit schemas, policies, and forms together with
config.commit({ label, objects, ref: "live" }). Each release is immutable and content-addressed. - To change the rules, publish a new release. Never edit an old one. Moving
liveback to an earlier release doesn't roll back data. - Publish before acquiring the client; acquisition checks that your declarations match the release. The client resolves and pins the current release when each new command is admitted.
const publish = Effect.gen(function* () {
const config = yield* ConfigStore.ConfigStore;
return yield* config.commit({
label: "site-safety-v1",
objects: yield* EntityType.nodesOf(...Model.types),
ref: "live",
});
});Read more in Releases, refs, and rollback.
3. Open the client and write commands
The client requires actor and commandId on every command; derive the ID from the request and reuse it on retries. It pins and enforces the same release automatically. A retry returns the original receipt without running the callback or applying another write.
const Client = Database.layer({ schema: Model });
const placeWorker = Effect.gen(function* () {
const db = yield* Database.Database;
return yield* db.command(
{
actor: "user:dana",
commandId: `create:${placement}`, // keep this ID on retries
correlationId: "request:7f3a",
},
(tx) => tx.create(Placement, placement, { worker: maria, site: harbor }, { validFrom: MAR_2 }),
);
});const receipt = yield* placeWorker;
const retry = yield* placeWorker; // original receipt; callback does not run again- Run builder Effects sequentially: one
db.commandcallback becomes one atomic transaction. - Authenticate and authorize in the host before calling the client.
correlationIdandcausationIdcan connect the command to its request, case, or triggering event. - Put business time in
validFromandvalidTo; intervals are half-open:[validFrom, validTo). OmittedvalidFrommeans commit time. Builder writes share an interval across the entity's attributes. Referenced targets must be valid from an earlier or equal time. Constraint failures report the failingvalidAtinerror.violations. - Use
tx.get(Type, id)for check-then-write decisions; it decodes throughType.decodeand automatically guards the entity's observed history against concurrent writes. - For temporal edits, fetch a fact with
tx.fact(Type, tripleId), then usetx.correct,tx.replace, ortx.end. These include the needed concurrency preconditions.correctreplaces the whole interval;replacechanges from a date on;endpreserves only the past. A retraction alone removes the whole valid interval from current recorded knowledge. - Keep irreversible external actions outside callbacks. Record outgoing intents with
tx.outbox.append; the host delivers them after commit.
Read Database client for typed get, bounded queries with Schema projections, builder semantics and stable asOf views. See Operational primitives for the underlying transaction contract.
4. Model obligations as derivations
Use Live queries for journal-driven snapshots and valid-time wakeups.
- Write the obligation as a Datalog query. Positive patterns describe the situation, and a
notclause describes the missing evidence. Every variable innotmust be bound outside it.Type.where(entity, { alias: "?var" })andType.not(...)build those clauses from aliases. - Set
identityto the variables that name one obligation, such as worker and site. Candidate IDs are then stable across re-evaluations. - Use
Derivation.evaluatefor reads and previews, andDerivation.Materialization.materializefor durable, freshness-tracked runs. - Pass the difference from
Derivation.reconcile(added,removed,changed) to your own task system. Triplex decides what should be open; your code decides what "open" means. - Wake up at
nextTemporalBoundaryto catch expiring or future-effective evidence. Don't poll. - Follow the journal with
triples.transactions({ after })and aConsumerCheckpoint. Advance the checkpoint only after downstream effects succeed. - A
staleorunmaterializedresult doesn't mean there is no work. Rematerialize before treating an empty result as current.
// Derivations use the underlying Triples service and an explicit release.
const openWork = (configSnapshot: string) =>
Effect.gen(function* () {
const training = yield* Derivation.make({
name: "task.site-safety-training",
query: {
find: ["?worker", "?site"],
where: [
...Placement.where("?placement", { worker: "?worker", site: "?site" }),
Worker.not("?worker", { trainedFor: "?site" }),
],
},
identity: ["?worker", "?site"],
configSnapshot,
});
const { candidates, nextTemporalBoundary } = yield* Derivation.evaluate(
yield* Triples,
training,
{ basis: { validAt: MAR_2 } },
);
return { open: candidates.map((task) => task.result), recheckAt: nextTemporalBoundary };
});The compliance host example adds the complete loop: journal consumer, durable task occurrences, a hypothetical preview, and a scheduler that reopens work at expiry. Read more in Derivations.
5. Answer audit questions
Use Why to explain a fact, attribute, entity or derivation candidate at an exact recorded cut. It returns the asserting transaction and its pinned release; optional bounded command causation follows the parent chain.
- What did we believe then about what was true then? Set both
recordedAtandvalidAt. Setting only one leaves the other at "now". - What did we know when we made a decision? Use
db.asOf({ recordedPosition: receipt.position, validAt })for an exact read cut. The receipt is returned bydb.command; raw journal lookup usestransactionByCommand. - Who changed this entity, and why? Page
transactionsForEntity(entityId), newest first. Each record carries its actor, command, correlation, causation, release, and typed changes. - Who asserted the state that held at a past date? Bind the assertion transaction in a fourth tuple term and join
["?tx", ":_tx/actor", "?actor"]in the same query. Reserved:_tx/*metadata uses the recorded cut and ignores valid time, so the join works for backdated assertions. A recorded cut before the commit still hides its metadata. See Datalog. - What was retracted?
history(entityId)includes retracted assertions.entityandmatchshow only facts visible at the basis you ask for.
const audit = (receipt: TransactionRecord) =>
Effect.gen(function* () {
const db = yield* Database.Database;
const config = yield* ConfigStore.ConfigStore;
const then = yield* db.asOf({ recordedPosition: receipt.position, validAt: MAR_2 });
const now = yield* db.asOf({ validAt: MAR_2 });
return {
actor: receipt.actor,
governedBy: !ContentId.isContentId(receipt.configSnapshot)
? undefined
: (yield* config.snapshotById(receipt.configSnapshot))?.label,
trainedAsKnownThen:
(yield* then.get(Worker, maria))?.values.trainedFor.includes(harbor) ?? false,
trainedAsKnownNow:
(yield* now.get(Worker, maria))?.values.trainedFor.includes(harbor) ?? false,
};
});6. Choose a runtime
Publish first, then provide Database.layer({ schema: Model }) to the application. Keep publishing and the client over the same ConfigStore and Triples instance. The complete example creates the referenced site and worker before placing them.
Construct one layer per database lifetime, at the application boundary. Building an in-memory layer again creates a new empty database, and building a SQL layer per request defeats connection sharing.
const Store = ConfigStore.layer.pipe(Layer.provideMerge(KvTriples.layer));
const application = Effect.gen(function* () {
const release = yield* publish; // deployment step, before client acquisition
const program = Effect.gen(function* () {
const db = yield* Database.Database;
yield* db.command({ actor: "seed", commandId: "seed:site-safety" }, (tx) =>
Effect.gen(function* () {
yield* tx.create(Site, harbor, { name: "Harbor" }, { validFrom: MAR_2 });
yield* tx.create(Worker, maria, { name: "Maria" }, { validFrom: MAR_2 });
}),
);
const receipt = yield* placeWorker;
const retry = yield* placeWorker; // original receipt; callback does not run again
return {
receipt,
retry,
audit: yield* audit(receipt),
work: yield* openWork(release.snapshot.id),
};
});
return yield* program.pipe(Effect.provide(Client));
});
export const main = application.pipe(Effect.provide(Store));On PostgreSQL, Triplex can join your application's own Effect SQL transaction, so your rows, Triplex facts, the journal, and an outbox row commit together. See Host integration.
Underneath the client
Use raw Triples explicitly for host infrastructure, migrations, derivations and journal consumers. It is the primitive API; it does not add the client's defaults. Derivations above still take a Triples service and an explicit release.
A raw write supplies actor and a stable commandId, plus both configSnapshot and enforce from GraphConstraint.pinned(release). The lower-level equivalent of the placement command is:
// One command is one atomic transaction: who acted, which command, under which rules.
export const placeWorker = (
release: InMemoryConfigStore.ConfigSnapshot,
placement: EntityId,
worker: EntityId,
site: EntityId,
) =>
Effect.gen(function* () {
const triples = yield* Triples;
const facts = Placement.assert(placement, { worker, site }, { validFrom: MAR_2 });
return yield* triples.transact(facts, {
actor: "user:dana",
commandId: `create:${placement}`, // unique: a retry can never apply twice
correlationId: "request:7f3a",
...(yield* GraphConstraint.pinned(release)), // the rules it answered to, enforced
});
});Raw duplicate commands fail with CommandAlreadyCommittedError; recover the receipt with transactionByCommand. Raw read-decide-write commands must pass EntityState preconditions, and Change.correct, Change.replace and Change.end return operations and preconditions that must be submitted together. Install SchemaBound for release-checked raw queries. The primitive site-safety scenario executes this primitive form and its receipt recovery as a separate checked example.
Anti-patterns
| Don't | Do instead |
|---|---|
Generate a new random commandId on every retry | Reuse the request ID; db.command returns the original receipt |
Pin configSnapshot and assume constraints were checked | Use db.command, which pins and enforces together |
| Retract a fact in one command and assert its replacement in another | Use tx.correct inside one command |
| Retract an open state to change it from a date on | Use tx.replace; use tx.end to end without replacement |
| Store a date attribute when the date says when the fact holds | Use validFrom and validTo |
| Backdate one attribute of an entity and let the others default | Give all of an entity's facts the same validFrom |
| Re-run derivations on a fixed timer | Follow the journal and wake up at nextTemporalBoundary |
Treat an empty stale or unmaterialized result as "no work" | Check freshness and rematerialize |
| Put task status, assignees, or SLAs in the derivation | Keep the task lifecycle in host code, driven by reconciliation |
| Read provenance in a second query with a different recorded cut | Join the asserting transaction and :_tx/actor at one recorded cut |
Call queryAll on request paths | Use query and follow nextCursor (100 rows per page by default) |
| Encode authorization as Datalog over the same store | Authorize in the host before providing the client |
Check a write rule before transact, then write | Enforce it in a commit validator, which reads the state that would commit |
Inspect with the CLI
The CLI is non-interactive and prints one JSON envelope per command: { "ok": true, "command": ..., "data": ... } on success, and { "ok": false, "command": ..., "error": Diagnostic } on stderr with a nonzero exit on failure. The diagnostic has code, message, retryable, and optional path, fix, and details. Branch on code; use fix to correct the input. Only retry a retryable conflict after reloading facts. Add --debug to include the original cause string; see Error diagnostics. Writes require meta.actor and meta.commandId.
npm install @triplex-build/triplex effect @effect/sql-sqlite-node \
@effect/platform-node @effect/platform-node-shared
triplex --sqlite ./app.db describe # capabilities and live release schema
triplex --sqlite ./app.db entity types
triplex --sqlite ./app.db entity facts worker:maria
triplex --sqlite ./app.db entity history worker:maria --limit 25
triplex --sqlite ./app.db query explain --input query.json
triplex --sqlite ./app.db why fact 01ARZ3NDEKTSV4RRFFQ69G5FAV
triplex --sqlite ./app.db why entity worker:maria --recorded-position 12
triplex --sqlite ./app.db transaction preview --input command.json
triplex --sqlite ./app.db transaction apply --input command.json
triplex --sqlite ./app.db journal receipt placement:create:maria-harbor
triplex --sqlite ./app.db config release --ref liveStart with describe: data.schema includes the release identity, all entity types and attributes, usage constraints and sensitivity, graph constraints, configuration/derivation objects, write rules, and one { entityType, input } example per type. Each input pins and enforces the same release. Replace its synthetic placeholders with application values, create referenced targets first, and submit the input to transaction apply. Use describe --ref preview to inspect another ref. If the ref has no release, data.schema is null and data.hint explains how to publish one.
Use why attribute <entityId> <attribute> for every visible value of one attribute. Stored subjects accept --recorded-at or --recorded-position, --valid-at, and --causation-depth. For candidates, why candidate --input candidate.json accepts the full definition, candidate ID, revision and explicit basis in the shared input shape. Preview takes apply's input and command ID, reports operations, changes and constraint violations without writing, and returns an existing receipt when that ID has committed. Preserve both the release pin and enforcement from describe. Preview is advisory; apply checks current state again.
The unscoped triplex package on npm is an unrelated project. See CLI and dashboard for the full command set.
Next
- Getting started to run a first program with expected output
- Core concepts for facts, retractions, and the two clocks
- Datalog for joins, negation, aggregation, recursion, and pagination
- Host integration for the full responsibility boundary
- Troubleshooting for empty queries, missing services, and stale projections