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.
Every code block on this page comes from the site-safety scenario. CI type-checks and runs that scenario on every change. This page is also available as Markdown, and the full documentation is available as /llms-full.txt.
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.0.3. 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 transact call per command |
| Evidence that starts or expires | validFrom and validTo on the assertion |
| Corrections | Retract and assert in one transaction |
| 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. Name entities 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. - 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. - Set
entityTypeon every assertion so type-level reads and constraints can find the entity.
import { Effect } from "effect";
import { EntityId } from "@triplex-build/triplex";
import { Attribute, ConfigStore, EntityType } from "@triplex-build/triplex/config";
// Attributes own global identity and value type.
export const SiteName = Attribute.text(":site/name");
export const Site = EntityType.make("Site", {
attributes: { name: Attribute.use(SiteName, { required: true, unique: true }) },
});
export const WorkerName = Attribute.text(":worker/name");
export const TrainedFor = Attribute.ref(":worker/trained-for", Site);
export const Worker = EntityType.make("Worker", {
attributes: {
name: Attribute.use(WorkerName, { required: true }),
trainedFor: Attribute.use(TrainedFor, { cardinality: "many" }),
},
});
export const PlacementWorker = Attribute.ref(":placement/worker", Worker);
export const PlacementSite = Attribute.ref(":placement/site", Site);
export const Placement = EntityType.make("Placement", {
attributes: {
worker: Attribute.use(PlacementWorker, { required: true }),
site: Attribute.use(PlacementSite, { required: true }),
},
});
// hr-2026.2 changes the rules: every placement now needs a supervisor.
export const PlacementSupervisor = Attribute.text(":placement/supervisor");
export const PlacementV2 = EntityType.make("Placement", {
attributes: {
worker: Attribute.use(PlacementWorker, { required: true }),
site: Attribute.use(PlacementSite, { required: true }),
supervisor: Attribute.use(PlacementSupervisor, { required: true }),
},
});
export const maria = EntityId.make("worker:maria");
export const harbor = EntityId.make("site:harbor");
export const placement = EntityId.make("placement:maria-harbor");
// Entity types own usage rules. Publishing them creates an immutable release.
export const publish = (label: string, placementType: typeof Placement | typeof PlacementV2) =>
Effect.gen(function* () {
const config = yield* ConfigStore.ConfigStore;
return yield* config.commit({
label,
objects: [...(yield* Site.nodes), ...(yield* Worker.nodes), ...(yield* placementType.nodes)],
ref: "live",
});
});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. - At command time, resolve the release you intend to enforce and keep its snapshot for the whole command.
Read more in Releases, refs, and rollback.
3. Write each command as one transaction
Follow these rules for every write:
- One application command is one
transactcall. Everything the command changes commits or rolls back together. - Always set
actorand a deterministiccommandId. Derive the command ID from the request so that a retry reuses it. Command IDs are atomically unique per database, and a duplicate fails withCommandAlreadyCommittedErrorinstead of applying twice. - Set
correlationIdandcausationId. Use the request or case for correlation and the triggering command or event for causation. - Pin
configSnapshotand passenforcefrom the same release. Pinning alone only records which rules applied. Constraints are checked only when you also pass them withenforce. - Put business time on the assertion. Use
validFromandvalidTowhen the dates define when the fact holds. Intervals are half-open:[validFrom, validTo). - Correct facts; don't delete them. Retract the wrong assertion and assert the correct one in the same transaction. For facts that other writers may also change, add a
{ _tag: "TripleLive", id }precondition so a concurrent change fails with a conflict instead of being lost.
// One application command is one atomic transaction that records who acted,
// which command it was, and the release whose rules it was checked against.
export const placeWorker = (release: InMemoryConfigStore.ConfigSnapshot) =>
Effect.gen(function* () {
const triples = yield* Triples;
const rules = yield* GraphConstraint.collect(release.root);
return yield* triples.transact(
[
{
op: "assert",
entityId: placement,
entityType: "Placement",
...Placement.attributes.worker.assertion(maria, { validFrom: MAR_2 }),
},
{
op: "assert",
entityId: placement,
entityType: "Placement",
...Placement.attributes.site.assertion(harbor, { validFrom: MAR_2 }),
},
],
{
actor: "user:dana",
commandId: "placement:create:maria-harbor", // unique: a retry can never apply twice
correlationId: "request:7f3a",
configSnapshot: release.id, // the rules this write was governed by
enforce: GraphConstraint.enforcement(rules), // required/unique/ref checks, atomically
},
);
});A retry with the same command ID returns the original receipt, and the journal gains no entry:
import { Effect } from "effect";
import { Triples } from "@triplex-build/triplex";
import type { InMemoryConfigStore } from "@triplex-build/triplex/config";
import { placeWorker } from "./record.ts";
// A retried command must never apply twice. The duplicate is rejected with the
// original transaction, so the caller can answer with the first receipt.
export const placeWorkerOnce = (release: InMemoryConfigStore.ConfigSnapshot) =>
placeWorker(release).pipe(
Effect.map((result) => result.position),
Effect.catchTag("CommandAlreadyCommittedError", (duplicate) =>
Effect.gen(function* () {
const triples = yield* Triples;
const receipt = yield* triples.transactionByCommand(duplicate.commandId);
return receipt?.position;
}),
),
);Read more in Operational primitives and Troubleshooting.
4. Model obligations as derivations
- 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. - 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.
import { Effect } from "effect";
import { Triples } from "@triplex-build/triplex";
import * as Derivation from "@triplex-build/triplex/derivation";
import { PlacementSite, PlacementWorker, TrainedFor } from "./model.ts";
// Obligation: every placed worker holds current training for that site.
export const siteSafety = (configSnapshot: string) =>
Derivation.make({
name: "task.site-safety-training",
query: {
find: ["?worker", "?site"],
where: [
["?placement", PlacementWorker.key, "?worker"],
["?placement", PlacementSite.key, "?site"],
["not", ["?worker", TrainedFor.key, "?site"]],
],
},
identity: ["?worker", "?site"],
configSnapshot,
});
const day = (instant: number) => new Date(instant).toISOString().slice(0, 10);
// Which training tasks should be open on a given day, and when will that change?
export const openWorkOn = (configSnapshot: string, validAt: number) =>
Effect.gen(function* () {
const triples = yield* Triples;
const definition = yield* siteSafety(configSnapshot);
const { candidates, nextTemporalBoundary } = yield* Derivation.evaluate(triples, definition, {
basis: { validAt },
});
return {
on: day(validAt),
open: candidates.map((task) => task.result),
recheckOn: nextTemporalBoundary === undefined ? null : day(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
- 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 the decision's receipt
instantasrecordedAt. Find the receipt withtransactionByCommand. - Who changed this entity, and why? Page
transactionsForEntity(entityId), newest first. Each record carries its actor, command, correlation, causation, release, and typed changes. - What was retracted?
history(entityId)includes retracted assertions.entityandmatchshow only facts visible at the basis you ask for.
import { Effect } from "effect";
import { Triples, type TemporalBasis } from "@triplex-build/triplex";
import { TrainedFor, maria } from "./model.ts";
import { MAR_2 } from "./record.ts";
// Was Maria trained for Harbor on the day she started there?
export const wasMariaTrained = Effect.gen(function* () {
const triples = yield* Triples;
const decision = yield* triples.transactionByCommand("placement:create:maria-harbor");
if (decision === null) return yield* Effect.die("placement was never recorded");
const trained = (basis: TemporalBasis) =>
triples
.match({ entityId: maria, attribute: TrainedFor.key }, basis)
.pipe(Effect.map((facts) => facts.length > 0));
return {
// What the database believed at the instant Dana made the placement.
knownAtDecision: yield* trained({ validAt: MAR_2, recordedAt: decision.instant }),
// What the database knows now, after the auditor's correction.
knownToday: yield* trained({ validAt: MAR_2 }),
};
});6. Choose a runtime
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.
import { Layer } from "effect";
import { KvTriples } from "@triplex-build/triplex";
import { ConfigStore } from "@triplex-build/triplex/config";
import { PgTriples } from "@triplex-build/triplex-postgres";
import { SqliteTriples } from "@triplex-build/triplex-sqlite";
// The same program runs against any of these. Only the provided layer changes.
export const InMemory = ConfigStore.layer.pipe(Layer.provideMerge(KvTriples.layer));
export const Sqlite = ConfigStore.layer.pipe(
Layer.provideMerge(SqliteTriples.layer({ filename: "app.db" })),
);
export const Postgres = ConfigStore.layer.pipe(
Layer.provideMerge(PgTriples.layerFromUrl(process.env["DATABASE_URL"] ?? "")),
);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.
Anti-patterns
| Don't | Do instead |
|---|---|
Generate a new random commandId on every retry | Derive it from the request; handle CommandAlreadyCommittedError |
Pin configSnapshot and assume constraints were checked | Also pass enforce built from that snapshot |
| Retract a fact in one command and assert its replacement in another | Do both in one transaction |
| Store a date attribute when the date says when the fact holds | Use validFrom and validTo |
| 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 |
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 Triples |
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, "error": ... } on stderr with a nonzero exit on failure. Writes require meta.actor and meta.commandId.
npm install --global @triplex-build/triplex-cli
triplex --sqlite ./app.db describe # capability manifest
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 journal receipt placement:create:maria-harbor
triplex --sqlite ./app.db config release --ref liveThe 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