# Triplex documentation > The database that remembers why: an embedded fact database for TypeScript back-office systems. Every write records who made it, which versioned rules governed it, and when it was true, so audit questions and open work become queries. Triplex is pre-1.0; install with `npm install @triplex-build/triplex effect@4.0.0-rc.112`. Triplex requires Effect 4 (`effect@4.0.0-rc.112`; Effect 3 is not compatible), is ESM-only, and targets Node.js 22+ plus modern browsers and edge runtimes for the core. In-memory and SQLite storage are supported, PostgreSQL is a production candidate, and Cloudflare and FoundationDB are experimental. Coding agents modeling a back-office domain should read the modeling guide first. Every page is available as Markdown by appending `.md` to its URL. Code on the homepage and in the modeling guide is type-checked and executed in CI. --- An embedded fact database · TypeScript + Effect triplex_ # The database that remembers why. Facts change. Rules change. Your record of what happened shouldn’t. Triplex keeps facts, time, and versioned rules together. Every write records **who acted**, **which rules applied**, and **when it was true**. Audit questions and open work become queries instead of investigations. [Get started](/getting-started) [Follow one decision ↓](#one-scenario) Pre-1.0 · MIT licensed · In-memory + SQLite supported; PostgreSQL is a production candidate · [Current state ↗](/current-state) Start in your process ```sh npm install @triplex-build/triplex effect@4.0.3 ``` Effect 4 required · [First program →](/getting-started) [t₁ / t₂**Two clocks.**What was true. What you knew.](#what-did-we-know) [#**Pinned rules.**The release behind every decision.](#which-rules) [=>**Derived work.**What should happen next.](#what-happens-next) ## Reading this as a coding agent? Start with the [Modeling guide for agents](/agents). Get the index at [/llms.txt](/llms.txt), every guide at [/llms-full.txt](/llms-full.txt), or append `.md` to any page URL. The [CLI](/tools) prints JSON. 00 / The missing context ## “Why?” is the most expensive question a back-office system gets asked. An auditor asks: *was Maria trained for the Harbor site on the day she started there?* In a typical stack, the evidence is spread across systems that never shared an identity model or a clock. | Where the evidence usually lives | What it can no longer tell you | | -------------------------------- | ----------------------------------------------------------------------- | | Application database | The training row was corrected in place, so the original belief is gone | | Config service or repository | The placement rules have changed since. Which version applied? | | Audit log | Who acted, but not the facts they saw or the rules they were held to | | Job queue | Whether a training task should have existed, and when it should reopen | Triplex keeps facts, versioned rules, the causal journal, and derived work in one store with one identity model and one clock. The auditor’s question becomes a query. 01 / One scenario, start to finish ## Maria, the Harbor site, and a certificate that was wrong. Maria is trained for Harbor from March 1 to June 1 and placed there on March 2 under release `hr-2026.1`. The rules then change: `hr-2026.2` requires a supervisor on every placement. Later, an auditor finds that the certificate actually began on March 5. Every snippet is type-checked. Every output is produced by [running this scenario in CI](https://github.com/bjacobso/triplex/tree/main/docs/snippets/home/site-safety). The database’s own journal reads: | Position | Actor | Command | Changes (+ assert, − retract; valid time) | | -------- | ---------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | 1 | `triplex/config-store` | — | Release `hr-2026.1`; `live` moved to it | | 2 | `user:hr` | `onboard:maria` | + `:site/name` `"Harbor"` from 2026-01-05; + `:worker/name` `"Maria"` from 2026-01-05 | | 3 | `user:trainer` | `training:record:maria-harbor` | + `:worker/trained-for` `site:harbor` [2026-03-01, 2026-06-01) | | 4 | `user:dana` | `placement:create:maria-harbor` | + `:placement/worker` `worker:maria` from 2026-03-02; + `:placement/site` `site:harbor` from 2026-03-02 | | 5 | `triplex/config-store` | — | Release `hr-2026.2`; `live` moved to it | | 6 | `user:auditor` | `training:correct:maria-harbor` | − `:worker/trained-for` `site:harbor` [2026-03-01, 2026-06-01); + `:worker/trained-for` `site:harbor` [2026-03-05, 2026-06-01) | 00 · Record ### One command, one atomic transaction. Dana’s placement records who acted, a command ID that makes retries safe, a correlation ID, and the release that governed the write. The constraints come from that same release and are checked in the same transaction. Nothing is overwritten. The auditor’s correction retracts the old belief and asserts the new one, and the journal keeps both. [Transactions and the journal](/operational-primitives) ```ts // 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 }, ); }); ``` 02 / Three questions, three queries ## Ask what you knew, which rules applied, and what happens next. Each answer below is the real output of the code beside it. 01 · What did we know? ### Two clocks: when it was true, and when you learned it. `validAt` is the day in question. `recordedAt` is the instant of Dana’s receipt. At that instant, the record said Maria was trained. Today, after the correction, it says she was not. Both answers can be proven. Dana acted in good faith on a record that was later corrected. [Recorded time and valid time](/concepts#recorded-time-and-valid-time) ```ts 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 }), }; }); ``` Output ```json { "knownAtDecision": true, "knownToday": false } ``` 02 · Which rules applied? ### Every receipt is pinned to an immutable release. The `live` ref has moved to `hr-2026.2`, but Dana’s placement still points at `hr-2026.1`, the content-addressed release that governed it. The same kind of command under today’s rules is rejected atomically. The rejected command writes no facts, no receipt, and no journal entry. [Releases, refs, and rollback](/configuration-versioning) ```ts import { Effect } from "effect"; import { Triples } from "@triplex-build/triplex"; import { ConfigStore, ContentId } from "@triplex-build/triplex/config"; // Who placed Maria, and under which version of the rules? export const governingRules = Effect.gen(function* () { const triples = yield* Triples; const config = yield* ConfigStore.ConfigStore; const receipt = yield* triples.transactionByCommand("placement:create:maria-harbor"); const pinned = receipt?.configSnapshot; if (pinned === undefined || !ContentId.isContentId(pinned)) { return yield* Effect.die("placement was not pinned to a release"); } return { actor: receipt?.actor, correlationId: receipt?.correlationId, governedBy: (yield* config.snapshotById(pinned))?.label, liveToday: (yield* config.resolveRef("live"))?.label, }; }); ``` Output ```json { "actor": "user:dana", "correlationId": "request:7f3a", "governedBy": "hr-2026.1", "liveToday": "hr-2026.2" } ``` The same command under `hr-2026.2` ```json { "error": "ConstraintViolationError", "violations": ["Placement requires :placement/supervisor"] } ``` 03 · What needs to happen? ### Open work is a query over facts. The obligation is a Datalog derivation: a placed worker without current training for that site. On March 3, the corrected record exposes a gap, so the task is open. On April 1 nothing is open, and the evaluation reports when that can next change. On June 1 the training expires and the task reopens, even though nobody wrote anything. `recheckOn` comes from `nextTemporalBoundary`. Schedule a wake-up for that date instead of polling. [Derivations and reconciliation](/derivations) ```ts 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), }; }); ``` Output for March 3, April 1, and June 1 ```json [ { "on": "2026-03-03", "open": [{ "?worker": "worker:maria", "?site": "site:harbor" }], "recheckOn": "2026-03-05" }, { "on": "2026-04-01", "open": [], "recheckOn": "2026-06-01" }, { "on": "2026-06-01", "open": [{ "?worker": "worker:maria", "?site": "site:harbor" }], "recheckOn": null } ] ``` 03 / Derived work ## Stop enqueuing obligations. Reconcile them. Most back-office bugs come down to a task that should exist but doesn’t, or one that exists but shouldn’t. Event handlers create work one step at a time and drift out of sync with the data. A derivation states what should be true, and reconciliation reports the difference. | | Event handlers that enqueue tasks | Triplex derivations | | ----------------- | --------------------------------------- | ------------------------------------------------------------------ | | Source of truth | Whatever each handler remembered to do | A Datalog query over facts, pinned to a release | | Late corrections | Missed unless someone re-runs a handler | Appear on the next evaluation, even for past dates | | Expiring evidence | A separate cron job per rule | `nextTemporalBoundary` says exactly when to re-evaluate | | Duplicates | Hand-built idempotency | Stable, content-addressed candidate IDs | | Explanation | Log lines | Each candidate carries its source facts and assertion transactions | | Change | Diff it yourself | `Derivation.reconcile` returns `added`, `removed`, and `changed` | ### Triplex owns Facts, history, and the temporal read basis. Atomic transactions, command receipts, and the ordered journal. Release identity and constraint enforcement. Derivation candidates, provenance, and reconciliation diffs. Consumer checkpoints and wake-up boundaries. ### Your application owns Domain vocabulary and entity IDs. Authentication, authorization, and HTTP handlers. What a release contains and when `live` moves. What each reconciliation diff means: a ticket, an email, a workflow. Timer delivery and retries. ### See it end to end The [compliance host example](https://github.com/bjacobso/triplex/tree/main/examples/compliance-host) adds durable task occurrences, a journal consumer with a checkpoint, a hypothetical preview, and a scheduler that reopens work at expiry. Read [Host integration](/host-integration) for the full boundary. 04 / Modeling ## From a back-office domain to Triplex primitives. Most back-office systems are built from the same parts. This is where each part goes. | In your domain | In Triplex | In the scenario | | --------------------------------------- | ---------------------------------------------------------------------- | -------------------------------- | | Things: workers, sites, cases, accounts | Stable `EntityId`s and an `EntityType` per kind | `worker:maria`, `Worker` | | Properties and relationships | Global `Attribute` keys, with `Attribute.ref` for relationships | `:placement/site` | | Schemas, policies, forms | Config nodes committed as an immutable release, pointed to by `live` | `hr-2026.1`, `hr-2026.2` | | User and system actions | One `transact` per command with `actor`, `commandId`, `configSnapshot` | `placement:create:maria-harbor` | | Evidence that starts or expires | `validFrom` and `validTo` on the assertion | Training valid March 1 to June 1 | | Corrections | Retract and assert in one transaction; history is kept | The auditor’s fix | | Obligations and to-dos | A `Derivation` whose candidates you reconcile | `task.site-safety-training` | | Tickets, notifications, SLAs | Your code, driven by reconciliation diffs and wake-up boundaries | Host-owned | | Audit questions | A temporal basis, command receipts, and the journal | Questions 01 and 02 | [Read the full modeling guide](/agents) 05 / Deployment ## A library, not another server. Triplex runs inside your process as Effect layers. Switching storage changes the layer you provide, and the rest of the program stays the same. On PostgreSQL, Triplex can share your application’s transaction, so your own rows, Triplex facts, the journal, and an outbox row commit or roll back together. ```ts 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"] ?? "")), ); ``` | Runtime | Package | Status | | -------------------------- | --------------------------------- | ------------------------------- | | In-memory KV | `@triplex-build/triplex` | Supported: tests, browser, edge | | SQLite | `@triplex-build/triplex-sqlite` | Supported durable baseline | | PostgreSQL | `@triplex-build/triplex-postgres` | Pre-1.0 production candidate | | Cloudflare Durable Objects | private workspace package | Experimental | | FoundationDB | private workspace package | Experimental | Want to build on another storage engine? Explore the [backend blueprints](/backends): working SQLite, experimental FoundationDB, and proposed Datomic and XTDB adapters. Each maps storage primitives to the Triplex contract and identifies what still needs to be built. Also in the box: a JSON-first [CLI](/tools) for agents, a browser [data explorer](/explorer), a [configuration-derived HTTP API](/http-api) with OpenAPI, and an in-browser [playground](/playground). 06 / Fit ## When to use Triplex, and when not to. ### Good fit Compliance and onboarding, eligibility and entitlements, policy engines, case management, and back-office workflows. Any domain where “as of when?” and “under which rules?” come up regularly. Also agent-driven systems that need a durable world model with an attributed record of every change. ### Not a fit High-volume telemetry, blob storage, caches, and queues. Also workloads that only need a relational table and never ask about history or provenance. Triplex is a system of record for facts, not a replacement for every table. ### How it relates [Datomic](/datomic) offers facts, Datalog, and historical database views. [Current XTDB](/xtdb) offers bitemporal data through SQL and XTQL. Triplex embeds in TypeScript and connects facts to versioned rules and reconciled work. **Event sourcing** gives you history, but you hand-write every projection. **Workflow engines** run durable code; Triplex decides what work should exist, and the two compose. 07 / Part of the WorldVM family ## A memory for your world. Small experiments in software you can inspect, explain, and build on. Triplex keeps the facts. These projects explore the language and models around them. [**Forma ↗**A typed Lisp for building your own domain language.](https://github.com/bjacobso/forma) [**Schematics ↗**Small Effect libraries that make application rules inspectable.](https://schematics.run) [**Open Ontology ↗**Configuration as code, checked across platforms.](https://open-ontology.com) [Meet the whole family at worldvm.com ↗](https://worldvm.com/) [Quickstart](/getting-started) [Modeling guide](/agents) [Core concepts](/concepts) [Datalog](/datalog) [Typed configuration](/configuration) [Derivations](/derivations) [Host integration](/host-integration) [Current state](/current-state) --- # Getting started This guide writes three facts to an in-memory Triplex database, joins them with Datalog, and prints the result. It is the shortest complete path to a running program. > **Pre-1.0 packages** The `@triplex-build` packages are available from npm and require `effect@4.0.3`. Effect 3 is incompatible. Do not use the superseded `@bjacobso` package names for new work. ## Prerequisites - Git - Node.js 22 or newer - Corepack and pnpm 10.11.0 (the repository declares the exact package-manager version) For an application, install the published core package and its Effect peer dependency: ```sh npm install @triplex-build/triplex effect@4.0.3 ``` ## Run the example Clone the repository and install its locked dependencies: ```sh git clone https://github.com/bjacobso/triplex.git cd triplex corepack enable pnpm install --frozen-lockfile ``` Run the checked quickstart directly from the checkout: ```sh pnpm exec tsx --tsconfig docs/snippets/tsconfig.json docs/snippets/getting-started.ts ``` The complete program is: ```ts import { Effect } from "effect"; import { EntityId, KvTriples, Triples, ref, string } from "@triplex-build/triplex"; const program = Effect.gen(function* () { const triples = yield* Triples; const alice = EntityId.make("person:alice"); const acme = EntityId.make("company:acme"); yield* triples.transact( [ { op: "assert", entityId: alice, entityType: "Person", attribute: ":person/name", value: string("Alice"), }, { op: "assert", entityId: alice, entityType: "Person", attribute: ":person/employer", value: ref(acme), }, { op: "assert", entityId: acme, entityType: "Company", attribute: ":company/name", value: string("Acme"), }, ], { actor: "quickstart", commandId: "quickstart/create-alice/v1", }, ); const { results } = yield* triples.query({ find: ["?personName", "?companyName"], where: [ ["?person", ":person/name", "?personName"], ["?person", ":person/employer", "?company"], ["?company", ":company/name", "?companyName"], ], }); return results.map((row) => ({ person: row["?personName"], company: row["?companyName"], })); }); const relationships = await Effect.runPromise(program.pipe(Effect.provide(KvTriples.layer))); console.log(JSON.stringify({ relationships }, null, 2)); ``` It prints: ```json { "relationships": [ { "person": "Alice", "company": "Acme" } ] } ``` The repository's smaller demo is another executable starting point: ```sh pnpm exec tsx --tsconfig docs/snippets/tsconfig.json examples/demo/demo.ts ``` ## What the Effect code is doing You only need four Effect ideas for this example: 1. `Triples` is a service tag. `yield* Triples` asks the current Effect context for the database. 2. `Effect.gen` lets the program sequence database effects with generator syntax. 3. `KvTriples.layer` constructs the in-memory implementation of that service. 4. `Effect.provide` supplies the layer, and `Effect.runPromise` runs the fully provided program. Create and share a layer at your application boundary. Do not construct a new layer inside each request: each `KvTriples.layer` runtime owns a fresh in-memory database, and its contents disappear when that runtime/process ends. The transaction records three typed values atomically. `ref(acme)` is a relationship because its value is another `EntityId`; the Datalog query follows that relationship by using the same `?company` variable in two clauses. `query` returns one bounded page, which is enough for this one-row example. Follow `nextCursor` for larger results or use `queryAll` only for trusted batch work that intentionally needs the complete result set. ## Use durable SQLite SQLite is the supported local persistent backend. Install it alongside core and Effect: ```sh npm install @triplex-build/triplex-sqlite ``` Replace the in-memory layer with a file-backed layer: ```ts import { SqliteTriples } from "@triplex-build/triplex-sqlite"; const DatabaseLive = SqliteTriples.layer({ filename: "./triplex.db" }); const relationships = await Effect.runPromise(program.pipe(Effect.provide(DatabaseLive))); ``` This is a focused replacement fragment: `program` is the complete program above. The convenience layer opens the SQLite file and applies Triplex's current migration. Create it once and share it for the application lifetime. Production hosts that separate schema migration from runtime startup should use the explicit unmigrated composition described in [Host integration](/host-integration). ## Next steps - Experiment without installing anything in the browser [Playground](/playground). - Read [Core concepts](/concepts) before modeling a domain with historical facts. - Follow [Configuration releases and rollback](/configuration-versioning) to version rules and pin operational writes to them. - Learn query clauses and pagination in [Datalog](/datalog). - Explore a database through the [CLI and dashboard](/tools). - Check the [current maturity contract](/current-state) before choosing a production backend. --- # 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](https://github.com/bjacobso/triplex/tree/main/docs/snippets/home/site-safety). CI type-checks and runs that scenario on every change. This page is also available as [Markdown](/agents.md), and the full documentation is available as [`/llms-full.txt`](/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](/host-integration). Check these constraints with the people you work for before adopting it: | Constraint | Current contract | | -------------- | ---------------------------------------------------------------------------- | | Maturity | Pre-1.0. See [Current state](/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 `EntityId`s 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:maria` or `case:01J9…`. Construct IDs once at the application boundary with `EntityId.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"`), and `unique`. 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`, and `ref`. - Set `entityType` on every assertion so type-level reads and constraints can find the entity. ```ts 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](/configuration#ontology-dsl). ## 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 `live` back 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](/configuration-versioning). ## 3. Write each command as one transaction Follow these rules for every write: 1. **One application command is one `transact` call.** Everything the command changes commits or rolls back together. 2. **Always set `actor` and a deterministic `commandId`.** Derive the command ID from the request so that a retry reuses it. Command IDs are atomically unique per database, and a duplicate fails with `CommandAlreadyCommittedError` instead of applying twice. 3. **Set `correlationId` and `causationId`.** Use the request or case for correlation and the triggering command or event for causation. 4. **Pin `configSnapshot` and pass `enforce` from the same release.** Pinning alone only records which rules applied. Constraints are checked only when you also pass them with `enforce`. 5. **Put business time on the assertion.** Use `validFrom` and `validTo` when the dates define when the fact holds. Intervals are half-open: `[validFrom, validTo)`. 6. **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. ```ts // 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: ```ts 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](/operational-primitives) and [Troubleshooting](/troubleshooting#what-should-happen-on-a-duplicate-command-id). ## 4. Model obligations as derivations - Write the obligation as a Datalog query. Positive patterns describe the situation, and a `not` clause describes the missing evidence. Every variable in `not` must be bound outside it. - Set `identity` to the variables that name one obligation, such as worker and site. Candidate IDs are then stable across re-evaluations. - Use `Derivation.evaluate` for reads and previews, and `Derivation.Materialization.materialize` for 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 `nextTemporalBoundary` to catch expiring or future-effective evidence. Don't poll. - Follow the journal with `triples.transactions({ after })` and a `ConsumerCheckpoint`. Advance the checkpoint only after downstream effects succeed. - A `stale` or `unmaterialized` result doesn't mean there is no work. Rematerialize before treating an empty result as current. ```ts 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](https://github.com/bjacobso/triplex/tree/main/examples/compliance-host) adds the complete loop: journal consumer, durable task occurrences, a hypothetical preview, and a scheduler that reopens work at expiry. Read more in [Derivations](/derivations). ## 5. Answer audit questions - **What did we believe then about what was true then?** Set both `recordedAt` and `validAt`. Setting only one leaves the other at "now". - **What did we know when we made a decision?** Use the decision's receipt `instant` as `recordedAt`. Find the receipt with `transactionByCommand`. - **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. `entity` and `match` show only facts visible at the basis you ask for. ```ts 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. ```ts 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](/host-integration#choose-a-runtime). ## 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`. ```sh 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 live ``` The unscoped `triplex` package on npm is an unrelated project. See [CLI and dashboard](/tools) for the full command set. ## Next - [Getting started](/getting-started) to run a first program with expected output - [Core concepts](/concepts) for facts, retractions, and the two clocks - [Datalog](/datalog) for joins, negation, aggregation, recursion, and pagination - [Host integration](/host-integration) for the full responsibility boundary - [Troubleshooting](/troubleshooting) for empty queries, missing services, and stale projections --- # Playground See how facts become answers. Pick a domain, try a change, and watch a real Triplex database update in your browser. No installation or JSON editing needed to get started. ## What this demonstrates The sample query runs automatically. Add a score or a training record to change its answer, then use **Retract this change** to see the original result return. Retraction changes which facts are currently visible; the journal keeps both writes. **Reset database** starts a fresh session, including a fresh journal. Open **Go further: edit queries and transactions** to experiment with the underlying JSON. The guided actions populate the transaction editor, so you can inspect the exact assertion or retraction you just ran. Raw entity facts include IDs you can use in your own retractions. The playground keeps one `ManagedRuntime` alive while you interact with a domain, so every command resolves the same `Triples` service and in-memory KV store. Switching domains or resetting disposes that runtime and builds a new one. This is the browser equivalent of the layer-lifetime guidance in [Getting started](/getting-started#what-the-effect-code-is-doing). The query and transaction editors decode the same public `DatalogQuery` and `TransactRequest` schemas used by Triplex hosts and the CLI. Results are bounded to 100 rows. For durable data, graph constraints, configuration authoring, or a larger operator interface, continue to [CLI and dashboard](/tools). --- # Core concepts Triplex stores facts, not mutable rows. It keeps when each assertion was recorded, when it was valid in the domain, which configuration release governed a write, and which facts supported derived results. This guide uses one safety-training example to connect those ideas. ## Facts and relationships Suppose worker Maria is placed at the Harbor site and has a safety certificate. Triplex represents that state as small typed facts: | Entity | Attribute | Value | | ------------------- | --------------------- | ------------------- | | `worker:maria` | `:worker/name` | `"Maria"` | | `placement:harbor` | `:placement/worker` | `ref(worker:maria)` | | `placement:harbor` | `:placement/site` | `ref(site:harbor)` | | `certificate:maria` | `:certificate/worker` | `ref(worker:maria)` | | `certificate:maria` | `:certificate/site` | `ref(site:harbor)` | An entity ID groups facts for convenient entity reads. A reference value creates a relationship to another entity. There is no separate relationship table or hidden object graph: queries join facts by their values. Entity type names such as `Worker` and global attribute names such as `:worker/name` are different identities. Configuration can say how an entity type uses an attribute—required, single-valued, unique, or reference-constrained—but those rules are not automatically enforced on every write. ## Assertions, retractions, and correction An assertion says that a typed fact holds over a valid-time interval. A retraction closes that assertion's **recorded** visibility; it does not erase the original fact or rewrite its history. Corrections therefore append evidence: 1. retract the assertion that is no longer part of the current recorded view; 2. assert the corrected interval or value; and 3. do both in one attributed `Triples.transact` call. `history(entityId)` includes retracted assertions. Current `entity` and `match` reads show only facts visible at their requested bitemporal basis. ## Recorded time and valid time The two clocks answer different questions: - **Recorded time:** what the database knew at an instant. - **Valid time:** when the fact was true in the domain. Imagine this certificate history: - On January 5, the host records that Maria's certificate is valid from January 1 through December 31. - On March 1, the host learns it was actually revoked effective February 1. In one correction transaction it retracts the original assertion and records the corrected January interval. The same `validAt` date can produce a different answer as knowledge changes: | Read basis | Certificate visible? | Why | | ----------------------------- | -------------------- | -------------------------------------------- | | recorded Feb 15, valid Feb 15 | Yes | The revocation was not known yet. | | recorded Mar 2, valid Feb 15 | No | The corrected interval ended on Feb 1. | | recorded Mar 2, valid Jan 15 | Yes | The corrected assertion still covers Jan 15. | Public reads accept one `{ recordedAt?, validAt? }` basis for the complete read, including every clause of a Datalog query. Times are non-negative epoch milliseconds and interval ends are exclusive. If `recordedAt` is omitted, Triplex uses the latest recorded state; if `validAt` is omitted, it uses the runtime's current time. Setting only one axis does not freeze the other. Recorded time is assigned by the Triplex transaction boundary, not supplied as application data. Use the journal's ordered commit positions when exact ordering matters; timestamps alone may not distinguish commits in the same millisecond. ## Datalog asks across relationships A structural query can identify placed workers who lack a certificate for the same site: ```ts const openTraining = { find: ["?worker", "?site"], where: [ ["?placement", ":placement/worker", "?worker"], ["?placement", ":placement/site", "?site"], [ "not", ["?certificate", ":certificate/worker", "?worker"], ["?certificate", ":certificate/site", "?site"], ], ], } as const; ``` Variables beginning with `?` join clauses. The `not` clause is safe because `?worker` and `?site` are already bound by the placement clauses. Before the corrected February basis the placement does not match; afterward it does. See [Datalog](/datalog) for predicates, disjunction, aggregation, recursion, and snapshot-stable pagination. ## Configuration releases and refs Triplex configuration is a separate typed graph for schemas, forms, policies, routines, and other application-defined objects. A commit records the **complete** graph as an immutable release: - a `ConfigNode` has a stable logical `(kind, key)` and a content ID; - a revision records a version of one logical object and its dependency closure; - a `ConfigSnapshot` pins the full release and its revision set; and - a movable ref such as `test` or `live` points to one existing snapshot. Promoting or rolling back configuration means moving a ref. It does not copy nodes, mutate an old release, reverse operational facts, or run a data migration. Operational transactions should store the actual snapshot ID returned by `commit`/`resolveRef` in `meta.configSnapshot`, rather than only the movable ref name. Follow the [complete versioning walkthrough](/configuration-versioning). ## Entity snapshots are not configuration snapshots Both use content identity, but they answer different questions: | Identity | Represents | Changes when | | ---------------- | ------------------------------------------------------ | ------------------------------------------------- | | `EntitySnapshot` | one fact entity materialized at a transaction or basis | that entity's visible facts change | | `ConfigSnapshot` | one immutable release of a configuration graph | a new complete configuration release is committed | Neither replaces the transaction journal. Entity snapshots are projections whose source position must be checked for freshness; configuration snapshots are immutable release roots addressed by configuration APIs. ## Derived work and provenance A derivation pins a Datalog query, candidate identity, configuration snapshot, and dependency set. For the query above, a candidate might mean “Maria needs Harbor training.” Triplex can retain the source triple IDs and assertion transactions that explain the candidate, compare one evaluation with another, and report `added`, `removed`, `changed`, and `unchanged` candidates. The host decides what those changes mean. Triplex does not automatically create or cancel a task, send a notification, retry a delivery, or assign an owner. Materialized derivations are projections and report `current`, `stale`, or `unmaterialized`; a stale result is last-known data, not current truth. The host owns catch-up and timer delivery, including waking at a derivation's next valid-time boundary. Exact derivation provenance currently covers patterns, predicates, and negation. Derivations reject recursive rules, disjunction, aggregation, pagination, dynamic attributes, and transaction-binding clauses where Triplex cannot preserve a complete explanation. Raw Datalog supports more of those features when provenance is not requested. ## Constraints and responsibility boundaries The ontology DSL produces requiredness, cardinality, uniqueness, and reference-target constraints. They become atomic write guards only when a host passes the rules in `meta.enforce`, normally from the same snapshot pinned in `meta.configSnapshot`. Observation-only validation remains useful for migrations and audits. Direct adapter writes and unconstrained commands are outside enforcement. Triplex owns facts, temporal reads, atomic transactions, the causal journal, configuration identity, and derivation mechanics. The host still owns authentication, authorization, database/tenant selection, domain commands, higher-order business invariants, durable workflow lifecycle, external delivery, retries, migrations, monitoring, retention, backup, and recovery. See [Host integration](/host-integration) for the operational boundary. ## Where Triplex fits Triplex is designed for domains where history and rules are part of the answer: compliance, onboarding, eligibility, entitlements, governed back-office work, and agent-driven systems that need a durable causal record. It is usually a poor fit for high-volume telemetry, blob storage, queue-only workloads, or simple state that never needs temporal or provenance questions. --- # Current state This document is the concise maturity contract for Triplex as of September 2026. The README explains how to use the system, [Host integration](/host-integration) defines application boundaries, [Custom runtimes](/custom-runtimes) defines adapter composition, and the roadmap tracks work that is not complete. ## Delivered - One `Triples` service for atomic writes, triple-pattern reads, batched entity reads, history, raw Datalog, stable query pages, dependency state, and the ordered transaction journal. - First-class bitemporality. Facts have independent recorded and valid intervals, and direct reads and Datalog use one shared temporal basis. - Atomically unique command receipts, causal metadata, typed assertion/retraction changes, commit positions, compare-and-retract preconditions, indexed snapshot-stable entity timelines, and durable consumer checkpoints. - Backend-independent Datalog semantics for patterns, joins, predicates, negation, disjunction, aggregates, ordering, pagination, and the intentionally constrained binary recursive-rule form. Query preflight rejects unsupported or ambiguous programs before backend execution. - Immutable entity snapshots as a projection, distinct from immutable configuration release snapshots. - Public `Runtime.define` and `Runtime.fromKv` builders, structured `DatabaseScope` identities, typed capability providers, deterministic fixtures, and a testkit `runtimeConformance` helper. Snapshot and emitter providers are explicit requirements; base stores use `Capabilities.none`. - A browser-safe canonical encoding and domain-separated SHA-256 `ContentId` foundation shared by entity snapshots, config graphs, derivations, validation observations, and decisions. - Typed configuration nodes, catalogs, releases, refs, impact queries, reactors, evaluation proofs, an immutable in-memory reference store, and a transactional Triples-backed `ConfigStore`. - An ontology DSL that separates global attribute identity/type from usage-local requiredness, cardinality, uniqueness, and reference-target constraints. - Queryable validation observations plus opt-in atomic enforcement of required, cardinality, uniqueness, and reference-target constraints. - Content-addressed Datalog derivations with explicit candidate identity, source provenance, immutable materialization runs, freshness positions, temporal wakeups, pure reconciliation, and read-only hypothetical overlays. - Standalone demos for basic linked facts and the application-owned compliance/work boundary, plus a Foldkit/Tailwind browser dashboard that exercises facts, reflected entity-type tables, config-rendered form previews, raw Datalog, derivations, the journal, and full configuration object/revision/release history through one app-lifetime Effect layer. - An Effect v4 agent CLI with schema-decoded commands and stable JSON output for SQLite and PostgreSQL. It explores entities, bitemporal facts, Datalog, causal history, configuration revisions/releases/refs/impact, applies attributed idempotent transactions, and moves refs. - A backend-neutral configuration-derived HTTP package with exact-keyword runtime schemas, REST CRUD, OpenAPI, immutable version resolution, host authorization, bounded exact-cut pages, and atomic constraint-enforced writes over both in-memory KV and SQLite. - A baseline SQL v1 migration, additive v2 numeric range index, host-owned migration entrypoints, Changesets configuration and release automation, dist-only exports, package tarball checks (including the installed CLI), and Effect dependencies aligned through the root pnpm catalog. - Additive Cloudflare v2 snapshot and v3 numeric index migrations, and a Cloudflare runtime implementation using only public core and SQL exports. Existing facts are preserved; snapshot backfill is explicit, and pre-upgrade Cloudflare pagination cursors must be restarted. - PostgreSQL layers for standalone pools, an ambient host-owned `SqlClient`, and validated database-scoped pools. Ambient composition shares Effect SQL's fiber-local transaction so host rows, Triplex facts/journal, command claims, commit positions, and host outbox rows commit or roll back together without an internal second pool. - The VitePress documentation is published as a Cloudflare assets-only Worker through the repository's Effect-native Alchemy stack at . - A private portable host package now defines immutable tenant database identity, authorization, CAS lifecycle contracts, route fencing, and a schema-decoded v1 data protocol. The Cloudflare package exposes a complete `CloudflareTriples` layer using one Durable Object SQLite handle for storage and Datalog, and passes the shared backend corpus both in its native-SQL harness and in workerd against a real SQLite-backed Durable Object. - A private Cloudflare reference host wires an authenticated gateway, a durable CAS registry, one SQLite Durable Object per database, idempotent provisioning, suspension/resume/deletion fencing, request budgets, and a separately state-named Alchemy v2 stack with retention protection. Its default workerd test exercises two-tenant isolation, restart persistence, lifecycle fencing, and generation-safe recreation. ## Backend maturity | Backend | Bitemporal `Triples` | Shared conformance | Default CI | Intended use today | | ------------- | -------------------- | ------------------ | ---------- | ------------------------------ | | In-memory KV | Yes | Yes | Yes | Tests, browser, ephemeral data | | SQLite | Yes | Yes | Yes | Supported local persistence | | PostgreSQL | Yes | Yes | Yes | Pre-1.0 production candidate | | Cloudflare DO | Yes | Harness + workerd | Yes | Experimental reference hosting | | FoundationDB | Yes | Native opt-in | No | Experimental | PostgreSQL includes validated logical database IDs, deterministic safely quoted schema names, and per-pool-connection schema binding. Its host-owned-client and database-scoped paths are covered by Docker integration tests in CI, including rollback and concurrent multi-connection isolation. It remains a pre-1.0 production candidate until operational use establishes its backup, migration, observability, and scale characteristics. Cloudflare now has the complete one-line `Triples` composition and passes the shared corpus against both a fast Durable Object API-compatible native SQLite harness and Cloudflare's workerd runtime. The reference host also passes its local workerd isolation and restart corpus. It remains experimental until credentialed deployment, upgrade, retention, limit, and provider-failure gates pass. The host and Alchemy stack have not been promoted or published. FoundationDB requires a non-empty subspace by default and scopes clears to it, but still depends on native infrastructure outside the normal test matrix. ## Honest limitations - Seven public packages are published under the `@triplex-build` organization scope. The GitHub repository is `bjacobso/triplex`. - The superseded `@bjacobso` bootstrap packages are not the installation path for new consumers. - `SubscriptionManager` discovers dependencies and reports possible invalidations. It does not push result deltas or automatically re-run queries. - Entity snapshots, validation results, and derivation materializations are projections. Callers must inspect their source position and freshness; projection failure cannot roll back an already committed source transaction. - Public runtime composition does not include automatic durable KV snapshot persistence. The shared Triples conformance suite does not certify capability behavior, crash durability, cross-process isolation, or migration safety. - Exact derivation provenance currently supports patterns, predicates, and negation. Recursive rules, disjunction, aggregation, pagination, dynamic attributes, and transaction-binding clauses are rejected where a complete explanation cannot be preserved. - Config decision verification proves internal content integrity given a trusted decision root. It does not independently prove that an external actor chose the right rule or answer. - Constraint enforcement is opt-in and serialized through the commit-position boundary. Direct adapter writes, authorization, general Datalog invariants, inbox/outbox delivery, timers, retries, and application workflow lifecycle are host responsibilities. - The HTTP API intentionally omits PATCH, filters, relationship expansion, raw Datalog, writable historical schemas, automatic migrations, empty entities, and mutation of scheduled facts. - The schema is intentionally greenfield. Databases created by pre-baseline development builds must be recreated or migrated by application-owned tooling. - The Cloudflare host is a reference implementation, not a production support claim. Backup and restore, provider limit measurements, staged migration/rollout drills, credentialed Alchemy reconciliation, and lost-acknowledgement/node-failure exercises remain explicit gates. ## Release follow-up 1. Configure npm trusted publishing for all seven packages and verify a tokenless `next` publish. 2. Verify the published packages in a clean registry-only consumer, including peer dependencies, provenance, CLI, and exports. 3. Deprecate the superseded `@bjacobso` canaries with migration guidance. Cloudflare and FoundationDB are private for the first release. Their source stays in the monorepo and continues to compile, but Changesets cannot publish them accidentally. The six pre-stable `@bjacobso` canary packages must be deprecated after the corresponding `@triplex-build` stable packages are available. The old artifacts remain available so existing canary installs receive a migration message instead of breaking. --- # Datalog Triplex exposes bounded Datalog pages through `Triples.query` and a wrapped form through `Triples.queryPage`. Both default to 100 bindings and allow at most 1,000 per page. The in-memory KV and SQL engines share one schema, semantic preflight, result identity, ordering contract, and bitemporal basis. If facts, relationships, or the two time axes are new, read [Core concepts](/concepts) first. A Datalog clause is a pattern over `(entity, attribute, value)`. Reusing a `?variable` joins clauses; the `find` list selects the bindings returned to the caller. For example, joining a worker's placement site to a certificate site answers whether the same relationship exists—not merely whether either entity has some site fact. ## Query shape ```ts const program = Effect.gen(function* () { const triples = yield* Triples; const { results } = yield* triples.query({ find: ["?name", "?age"], where: [ ["?person", ":person/name", "?name"], ["?person", ":person/age", "?age"], [">=", "?age", 30], ], }); return results; }); ``` `results` is an array of binding objects whose keys retain the `?` prefix. A three-element pattern is `[entity, attribute, value]`. An optional fourth element binds the assertion transaction: ```ts const program = Effect.gen(function* () { const triples = yield* Triples; const { results } = yield* triples.query({ find: ["?name", "?actor"], where: [ ["?person", ":person/name", "?name", "?tx"], ["?tx", ":_tx/actor", "?actor"], ], }); return results; }); ``` The `?` prefix is reserved for variables. Entity, attribute, transaction, and rule identity positions accept string literals or variables. Malformed runtime input and unsupported programs fail with typed `DatalogValidationError` or `UnboundVariableError` before either backend runs. ## Values and equality Stored facts retain seven value types: string, number, boolean, datetime, ref, JSON, and blob. Datalog projects their public scalar values: - numbers and datetimes project as numbers; - strings and refs project as strings; - booleans remain booleans; - JSON projects as canonical JSON text; and - blobs project their content identity. A bare scalar pattern constant uses public scalar equality. Use a typed constant when storage type matters: ```ts import { EntityId, ref } from "@triplex-build/triplex"; const query = { find: ["?movie"], where: [["?movie", ":movie/director", ref(EntityId.make("person:nolan"))]], }; ``` Numeric-looking strings and identity values are never guessed into numbers. Equality preserves the same public scalar families through patterns, joins, predicates, negation, disjunction, grouping, distinctness, and pagination. ## One temporal basis Pass a basis as query options: ```ts const historicalQuery = Effect.gen(function* () { const answer = yield* triples.query(query, { basis: { recordedAt: auditInstant, validAt: businessInstant, }, }); return answer; }); ``` Omitted recorded time means the latest recorded state. Omitted valid time means the runtime's current business time. Every pattern, join, negation, and rule in the query sees the same cut. ## Predicates and clause order Comparison clauses use `>`, `>=`, `<`, `<=`, `=`, and `!=`: ```ts where: [ ["?person", ":person/age", "?age"], [">=", "?age", 30], ]; ``` Ordered predicates are numeric-only; number and datetime facts share that family. Text and identity values use equality or inequality. Incompatible equality is false and incompatible inequality is true rather than depending on backend casts. Conjunction is declarative. Positive patterns establish outer bindings before predicates, negation, and disjunction regardless of their written order. Patterns inside a conjunctive `not` establish its local bindings before local predicates run. ## Negation and disjunction ```ts const activePeople = { find: ["?person"], where: [ ["?person", ":person/name", "?name"], ["not", ["?person", ":person/status", "inactive"]], ], }; const namedAliceOrBob = { find: ["?person"], where: [ [ "or", [ ["?person", ":person/name", "Alice"], ["?person", ":person/name", "Bob"], ], ], ], }; ``` Unsafe negation, empty disjunctions, and unbound projections fail during shared preflight. ## Aggregation and ordering Aggregate clauses are `[operation, source, target]`. Supported operations are `count`, `sum`, `avg`, `min`, and `max`: ```ts const countTasks = Effect.gen(function* () { const result = yield* triples.query({ find: ["?status", "?count"], where: [ ["?task", ":task/status", "?status"], ["?task", ":task/owner", "?owner"], ], aggregate: [["count", "?task", "?count"]], having: [[">", "?count", 1]], orderBy: [{ variable: "?count", direction: "desc" }], limit: 20, }); return result; }); ``` Grouping is implicit over non-aggregate projected variables. `count` counts distinct public source values. The other numeric aggregates retain duplicate input rows. Ungrouped empty input returns one row with count zero and the other aggregates `null`; grouped empty input returns no rows. Aggregate and optional-projection targets must appear in `find`. `having` and `orderBy` may refer only to result bindings. Invalid target collisions and nonnumeric aggregate comparisons are typed preflight failures. ## Snapshot-stable pagination Ordinary queries return a cursor too: ```ts const query = { find: ["?person", "?name"], where: [["?person", ":person/name", "?name"]], } as const; const pages = Effect.gen(function* () { const first = yield* triples.query(query, { pageSize: 50 }); const second = first.nextCursor ? yield* triples.query(query, { pageSize: 50, cursor: first.nextCursor }) : undefined; return { first, second }; }); ``` Use the same query and page size for continuation. Omitted temporal fields inherit the original page's basis. `query.limit` and `query.offset` define the total logical subset; `options.pageSize` pages that subset without changing its membership. `queryPage` exposes separate wrapper filters, ordering, page size, and optional total count: ```ts const request = { inner: { find: ["?person", "?name"], where: [["?person", ":person/name", "?name"]], }, orderBy: [{ variable: "?name", direction: "asc" }], limit: 50, } as const; const pages = Effect.gen(function* () { const first = yield* triples.queryPage(request); const second = first.nextCursor ? yield* triples.queryPage({ ...request, cursor: first.nextCursor }) : undefined; return { first, second }; }); ``` Triplex completes the requested order with deterministic tie-breakers and pins the first page's exact recorded commit position. Later assertions and retractions cannot move rows between pages. The opaque cursor is versioned and bound to the canonical query, wrapper filters, complete order, temporal basis, and database scope. Malformed or cross-query/scope reuse fails with `PaginationCursorError`. The total order is numbers/datetimes, booleans, text-family values, then null. Direction applies within a family. Applications should treat cursors as short-lived capabilities and never decode or edit them. Queries with joins, negation, disjunction, optional projections, aggregation, and recursive rules all support this pagination contract. Constant-only projections form at most one distinct row and return a terminal page. `includeCount` defaults to false because it evaluates an additional full count query. ### Migrating complete reads `Triples.query` now returns the first page instead of an unbounded array. Callers must follow `nextCursor` to process further pages. Trusted batch operations that explicitly require complete results can use `triples.queryAll(query, options)`, which retains the previous raw-query semantics. Core configuration discovery and derivation evaluation use that explicit API to preserve complete results. `Triples.queryPage` without a limit is now bounded too. Pagination bounds result transfer, not necessarily database work. See the [Datalog performance investigation](datalog-performance.md) for measured SQLite/PostgreSQL plans, timings, and the remaining low-level fact-read audit. ## Recursive rules Triplex supports a deliberately bounded binary recursive form: ```ts const ancestors = Effect.gen(function* () { const { results } = yield* triples.query({ find: ["?ancestor"], where: [["ancestor", "person:alice", "?ancestor"]], rules: [ { name: "ancestor", body: [["?x", ":parent", "?y"]] }, { name: "ancestor", body: [ ["?x", ":parent", "?z"], ["ancestor", "?z", "?y"], ], }, ], }); return results; }); ``` Same-named definitions union. SQL compiles them to recursive CTEs; KV evaluates them to a deduplicated fixpoint. Rule endpoints are string identities, repeated variables unify as equality constraints, and `maxDepth` is a positive safe integer. Unsupported recursive bodies fail during preflight rather than receiving different interpretations from different backends. ## Dependency discovery The subscriptions and derivation modules statically discover fixed attributes from Datalog. `SubscriptionManager.checkAffected` reports queries that may need recomputation; it is not an automatic live-query runtime. `Triples.dependencyState` returns the latest assertion/retraction position and earliest recorded future valid-time edge for a fixed attribute set. Exact derivation provenance has a narrower contract than the query engine. It currently accepts patterns, predicates, and negation, and rejects rules, disjunction, aggregation, pagination, dynamic attributes, and transaction-binding clauses when it cannot preserve a complete source explanation. See [Derivations](derivations.md). --- # Configuration releases and rollback This walkthrough defines a typed course schema, publishes two immutable releases, promotes them through refs, pins operational writes to the actual returned snapshot IDs, inspects history, and rolls `live` back. It uses the in-memory layer so the whole program is executable from a source checkout. For the underlying node, revision, compatibility, validation, and enforcement contracts, see the [configuration reference](/configuration). ## Complete runnable example From the repository root, run: ```sh pnpm exec tsx --tsconfig docs/snippets/tsconfig.json docs/snippets/config-versioning.ts ``` ```ts import { Effect, Layer } from "effect"; import { EntityId, KvTriples, Triples } from "@triplex-build/triplex"; import { Attribute, ConfigStore, EntityType, GraphConstraint, InMemoryConfigStore, } from "@triplex-build/triplex/config"; const CourseTitle = Attribute.text(":course/title"); const CourseStatus = Attribute.enumOf(":course/status", ["draft", "published"]); const CourseV1 = EntityType.make("Course", { attributes: { title: Attribute.use(CourseTitle, { required: true }), }, }); const CourseV2 = EntityType.make("Course", { attributes: { title: Attribute.use(CourseTitle, { required: true }), status: Attribute.use(CourseStatus, { required: true }), }, }); const program = Effect.gen(function* () { const config = yield* ConfigStore.ConfigStore; const triples = yield* Triples; // Publish v1 to test, then promote that exact returned snapshot to live. const v1 = yield* config.commit({ label: "courses-2026.1", objects: yield* CourseV1.nodes, ref: "test", }); yield* config.setRef("live", v1.snapshot.id); yield* triples.transact( [ { op: "assert", entityId: EntityId.make("course:intro"), entityType: CourseV1.entityType, ...CourseV1.title.assertion("Introduction to temporal data"), }, ], { actor: "user:publisher", commandId: "courses/create-intro/v1", configSnapshot: v1.snapshot.id, enforce: GraphConstraint.enforcement(CourseV1.constraints), }, ); // Publish a complete v2 graph without changing live, inspect it, then promote it. const v2 = yield* config.commit({ label: "courses-2026.2", objects: yield* CourseV2.nodes, ref: "test", }); const testBeforePromotion = yield* config.resolveRef("test"); yield* config.setRef("live", v2.snapshot.id); yield* triples.transact( [ { op: "assert", entityId: EntityId.make("course:advanced"), entityType: CourseV2.entityType, ...CourseV2.title.assertion("Advanced temporal data"), }, { op: "assert", entityId: EntityId.make("course:advanced"), entityType: CourseV2.entityType, ...CourseV2.status.assertion("published"), }, ], { actor: "user:publisher", commandId: "courses/create-advanced/v1", configSnapshot: v2.snapshot.id, enforce: GraphConstraint.enforcement(CourseV2.constraints), }, ); const state = yield* config.load(); const changes = InMemoryConfigStore.changesBetween(state, v1.snapshot, v2.snapshot); const historicalV2 = yield* config.snapshotById(v2.snapshot.id); const v2Receipt = yield* triples.transactionByCommand("courses/create-advanced/v1"); // Roll back configuration by moving live. Operational facts and their pins remain. yield* config.setRef("live", v1.snapshot.id); const liveAfterRollback = yield* config.resolveRef("live"); const advancedCourse = yield* triples.entity(EntityId.make("course:advanced")); return { v1Snapshot: v1.snapshot.id, v2Snapshot: v2.snapshot.id, testBeforePromotion: testBeforePromotion?.label, changedObjects: changes.map((change) => `${change.kind}:${change.key}`), historicalV2: historicalV2?.label, v2WritePin: v2Receipt?.configSnapshot, liveAfterRollback: liveAfterRollback?.label, advancedFactsAfterRollback: advancedCourse.length, }; }); const AppLayer = ConfigStore.layer.pipe(Layer.provideMerge(KvTriples.layer)); const result = await Effect.runPromise(program.pipe(Effect.provide(AppLayer))); console.log(JSON.stringify(result, null, 2)); ``` The two snapshot values in the output are real IDs returned by `ConfigStore.commit`; never copy a made-up `sha256-…` placeholder into transaction metadata. The exact hashes are deterministic for this history, but application code should treat them as opaque. The stable outcomes are: - `testBeforePromotion` and `historicalV2` are `courses-2026.2`; - the change list contains the added `:course/status` attribute and changed `Course` schema; - `v2WritePin` equals the returned v2 snapshot ID; - after rollback, `liveAfterRollback` is `courses-2026.1`; and - `advancedFactsAfterRollback` is still `2`. ## 1. Define typed schema objects `Attribute` owns a global keyword and value type. `EntityType` owns how that attribute is used by one entity type. Adding `status` as required changes the `Course` entity schema in v2 without changing the identity or definition of `:course/title`. `CourseV1.nodes` and `CourseV2.nodes` each evaluate to the complete configuration nodes needed by that schema, including generated graph-constraint nodes nested under the entity schema. A commit takes a complete object set, not a patch against the previous release. ## 2. Publish without promoting Passing `ref: "test"` commits the release and moves `test` in the same atomic Triplex transaction. The example does not point `live` at arbitrary authoring state: it calls `setRef("live", v1.snapshot.id)` with the exact ID returned by the successful commit. The v2 commit moves only `test`, so `live` remains on v1 during inspection. `resolveRef("test")` returns the immutable snapshot behind the current pointer. A deployment tool can inspect that snapshot and its changes before promotion. ## 3. Promote and pin writes Promotion is another `setRef` call. Moving a ref copies no configuration, and compare-and-retract prevents a stale concurrent writer from silently replacing a newer ref target. The operational transactions use: ```ts { configSnapshot: v2.snapshot.id, enforce: GraphConstraint.enforcement(CourseV2.constraints) } ``` The snapshot pin records which rules governed the command. `enforce` is separate and opt-in: a snapshot ID in metadata does not itself turn constraints on. In a long-running host, resolve the intended ref once at the command boundary, collect constraints from that immutable snapshot when the TypeScript handles are not already present, and use that same snapshot and rule set throughout the transaction. ## 4. Inspect an older release `snapshotById(id)` resolves an immutable historical release whether or not a ref still points to it. `load()` exposes the reference store model, and `InMemoryConfigStore.changesBetween(state, from, to)` reports added, removed, and changed logical objects. The detailed reference also exposes node-level Merkle diffs, revision history, reverse dependencies, and impact candidates. The CLI and dashboard provide operator views over the same records. See [CLI and dashboard](/tools#inspect-configuration) for commands that list releases, resolve a ref, inspect one object's immutable history, and move a ref. ## 5. Understand rollback The final `setRef("live", v1.snapshot.id)` is a configuration rollback. It changes what future code resolving `live` sees. It does **not**: - retract or rewrite facts written while v2 was live; - change the `configSnapshot` stored on earlier transaction receipts; - delete v2, its object revisions, or its dependency graph; - transform data so it conforms to v1; or - reverse external work already performed by the host. That is why the v2 course still has two operational facts after rollback and its receipt still points to v2. Data migration, compensating commands, validation, and external side-effect recovery are separate host-owned operations. Plan them explicitly when a configuration change alters what old or new data means. ## Durable version For persistence, compose `ConfigStore.layer` over one shared SQLite or PostgreSQL `Triples` layer. Do not create separate database layers for operational facts and configuration if they must share an atomic boundary: ```ts const AppLayer = ConfigStore.layer.pipe( Layer.provideMerge(SqliteTriples.layer({ filename: "./triplex.db" })), ); ``` This is a focused composition fragment; imports and the complete in-memory program appear above. See [Getting started](/getting-started#use-durable-sqlite) and [Host integration](/host-integration) for runtime and migration choices. --- # Derivations `@triplex-build/triplex/derivation` turns structural Datalog results into content-addressed candidates with explicit identity, provenance, temporal basis, and configuration identity. It is a derived-fact engine, not a workflow engine: applications decide whether a candidate opens a task, updates a projection, triggers an integration, or has no operational consequence. For an accessible example of a placement producing safety-training work, start with [Derived work and provenance](/concepts#derived-work-and-provenance). This page defines the detailed evaluation, materialization, reconciliation, and overlay contracts. ## Definitions and candidates ```ts import * as Derivation from "@triplex-build/triplex/derivation"; const program = Effect.gen(function* () { const openTraining = yield* Derivation.make({ name: "task.site-training", configSnapshot: deployedSnapshot.id, identity: ["?worker", "?site"], query: { find: ["?worker", "?site"], where: [ ["?placement", ":placement/worker", "?worker"], ["?placement", ":placement/site", "?site"], ["not", ["?worker", ":training/site", "?site"]], ], }, }); const evaluation = yield* Derivation.evaluate(triples, openTraining, { basis: { validAt: Date.now() }, }); return { openTraining, evaluation }; }); ``` A definition content-binds its name, complete query, optional result `TypeExpr`, declared identity projection, discovered dependencies, and config snapshot. Results sharing the declared identity become one candidate; source provenance from every positive graph path is merged. Candidate identity is stable across definition revisions. Candidate revision changes when the definition, config pin, result, or explanation changes. Conflicting results for one identity fail with a typed error rather than being selected arbitrarily. Each positive source records its triple ID, assertion transaction ID, and transaction position. The candidate also retains the pinned bitemporal basis and earliest future `validTo` among its supporting facts when known. ## Provenance contract The initial exact-provenance language supports patterns, predicates, and negation. Recursive rules, disjunction, aggregation, pagination, dynamic attributes, and transaction-binding clauses are rejected when the engine cannot provide a complete and stable source explanation. Negation explains that the candidate depended on absence but naturally has no positive source fact for the missing evidence. `nextTemporalBoundary` compensates for temporal negation by considering recorded future edges across all fixed dependency attributes, including evidence that currently suppresses every candidate. ## Reconciliation `Derivation.reconcile(previous, next)` is a pure diff over stable candidate identity. It returns: - `added` — a logical candidate appeared; - `removed` — it no longer applies; - `changed` — it remains but its revision changed; and - `unchanged` — both identity and revision are unchanged. The host translates that diff into domain work. A compliance host might create a durable requirement occurrence for `added`, satisfy or cancel one for `removed`, revise its explanation for `changed`, and perform no write for `unchanged`. Triplex deliberately does not define those lifecycle semantics. ## Materialization ```ts const inspectMaterialization = Effect.gen(function* () { const run = yield* Derivation.Materialization.materialize(triples, openTraining, { basis: { validAt: now }, }); const state = yield* Derivation.Materialization.current(triples, openTraining, { basis: { validAt: now }, }); return { run, state }; }); ``` Materialization persists immutable candidate revisions and a complete evaluation run as reserved Triplex entities. A run atomically binds: - definition and configuration IDs; - the bitemporal basis; - candidate membership; - the latest dependency-relevant source position; and - the next temporal boundary, including for an empty candidate set. `current` returns `current`, `stale`, or `unmaterialized`. Stale state retains the last durable candidates and source position. An unrelated transaction does not make a fixed-dependency run stale. Definition changes, relevant writes, and a different basis do. There is no mutable first-writer head race. Runs are ordered by dependency source position and then their materialization commit position. Stored candidate bodies are schema-decoded and their content IDs are verified on read. `Materialization.runsQuery` exposes immutable historical membership as ordinary Datalog. ## Dependency state and temporal wakeups `Triples.dependencyState(attributes, basis)` reads backend indexes rather than replaying the journal. It returns the latest assertion-or-retraction commit position and the earliest recorded future `validFrom` or `validTo` for the fixed attribute set. Every evaluation and materialized run exposes the same conservative `nextTemporalBoundary`. A host scheduler should wake and rematerialize at that instant: ```ts if (run.nextTemporalBoundary !== undefined) { scheduler.wakeAt(run.nextTemporalBoundary, openTraining.id); } ``` The schedule is attribute-conservative: an unrelated entity sharing a dependency attribute may cause an extra wakeup, but recorded future-effective or expiring evidence is not omitted. Dynamic attribute definitions use an explicit journal fallback and should not be hot-path materializers. Triplex owns boundary discovery, not timer delivery or retry policy. ## Hypothetical overlays ```ts const previewChange = Effect.gen(function* () { const preview = yield* Derivation.Overlay.evaluateOverlay(triples, openTraining, { basis: { validAt: now }, overlay: { assertions: [proposedPlacement, proposedTraining], retractions: [supersededFactId], }, }); return preview; }); ``` Overlay evaluation copies only the definition's fixed dependency attributes at the requested basis into a private in-memory KV index, applies temporary assertions and visible-fact retractions, and runs the same structural evaluator. It never mutates the source facts or transaction journal. Base sources retain their durable IDs. Temporary sources receive deterministic hypothetical content commitments and are marked `hypothetical: true`, making collect-versus-reuse previews comparable to committed evaluation without claiming they happened. An assertion without `validFrom` begins at the overlay's `validAt`. Retractions must identify a fact visible at that basis. Duplicate, missing, irrelevant, dynamic-attribute, or transaction-binding operations fail explicitly rather than producing an incomplete preview. ## Host boundary Triplex provides fact history, Datalog, config identity, candidate provenance, freshness, temporal wakeups, and pure diffs. The host remains responsible for: - durable workflow or requirement occurrences; - assignments, conversations, evidence disposition, and authorization; - command naming and response caching; - timer delivery, integrations, retries, inboxes, and outboxes; and - interpreting candidate addition or removal as product behavior. The standalone [compliance host demo](https://github.com/bjacobso/triplex/tree/main/examples/compliance-host) composes those responsibilities end to end over the in-memory backend. --- # Source provenance Triplex began as the database subsystem of an earlier private application repository. The initial import was filtered from source commit `473753746f7c8f40c059edf81cca47a8e04202ef` and retained the history affecting: - `packages/database` - `test/stress` The filtered history is merged into this repository, preserving commit authorship and messages. After import, the packages were renamed and flattened into the standalone package family. The source application is not a dependency and is not automatically synchronized with this repository. Typed configuration began in the adjacent MIT-licensed `config-graph` repository. Its complete reachable history through source commit `93de8e3` was merged without squashing, first under the temporary `.config-graph-import` prefix and then reshaped into Triplex in a separate commit. The source repository was not modified and remains an independent historical repository. --- # Typed configuration Triplex configuration is immutable, typed, and content-addressed. It is separate from operational facts even when persisted through the same `Triples` service: > **Looking for the release workflow?** Start with the [complete publish, promote, pin, inspect, and rollback walkthrough](/configuration-versioning). This page is the detailed configuration contract. - an `EntitySnapshot` materializes one fact entity at a transaction or time; - a `ConfigSnapshot` is an immutable release root containing revisions, schema stamps, dependency closures, and refs. Configuration is exported from `@triplex-build/triplex/config` rather than flattened into the package root. ## Content model `TypeExpr` describes runtime values. `ConfigNode` attaches a stable logical `(kind, key)` identity, typed attributes, and references to that value. Canonical encoding and domain-separated SHA-256 produce explicit `ContentId` values; equal nodes deduplicate and unchanged revisions are shared between releases. `InMemoryConfigStore` is the immutable semantic reference implementation. `ConfigStore.layer` is the Effect-native store that persists config objects, revisions, snapshots, and refs as reserved Triplex system facts. A release commit and optional ref move use one atomic Triples transaction. ```ts import { Layer } from "effect"; import { KvTriples } from "@triplex-build/triplex"; import { ConfigStore } from "@triplex-build/triplex/config"; const AppLayer = ConfigStore.layer.pipe(Layer.provideMerge(KvTriples.layer)); ``` ## Ontology DSL The DSL separates three identities: - entity type: PascalCase, such as `Employer`; - global attribute: lowercase namespaced keyword, such as `:employer/name`; - TypeScript property: an ergonomic local alias, such as `name`. ```ts import { EntityId } from "@triplex-build/triplex"; import { Attribute, EntityType } from "@triplex-build/triplex/config"; export const EmployerName = Attribute.text(":employer/name"); export const Employer = EntityType.make("Employer", { attributes: { name: Attribute.use(EmployerName, { required: true, unique: true, }), }, }); export const EmployerSummary = EntityType.make("EmployerSummary", { attributes: { name: Attribute.use(EmployerName, { required: false }), }, }); export const EmploymentEmployer = Attribute.ref(":employment/employer", Employer); export const Employment = EntityType.make("Employment", { attributes: { employer: Attribute.use(EmploymentEmployer, { required: true }), }, }); const validFrom = Date.now(); Employer.attributes.name.key; // ":employer/name" Employer.attributes.name.assertion("Acme", { validFrom }); Employment.attributes.employer.assertion(EntityId.make("employer:acme"), { validFrom }); ``` Requiredness is not inherently true of `:employer/name`; it is true of the way `Employer` uses the attribute. Reference attributes follow the same separation. `Employer.nodes` yields the independently identified attribute definitions followed by the entity schema. `Employer.constraints` yields independently content-addressed required, cardinality, uniqueness, and reference-target rules. Handles remain convenient at the application boundary: ## Releases and refs Commit application-defined node kinds together. Triplex does not prescribe what a form, policy, routine, permission, integration, or view means; it preserves their typed graph and identity. ```ts const publish = Effect.gen(function* () { const config = yield* ConfigStore.ConfigStore; const release = yield* config.commit({ label: "2026.1", objects: [...(yield* Employer.nodes), formNode, policyNode, routineNode], ref: "live", }); yield* config.setRef("test", release.snapshot.id); return release; }); ``` Moving a ref copies no configuration. Ref movement uses compare-and-retract so a stale writer cannot silently overwrite a newer pointer. The store rejects dangling refs and duplicate logical objects, preserves `validUnder` schema compatibility, and uses Datalog for reverse dependency and deploy-impact candidate discovery. Merkle hashing and proof verification remain content operations, not database queries. Operational transactions can pin the exact release that governed them: ```ts const write = Effect.gen(function* () { yield* triples.transact(operations, { actor: "agent:worker-7", commandId: "command:123", configSnapshot: release.snapshot.id, }); }); ``` ## Runtime evaluation and decision proofs `ConfigRuntime` resolves a ref, derives the rule catalog from that immutable snapshot, reads only the current or historical Triple facts statically reachable by the rule, and evaluates against the pinned release: ```ts const decide = Effect.gen(function* () { const decision = yield* ConfigRuntime.evaluate({ ref: "live", rule: "may-deploy", subject: "employee:alice", clock: { now: Date.now(), granularity: "day" }, }); return ConfigRuntime.verify(decision); }); ``` The decision ID binds the release root, subject, reason, and nested evaluation. Changing the config pin or proof breaks verification. This proves internal content integrity given a trusted decision root; it does not independently prove that an external actor chose the right rule, authorized the decision, or supplied a correct real-world answer. Passing `asOf` evaluates the same deployed rule against historical facts. A rule read that observes multiple live values fails rather than arbitrarily choosing one, and nonscalar JSON is rejected at the storage-to-proof bridge. ## Validation observations `EntityValidation` deploys runtime entity schemas and explicitly validates ordinary facts against a config ref: ```ts const inspectValidation = Effect.gen(function* () { const validation = yield* EntityValidation.EntityValidation; const run = yield* validation.revalidate({ ref: "live" }); const state = yield* validation.currentInvalid("live"); const everInvalid = yield* validation.everInvalid(); const violations = yield* validation.violations({ subject: "employee:alice" }); return { run, state, everInvalid, violations }; }); ``` Results and individual violations are immutable, content-addressed reserved facts. They bind the exact config snapshot, schema, subject, and a content ID for the materialized entity state without duplicating the entity body. Fixing an entity moves its current validation head but does not erase historical invalidity or messages. The projection records the latest nonvalidation source position it observed. `currentInvalid("live")` returns `current`, `stale`, or `unvalidated`; stale results retain their last known errors rather than becoming an empty answer. Query builders such as `currentInvalidQuery`, `everInvalidQuery`, and `violationsQuery` produce ordinary Datalog that can be composed with application queries. ## Atomic graph constraints The same rules can guard an operational transaction: ```ts const constrainedWrite = Effect.gen(function* () { const snapshot = release.snapshot; const constraints = yield* GraphConstraint.collect(snapshot.root); yield* triples.transact(operations, { actor: "agent:worker-7", configSnapshot: snapshot.id, enforce: GraphConstraint.enforcement(constraints), }); }); ``` Enforcement projects the complete post-state and checks every represented valid-time boundary, including future-effective intervals. A new or worsened required, cardinality, uniqueness, or reference-target violation fails with `ConstraintViolationError`; facts, journal, command receipt, and commit position all roll back. Existing violations do not block unrelated writes or repairs. KV, SQLite, and PostgreSQL serialize this check through the commit-position boundary, preventing concurrent Triplex writers from both winning the same absence or uniqueness decision. Candidate loading uses entity-type and reference-target indexes plus batched subject reads. Direct adapter writes, unconstrained commands, authorization, and general Datalog invariants remain outside this guarantee. Validation observations remain useful for migrations and audit even when enforcement is enabled. Applications normally resolve one pinned release, collect its rules, and pass both its `configSnapshot` and enforcement set at the command boundary. ## Explore configuration The repository dashboard exposes typed nodes, releases, refs, immutable object history, and impact analysis over its demo database or a supplied SQLite/PostgreSQL database. The CLI provides the same operator-oriented history and ref controls as stable JSON. See [CLI and dashboard](/tools#inspect-configuration). Continue to [Derivations](/derivations) to use a pinned configuration snapshot in explainable derived work, or [Troubleshooting](/troubleshooting#does-moving-a-config-ref-roll-back-data) for common release and rollback surprises. --- # Operational Primitives Triplex is a durable substrate for applications that need temporal facts, derivations, and audit. This specification separates database/runtime guarantees that belong in Triplex from workflow and product concepts that belong in a host application. Read [Core concepts](/concepts) for the beginner-oriented model and [Host integration](/host-integration) for application composition. This page is the low-level operational contract. ## Implemented foundation ### Atomic transactions `Triples.transact` is the authoritative portable command boundary. `assert`, `assertBatch`, `retract`, and `retractByPattern` delegate to it, so every supported write receives one commit position and causal envelope. SQL adapters execute it in a native SQL transaction. KV implementations create a transaction-scoped hexastore over `KvBackend.transact`; all index and metadata writes commit or roll back together. The in-memory backend serializes transactions and buffers their writes. A completed KV transaction clears the shared decoded-datom cache so reads observe its result. ### Compare-and-retract A transaction may declare `TripleLive` preconditions. Each condition must identify an explicit `retract` operation in the same transaction. Retraction is the atomic comparison: exactly one writer can retract the expected live fact. A stale writer receives `TransactionConflictError` and all of its writes roll back. This intentionally small primitive covers moving pointers and state machines without pretending that a read followed by an arbitrary write is compare-and-set. Config refs and entity-validation heads use it. There is no generic public "assert when no value exists" precondition. The narrower graph-constraint enforcement path does atomically protect declared required, cardinality, uniqueness, and reference-target invariants by evaluating them inside the serialized commit boundary. ### Causal transaction envelopes Every successful `transact` persists an `_Transaction` entity with: - `:_tx/position`; - `:_tx/instant`; - `:_tx/actor`; - `:_tx/command-id`; - `:_tx/correlation-id`; - `:_tx/causation-id`; - `:_tx/config-snapshot`; and - one indexed `:_tx/changed-entity` reference per changed application entity; and - one `:_tx/change` JSON fact for every asserted or retracted application fact. `Triples.transaction(txId)` reconstructs the typed envelope. The same facts remain available to Datalog for application-specific audit queries. A command ID is an atomically unique idempotency identity within one Triplex database. Concurrent attempts acquire a backend-local claim inside the same transaction as the facts and envelope; exactly one commits. A loser receives `CommandAlreadyCommittedError` with the original transaction ID, and `Triples.transactionByCommand(commandId)` loads that durable receipt. `Triples.transactionsForEntity(entityId, request)` uses the changed-entity references to read a bounded newest-first audit page without replaying or decoding the global journal. It deduplicates transactions that changed several facts on the entity, then hydrates their authoritative complete envelopes. The first page returns a `snapshotPosition`; continuation requests combine it with the exclusive `nextBeforePosition`, so concurrent later commits do not enter an in-progress timeline. Invalid limits and positions fail with `ReadError`, and an unknown entity returns an empty page. ### Ordered transaction feed The backend allocates a monotonically increasing commit position inside the same atomic boundary as the application facts and causal envelope. `Triples.transactions({ after, limit })` returns envelopes in that order and exposes the last position as the next durable resume cursor. Failed transactions publish neither facts nor a position. This avoids treating timestamps or client-generated ULIDs as commit order under concurrency. The feed covers every successful application fact write. Delivery built on repeated page reads is at least once, so consumers retain a checkpoint and deduplicate by command or transaction identity. `ChangeEmitter` remains a best-effort wake-up mechanism and never replaces catch-up reads. ### Consumer checkpoints `@triplex-build/triplex/operational` stores one reserved, queryable checkpoint entity per named consumer. `ConsumerCheckpoint.advance` accepts the position read before processing and the final position whose effects completed. Moving it uses compare-and-retract, never moves backwards, and returns a typed conflict to a stale worker. The initial write is protected by the same atomic command-claim mechanism, so concurrent initialization cannot create two live positions. Checkpoint maintenance is atomically persisted but intentionally omitted from the transaction feed. Otherwise an idle consumer would observe its own cursor update, advance past it by writing another cursor update, and repeat forever. These unjournaled system writes still allocate commit positions, so recorded snapshot ordering remains total; application commands remain fully journaled. ### Snapshot-stable Datalog pages `Triples.queryPage` returns an opaque, versioned keyset cursor. Its content-addressed fingerprints bind the canonical query, filters, complete deterministic projected-row ordering, temporal basis, and database scope. The first page captures the backend's latest committed position; assertion and retraction positions keep every later page on that exact recorded snapshot, including when two transactions share the same recorded millisecond. Cursor decoding and reuse failures are typed as `PaginationCursorError`. ### Bitemporal query basis Facts carry separate recorded and valid intervals. Direct matching, entity reads, batched reads, and Datalog accept the same `{ recordedAt?, validAt? }` basis, so every clause in a join, negation, or rule sees one coherent cut. Historical corrections append new facts; they do not rewrite the recorded history. ### Portable derivations and pure reconciliation `@triplex-build/triplex/derivation` provides a content-addressed `Definition` that pins a complete structural Datalog query, optional result `TypeExpr`, canonical identity projection, discovered attribute dependencies, and configuration snapshot. Evaluation returns `Candidate` values with: - a stable logical identity derived from the definition name and declared result key; - a revision covering the exact definition, config pin, result, and explanation; - the pinned bitemporal basis; - merged source triple IDs and assertion transaction IDs/positions from every positive graph path; and - the earliest future `validTo` among supporting facts, when one is known. The complete `Evaluation` also exposes a conservative `nextTemporalBoundary` across the current recorded view of every dependency attribute. It includes both future `validFrom` and `validTo` edges, including facts inside negated clauses that currently suppress every candidate. This lets a host wake exactly when evidence may expire or become effective without a daily full scan. `Triples.dependencyState` derives both the dependency-relevant source position and this schedule through backend attribute indexes. It can produce harmless extra wakeups for unrelated entities that share an attribute, but it does not omit a recorded boundary. `reconcile` is a pure diff that classifies candidates as `added`, `removed`, `changed`, or `unchanged`. A host can translate that diff into durable requirement occurrences, tasks, or other governed work without coupling those concepts to Triplex. Conflicting outputs for the same declared identity fail with a typed error rather than being selected arbitrarily. `Derivation.Materialization` persists candidate revisions and complete evaluation runs as immutable Triples system entities. Each run atomically binds its definition, config snapshot, bitemporal basis, candidate set, and latest dependency-relevant transaction position. There is no mutable first-writer checkpoint race: the current run for a definition is selected by source position and then materialization commit position. A newly deployed definition has its own stream, while reconciliation can still compare its stable logical candidate identities with the preceding run for the same name. `current` returns explicit `current`, `stale`, or `unmaterialized` state and retains the last durable candidates when stale. Relevant transaction positions are derived from the definition's discovered attributes; materializer and unrelated transactions do not create false lag. Persisted candidate bodies are schema-decoded and content-verified. Immutable run membership can be queried with ordinary Datalog for audit and composition. Versioned run identities content-bind and expose the evaluation's `nextTemporalBoundary`, including runs with zero candidates. Triplex does not own timer delivery: a host scheduler wakes the materializer at that instant and persists the next run. `Derivation.Overlay.evaluateOverlay` applies temporary assertions and visible-triple retractions at a pinned basis in a fresh private in-memory KV store. It seeds only the definition's discovered fixed attributes and delegates to the same structural Datalog evaluator, then translates source facts back to durable IDs or deterministic hypothetical content commitments. The source Triples store and transaction journal are never mutated. This supports collect-versus-reuse and proposed relationship planning with the same candidate identity and explanation shape as committed data. Assertions whose `validFrom` is omitted begin at the overlay's `validAt`. Retractions must identify facts visible at that basis, and duplicate or irrelevant patch operations fail explicitly. Dynamic attribute queries and transaction-binding clauses are rejected because a bounded copy could not preserve their semantics honestly. The initial provenance contract supports patterns, predicates, and negation. Recursive rules, disjunction, aggregation, and pagination are rejected until their exact provenance semantics are implemented. ## Delivered extensions and next primitives ### Deferred: scoped optimistic concurrency The global commit position is an ordering and checkpoint token, not an application-wide expected version. Today, however, allocating that position uses one backend-wide contention point, and graph constraint enforcement deliberately relies on the same serialization to prevent absence and uniqueness write skew. This is simple and safe, but it may serialize commands whose business state is independent. A future design may separate these responsibilities, following the distinction described in [Basic principles of record storing](http://systema10.org/posts/basic-principles-of-record-storing.html): - every successful transaction still receives one global position for replay, temporal snapshots, subscriptions, and consumer checkpoints; - optimistic concurrency claims versions for explicit conflict scopes rather than the complete journal; - a scope may identify an entity or a cross-entity invariant such as a uniqueness key, movable ref, compliance scope, or reference target; - one transaction atomically claims every affected scope, writes its facts, and appends its journal envelope, or commits none of them; and - append-only operations with no lost-update risk may explicitly request no conflict check. An entity-only `(record, entity, version)` index is not sufficient for Triplex because graph constraints can couple otherwise distinct entities. A general branded `ConflictScope`, an atomic scope-head compare-and-advance operation, and indexed transaction-to-scope/entity facts are the more promising shape. Multi-scope claims must verify successor continuity, not merely rely on a unique `(scope, version)` pair. Record insertion and scope materialization must never be separate commits. This remains deferred. Do not weaken the current global graph-constraint serialization until a KV/SQLite/PostgreSQL conformance suite proves atomic multi-scope claims, cross-entity invariant protection, and unchanged global journal ordering. A reusable Effect fold or stream over `Triples.transactions` can be considered independently; ordinary reads should continue to use indexed triples, Datalog, and checkpointed projections rather than replaying the complete journal. ### Inboxes and outboxes Define conventional first-class inbox and outbox records without embedding delivery vendors or retry policy in core. Command receipts and consumer checkpoints now provide the atomic identities and durable resume positions beneath them. ### Graph constraints `TypeExpr` remains local and decidable. Separately content-addressed `GraphConstraint` nodes now cover cardinality, uniqueness, required relationships, and reference target kinds. The ontology DSL generates them from usage-local declarations, and `EntityValidation` writes their findings as first-class violations with stable codes and constraint identities. Read-only evaluation accepts a bitemporal basis. Opt-in transaction enforcement accepts those same plain versioned rules alongside the pinned config snapshot. It projects the whole command, evaluates every post-state valid-time boundary, and rejects new or worsened findings atomically. Commit-position serialization provides backend-portable absence and uniqueness protection across Triplex writers. Observation mode remains available for migration and audit, while direct adapter writes, authorization, and general Datalog invariants remain outside this guarantee. ### Projection ownership Indexed dependency state and durable consumer checkpoints now let long-running materializers avoid a full journal replay. Triplex should not turn an added candidate into a Task or a removed candidate into a cancellation; applications own durable occurrences, assignment, evidence disposition, conversations, and retry policy. ### Content-addressed blobs Define a browser-safe `BlobStore` service that streams bytes, computes the shared SHA-256 `ContentId`, verifies reads, and leaves metadata and references in Triples. Durable adapters such as S3 or R2 stay outside core. Garbage collection follows reachability and retention policy. ### Configuration migrations Connect release diffs to affected-entity queries, typed transformations, checkpointed batches, validation observations, and completion facts. Triplex owns provenance and resumability; domain transformations remain application code. ## Runtime and backend boundary These guarantees are in the shared `Triples` contract and are exercised by in-memory KV and SQLite in the default suite. PostgreSQL passes the same conformance and multi-database isolation tests through a dedicated integration suite that runs in CI and is opt-in locally. PostgreSQL remains a pre-1.0 production candidate pending operational validation. Cloudflare and FoundationDB are experimental and are not covered by every guarantee above. PostgreSQL additionally exposes three composition boundaries. `PgTriples.layer` and `layerFromUrl` own a pool and migrate it for standalone use. `layerFromSqlClient` consumes an ambient host-owned `SqlClient`, creates no pool, and runs no DDL; both host statements and nested `Triples.transact` therefore resolve Effect SQL's same fiber-local transaction connection. `layerForDatabase` creates a pool whose every connection is bound at startup to the schema derived from a validated, server-resolved `DatabaseId`, and provides both that scoped client and `Triples`. The `*Migrated` variants are explicit provisioning conveniences. Production migration execution remains host-controlled through the ordered exports in `@triplex-build/triplex-sql`. Subscriptions remain conservative invalidation hints rather than an automatic live-query runtime. Entity snapshots, validation observations, and derivation runs remain checkpointed projections; their freshness/source positions are part of their correctness contract. See [`current-state.md`](current-state.md) for the complete maturity matrix. ## Boundary Triplex should not define Threads, routine step DSLs, timers, integration catalogs, tenancy, or HR permissions. It should provide the atomicity, concurrency, history, constraints, temporal queries, content identity, and durable derivation mechanics those systems require. --- # Configuration-derived HTTP API `@triplex-build/triplex-http` turns a persisted `ConfigSnapshot` into runtime request/response schemas, an Effect `HttpApi`, OpenAPI 3.1, and REST handlers. It never needs the original `Attribute` or `EntityType` TypeScript values after deployment. The package depends only on public Triplex core and config APIs; the host chooses and provides its backend and server. ## Host composition ```ts import { ConfigStore } from "@triplex-build/triplex/config"; import { EntityHttp, HttpAuthorizationAllowAll } from "@triplex-build/triplex-http"; import { SqliteTriples } from "@triplex-build/triplex-sqlite"; import { Layer } from "effect"; const database = ConfigStore.layer.pipe( Layer.provideMerge(SqliteTriples.layer({ filename: "triplex.sqlite" })), ); const routes = EntityHttp.layer({ basePath: "/api", exposure: { collections: { Employer: "employers" } }, }).pipe( Layer.provide(HttpAuthorizationAllowAll), // local examples only Layer.provide(database), ); ``` Production hosts provide `HttpAuthorization` with request authorization, row visibility for collection reads, and trusted actor metadata. Payload fields never control transaction metadata. ## Routes and wire format For `/api/rest/{version}`, the package exposes `schema`, `openapi.json`, an optional interactive Scalar `docs` page, collection GET/POST, and item GET/PUT/DELETE routes. Create and replace bodies have one `attributes` object: ```json { "attributes": { ":employer/name": "Acme" } } ``` Responses add server-owned identity and the configured entity type. Full keywords are the only accepted wire keys. Cardinality-many attributes are deterministically ordered arrays; list-valued attributes remain one JSON fact. Dates are `YYYY-MM-DD`, instants are epoch milliseconds, and refs are entity ID strings. Every response has `X-Triplex-Config-Snapshot`. Writes carry the same ID in `TransactionMeta.configSnapshot`, decode before mutation, enforce constraints from the complete release, and use one `Triples.transact` boundary. PUT replaces only exposed attributes and retains unexposed facts. DELETE retains normal bitemporal history. The initial contract rejects empty entities, mixed entity-type membership, and mutations of entities with active scheduled facts. ## Versions and temporal reads - `latest` resolves `latestRef` (`live` by default) for each request and is writable. - Explicit snapshot IDs and configured aliases are immutable and read-only by default. - `activeWriteSnapshot` can designate one immutable snapshot as the deployment's write contract. - `recordedAt` and `validAt` are validated epoch-millisecond query parameters on reads only. Collection cursors bind the database scope, entity type, temporal basis, exact commit position, and the surrounding HTTP snapshot/version cache key. A continuation therefore materializes every body at the first page's exact cut. If a movable ref changes, clients must restart under the newly resolved contract. Errors are JSON objects with a stable `code`, sanitized `message`, and an optional field `path`. Malformed input is `400`, host authentication/authorization is `401`/`403`, missing resources are `404`, read-only writes are `405` with `Allow`, and data/constraint/concurrency conflicts are `409`. Unexpected storage and deployed-config failures return a sanitized `500` while their causes remain in Effect logs. --- # Performance Triplex is pre-1.0 and does not yet publish a production performance guarantee. These measurements are a reproducible baseline for finding scaling cliffs, not a capacity-planning substitute. ## September 2026 baseline The following SQLite in-memory Datalog pagination measurements are medians of five warm runs on a single 8-core, 16 GB Linux VM. Reads requested the first 100 rows unless explicitly described as complete. There was no concurrent load. | Facts | All facts, first 100 | Fixed attribute, first 100 | Join, first 100 | Group count | | --------: | ------------------------------------------------------: | -------------------------: | --------------: | ----------: | | 30,000 | 35 ms | 13 ms | 16 ms | 15 ms | | 300,000 | 346 ms | 122 ms | 153 ms | 165 ms | | 3,000,000 | Out of memory during an unbounded `queryAll` setup read | — | — | — | The first-page latency grows with the number of candidate table rows even though the returned page is fixed at 100. See [Datalog performance](datalog-performance.md) for the query-plan explanation and indexing guidance. `queryAll` is intended only for trusted batch processing with an explicit memory budget. An in-memory KV run at approximately 920,000 triples measured about 3,400 inserted triples per second and 823 ms for one entity lookup. Its entity-type scan exposed a stack overflow in large cross-partition result collection; the implementation now appends entries iteratively and has a 150,000-entry regression test. ## Reproducing measurements The multi-backend harness exercises writes through Triplex APIs, current and historical reads, and Datalog queries: ```sh # Quick SQLite smoke run (~100,000 initial triples) pnpm --filter triplex-stress stress-test:smoke # Full report; Docker enables PostgreSQL and a native client enables FoundationDB pnpm --filter triplex-stress benchmark # Focused pagination benchmark pnpm --filter triplex-stress benchmark:datalog ``` The full report records Node, CPU, memory, fact count, update history depth, insertion throughput, database size, and per-query wall time. PostgreSQL and FoundationDB results are omitted when their required local infrastructure is unavailable. Always report storage mode, warm/cold cache state, contention, and whether a read used bounded pagination or `queryAll` when comparing runs. --- # CLI and dashboard Triplex includes two operator tools: a published JSON-first CLI for repeatable commands and a browser dashboard for interactive exploration. Both are pre-1.0. The dashboard is a private workspace package rather than a published application. ## Build from the source checkout After following [Getting started](/getting-started), build the CLI and its workspace dependencies, then define a short shell function for the built binary: ```sh pnpm turbo run build --filter=@triplex-build/triplex-cli... triplex() { node --disable-warning=ExperimentalWarning packages/cli/dist/cli.js "$@"; } ``` The examples below use the repository command. Install `@triplex-build/triplex-cli` from npm to use the same `triplex` binary in an application: ```sh triplex --sqlite ./app.db describe triplex --sqlite ./app.db status ``` `describe` returns the machine-readable command manifest. Successful CLI commands emit one `{ "ok": true, "command": "…", "data": … }` JSON value to stdout. Decode/runtime failures emit an error envelope to stderr and exit nonzero. Add `--pretty` before the subcommand when reading output by hand. Opening the SQLite convenience layer can apply pending Triplex migrations. Hosts that require migration separation should run migrations in their deployment process before granting CLI access. ## Write and inspect a fact `transaction apply` accepts the public `TransactRequest` wire shape. Mutation input must include an actor and a unique command ID: ```sh triplex --sqlite ./app.db transaction apply --input - <<'JSON' { "operations": [ { "op": "assert", "entityId": "worker:maria", "entityType": "Worker", "attribute": ":worker/name", "value": { "type": "string", "value": "Maria" } } ], "meta": { "actor": "user:docs", "commandId": "docs/create-maria/v1" } } JSON triplex --sqlite ./app.db entity types triplex --sqlite ./app.db entity get worker:maria triplex --sqlite ./app.db entity facts worker:maria triplex --sqlite ./app.db journal receipt docs/create-maria/v1 ``` Do not retry that payload with a new command ID merely because the client lost the response. Look up the original receipt first. A duplicate committed command ID produces `CommandAlreadyCommittedError`; it does not silently execute or return success again. ## Run and page a query Create `query.json`: ```json { "find": ["?worker", "?name"], "where": [["?worker", ":worker/name", "?name"]], "orderBy": [{ "variable": "?name", "direction": "asc" }] } ``` Run or explain it: ```sh triplex --sqlite ./app.db query run --input query.json --limit 50 --debug triplex --sqlite ./app.db query explain --input query.json ``` `query run` returns one bounded page. If `data.nextCursor` is present, pass that exact opaque value back with `--cursor`; keep the query, page size, database, and temporal basis unchanged. The cursor pins the recorded snapshot so concurrent writes do not reshuffle an in-progress scan. Use epoch milliseconds to inspect a historical basis: ```sh triplex --sqlite ./app.db entity get worker:maria \ --recorded-at 1788998400000 --valid-at 1788998400000 ``` The same `--recorded-at` and `--valid-at` flags are available for fact and query reads. See [Core concepts](/concepts#recorded-time-and-valid-time) before interpreting a one-axis historical query. ## Inspect configuration Configuration authoring remains application-owned. Once an application has committed releases, operators can inspect and move their refs: ```sh triplex --sqlite ./app.db config refs triplex --sqlite ./app.db config releases triplex --sqlite ./app.db config release --ref live triplex --sqlite ./app.db config objects --kind entity-schema triplex --sqlite ./app.db config object entity-schema Course triplex --sqlite ./app.db config impact attribute :course/status ``` `config releases` returns the real snapshot IDs. To promote or roll back, copy the intended returned ID and pass it unchanged: ```sh SNAPSHOT_ID='copy an actual snapshotId returned above' triplex --sqlite ./app.db config set-ref live "$SNAPSHOT_ID" ``` The assignment is an operator template, not a command to paste unchanged. Inspect the target release before moving the ref. Moving it does not change operational data; see [Configuration releases and rollback](/configuration-versioning#understand-rollback). ## Manage local databases The CLI can manage isolated SQLite files in `./data` (or `--data-dir`): ```sh triplex db create app --description "Application database" triplex db list triplex --database-id app status ``` `db clear` and `db delete` are destructive and require `--yes`. PostgreSQL can be selected with `TRIPLEX_POSTGRES_URL`; prefer the environment over a URL flag so credentials do not enter shell history. A host must resolve database IDs from authenticated server-owned state, not unchecked request input. ## Explore in the dashboard Start the browser-local demo: ```sh pnpm dashboard ``` Open `http://localhost:4173`. The demo loads classroom fixtures into a fresh in-memory database. Edits are real Triplex writes but disappear when the app runtime restarts. To inspect a persistent database, build and run the local HTTP host: ```sh pnpm dashboard:serve -- --sqlite /absolute/path/to/triplex.db ``` The dashboard includes a spreadsheet-like entity grid with managed/runtime type filters, typed form editing, raw JSON editing, and a semantic before/after diff before commit. It also includes entity timelines, raw Datalog and query plans, the transaction journal, derivation candidates, form previews, and full configuration release/ref/revision history. Its temporal control selects recorded and valid time independently; configuration and journal views remain current so the surrounding context stays visible. You can also [try the same in-memory explorer in the website](/explorer). The server binds to `127.0.0.1` and has mutation capabilities. It is an operator tool, not an authenticated public server. Do not expose it as your application's authorization boundary. ## Runnable demos - [`examples/demo`](https://github.com/bjacobso/triplex/tree/main/examples/demo) — linked facts and Datalog - [`examples/compliance-host`](https://github.com/bjacobso/triplex/tree/main/examples/compliance-host) — configuration, journal catch-up, derivation reconciliation, overlays, and temporal wakeups - [`examples/http-api`](https://github.com/bjacobso/triplex/tree/main/examples/http-api) — generated HTTP routes and OpenAPI - [`examples/config-explorer`](https://github.com/bjacobso/triplex/tree/main/examples/config-explorer) — configuration graphs, diffs, evaluation, and proof tamper detection - [`examples/tenant-host-cloudflare`](https://github.com/bjacobso/triplex/tree/main/examples/tenant-host-cloudflare) — experimental multi-tenant reference host For the exhaustive CLI command contract, see the [package README](https://github.com/bjacobso/triplex/tree/main/packages/cli). For common failures, continue to [Troubleshooting and FAQ](/troubleshooting). --- --- # Host integration Triplex can provide the fact, temporal-query, configuration, derivation, and audit substrate for an application that also owns relational operational records. The host remains responsible for authentication, authorization, HTTP contracts, durable work, response caching, external effects, and product-specific invariants. Start with [Core concepts](/concepts#constraints-and-responsibility-boundaries) if that separation is unfamiliar. Use [CLI and dashboard](/tools) for local inspection and [Troubleshooting](/troubleshooting) for common runtime and freshness failures. The executable companion is [`examples/compliance-host`](https://github.com/bjacobso/triplex/tree/main/examples/compliance-host). Despite its focused scenario, it uses only generic Triplex primitives: content-addressed configuration, temporally scoped facts, Datalog derivations, provenance, reconciliation, hypothetical overlays, and consumer checkpoints. Treat [`current-state.md`](current-state.md) as the release-readiness contract. PostgreSQL passes the shared conformance and isolation suites, but remains a production candidate while Triplex is pre-1.0. ## Responsibility boundary Triplex provides: - atomic fact assertions and retractions with optimistic preconditions; - independent recorded and valid time; - structural Datalog queries and snapshot-stable pagination; - a causal transaction journal with actor, command, correlation, causation, and config pins; - content-addressed configuration releases and movable refs; - derivation candidates, provenance, materialization, and explicit freshness; and - database-scoped in-memory, SQLite, and PostgreSQL runtimes. The host provides: - authentication and authorization before selecting a database; - mapping from its isolation boundary to a validated `DatabaseId`; - operational tables, response caches, inboxes, outboxes, and delivery workers; - domain command validation and higher-order business invariants; - lifecycle semantics for durable tasks, cases, evidence, or other work; and - migration orchestration, monitoring, retention, and recovery policy. These are database guarantees, not authorization guarantees. Resolve the caller's access and then select a server-owned database mapping before exposing `Triples` to application code. ## Choose a runtime | Host shape | Public Triplex entry point | | ----------------------------------------------- | ------------------------------------------------ | | Ephemeral or test database | `KvTriples.layerWithScope(databaseId)` | | One SQLite database per isolation boundary | `SqliteTriples.layer({ filename })` | | Standalone PostgreSQL with a Triplex-owned pool | `PgTriples.layerFromUrl(url)` | | Host-owned Effect SQL pool | `PgTriples.layerFromSqlClient({ scope })` | | Server-mapped PostgreSQL database | `PgTriples.layerForDatabase(config, databaseId)` | Application code should depend on `Triples`. New backend authors can use the public `@triplex-build/triplex/runtime` builders and adapter contracts described in [Custom runtimes](/custom-runtimes). The `/internal` entry point remains legacy implementation support and is not required for host integration or authoring a new runtime. ## One database per isolation boundary Persist a server-owned mapping from the host's isolation key to a validated Triplex `DatabaseId`. Do not derive authorization or physical schema names directly from request input. At request entry: 1. authenticate the actor and authorize the requested operation; 2. load the trusted `DatabaseId` mapping; 3. construct or obtain the cached database runtime; 4. provide its scoped `SqlClient` and `Triples` services to the request program; and 5. keep authorization checks in the host command and query layer. For PostgreSQL, `PgTriples.layerForDatabase` creates a collision-resistant schema-bound pool, provides its exact `SqlClient` alongside `Triples`, and embeds the logical database scope in pagination cursors. Never use the registry or system client as an application fact client. SQLite callers should allocate one file per isolation boundary: ```ts const DatabaseLive = SqliteTriples.layer({ filename: `/var/lib/example/triplex/${databaseId}.sqlite`, }); ``` Tests and ephemeral programs should name the in-memory scope explicitly: ```ts const DatabaseLive = KvTriples.layerWithScope(databaseId); ``` Cache runtimes by the trusted `DatabaseId`, not an unchecked request string. Never batch requests across database boundaries. ## Share a PostgreSQL transaction A host-owned Effect SQL client can back Triplex without a hidden second pool: ```ts import { Effect, Layer } from "effect"; import { SqlClient } from "effect/sql"; import { EntityId, Triples, string } from "@triplex-build/triplex"; import { PgTriples, makePostgresqlLayerUnmigratedFromUrl } from "@triplex-build/triplex-postgres"; const HostSql = makePostgresqlLayerUnmigratedFromUrl(process.env.DATABASE_URL!); const DatabaseLive = PgTriples.layerFromSqlClient({ scope: "host/main" }).pipe( Layer.provideMerge(HostSql), ); const command = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const triples = yield* Triples; yield* sql.withTransaction( Effect.gen(function* () { yield* sql`INSERT INTO host_records (id) VALUES ('record:1')`; yield* triples.transact( [ { op: "assert", entityId: EntityId.make("record:1"), attribute: ":record/status", value: string("accepted"), }, ], { actor: "user:1", commandId: "command:1" }, ); yield* sql`INSERT INTO host_outbox (id) VALUES ('delivery:1')`; }), ); }).pipe(Effect.provide(DatabaseLive)); ``` The host statements and nested `Triples.transact` resolve Effect SQL's same fiber-local transaction connection. A failure on either side rolls back the relational rows, Triplex facts, journal envelope, command receipt, commit position, and outbox row together. Nested transactions retain Effect SQL savepoint semantics. `layerFromSqlClient` creates no pool and runs no DDL. The explicitly named `layerFromSqlClientMigrated` variant is a provisioning convenience, not the default production boundary. ## Host-controlled migrations `@triplex-build/triplex-sql` exports the ordered `migrations` definitions and `runMigrations`. Run them from the host's deployment process against the same database or scoped schema before constructing an unmigrated runtime. Triplex records its state in `triplex_schema_migrations`, avoiding the host's migration table. SQL migration v1 is the baseline; additive v2 creates the shared numeric/datetime expression index. Cloudflare has a separate sequence: v2 adds snapshots and v3 adds the same numeric index. Existing baseline databases upgrade in place; applied versions are skipped on subsequent runs. The baseline definitions are unchanged. `INDEX_DDLS` and `INDEX_NAMES` describe all current indexes, including the new index, so SQLite bulk-load index rebuilding preserves it. Use the exported `runMigrations` Effect with the host's `SqlClient` at deployment/provisioning time, or execute the ordered `migrations` with the host's migration tooling. Convenience layers apply pending migrations on startup; unmigrated layers and `layerFromSqlClient` leave DDL to the host. KV needs no schema migration for this change. Index creation scans existing numeric facts and adds storage/write cost, including retained history. The default migration uses ordinary `CREATE INDEX`, which can block writes; serialize migration execution and schedule it appropriately for large databases. PostgreSQL hosts that need an online build can pre-create the exact index with `CREATE INDEX CONCURRENTLY` outside a transaction, verify that the index is valid, then run the migration to record its version. `IF NOT EXISTS` checks the name, not the definition or validity: reserve `idx_attr_numeric` for Triplex and resolve any conflicting or invalid index before migrating. Runfold can remove its `runfold_actor_due` DDL after deploying this Triplex version and applying the migration to every database. Triplex does not drop that host-owned index; dropping an existing redundant index is a separate host migration. Older binaries can read the additive schema, but old SQLite bulk-loading code that drops/rebuilds only its known indexes cannot suspend maintenance of the new index. Deploy the migration and updated runtime together when using bulk loading. See [numeric query plans and indexing scope](/datalog-performance#numeric-ranges-and-actor-due-work). Triplex does not upgrade databases created by unpublished development builds. A host adopting an existing EAV store should rehearse the copy against production-shaped data, preserve every assertion and retraction, rebuild journal positions deterministically, and compare live and historical reads before cutover. ## Compile application configuration The host can compile its own ergonomic DSL into independently addressed Triplex nodes: ```ts const RecordName = Attribute.text(":record/name"); const Record = EntityType.make("Record", { attributes: { name: Attribute.use(RecordName, { required: true }), }, }); ``` The attribute node owns its global key and value type. The entity-type node references it and owns usage-local constraints such as requiredness and cardinality. Forms, policies, actions, permissions, integrations, and views can reference those same keys in one `ConfigStore.commit`. Store the resulting `ConfigSnapshot` ID on operational transactions through `meta.configSnapshot`. `EntityValidation` records queryable observations for attribute cardinality, required facts, reference target types, and uniqueness. The same collected `GraphConstraint` definitions can be passed to `Triples.transact` for atomic enforcement. The host remains responsible for authorization and higher-order policy invariants. ## Batch entity reads Build an Effect `RequestResolver` above `Triples.entities(ids, basis?)`: 1. partition requests by an already authorized database and temporal basis; 2. deduplicate entity IDs within each partition; 3. call `entities` once per partition; and 4. associate every result, including missing entities, with its original request. KV performs indexed entity lookups. SQLite and PostgreSQL use bounded parameterized queries. The host should schema-decode the returned attribute sets into its own domain type. ## Consume the journal Translate a validated command into `TransactOp` values and call one `Triples.transact` with: - `actor` from the authenticated principal; - the idempotency key as `commandId`; - `correlationId` and `causationId` from the host operation context; - the pinned `configSnapshot`; - graph constraints from that exact snapshot in `enforce`; and - a `TripleLive` precondition for each optimistic fact being replaced. Use `transaction(txId)` to construct an audit response. Retraction changes include the old typed value and original assertion transaction; assertions include the replacement. Command IDs are atomically unique within one Triplex database. A duplicate produces `CommandAlreadyCommittedError`, and `transactionByCommand` retrieves the durable receipt. Durable consumers page `transactions({ after: checkpoint })`, perform idempotent work, and advance `ConsumerCheckpoint` only after their effects commit. The feed is authoritative; an in-process change signal is only a wake-up hint. For an entity audit view, page `transactionsForEntity(entityId, { limit })`. Preserve the returned `snapshotPosition` and `nextBeforePosition` when requesting the next page. Every record already contains actor, command, correlation, causation, config snapshot, time, position, and complete typed assertion and retraction changes. Do not create a second audit log. ## Remaining limitations - Authorization and general cross-entity Datalog invariants remain host-owned. - Inbox and outbox records, response caching, retries, and timer delivery remain host-owned. - Derivation provenance rejects recursive rules, disjunction, aggregation, and dynamic attributes rather than returning an incomplete explanation. - Entity snapshots and derivation materializations are projections. Inspect their source position and freshness instead of treating absence as current truth. - Dynamic-attribute derivations fall back to journal replay and should not serve hot-path materializers. - FoundationDB and Cloudflare are experimental and are not production targets for this guide. - Stable adoption should wait for a stable package release, a rehearsed data copy where applicable, and application-level shadow comparisons. --- # Troubleshooting and FAQ ## Why does npm return 404 for a Triplex package? The seven public packages use the `@triplex-build` scope. Check the full package name and clear a stale local npm cache if a newly published version returns 404. Cloudflare and FoundationDB remain private workspace packages. Do not switch new examples back to the superseded `@bjacobso` scope. Keep every Triplex package on one coordinated release and use the exact Effect peer version declared by that release. The current source tree targets Node.js 22+ and `effect@4.0.3`; Effect 3 is incompatible. Multiple Effect copies or a version mismatch can make service tags and runtime types disagree even when imports look correct. ## Why is `Triples` (or another service) missing? An Effect program describes requirements until a layer provides them. Supply a backend layer at the application boundary: ```ts await Effect.runPromise(program.pipe(Effect.provide(KvTriples.layer))); ``` `ConfigStore.layer` itself requires `Triples`, so compose it over the same database: ```ts const AppLayer = ConfigStore.layer.pipe(Layer.provideMerge(KvTriples.layer)); ``` Construct that layer once per intended database lifetime. Reconstructing an in-memory layer creates a new empty database; constructing per-request SQLite/PostgreSQL layers also defeats connection and runtime sharing. ## Why is a query empty? Check these in order: 1. **Identity and value type:** direct entity/reference APIs require branded `EntityId`/`TripleId` values; raw Datalog terms are decoded by the query schema. 2. **Temporal basis:** omitted `validAt` means now. A future-effective or expired fact will be absent even if it exists in history. 3. **Retraction:** `entity` and `match` show visible facts; `history(entityId)` also shows retracted assertions. 4. **Join variables:** a spelling difference such as `?site` versus `?siteId` prevents clauses from joining. 5. **Negation safety:** variables in `not` must already be bound outside it; invalid programs fail preflight instead of producing a partial answer. Use `triples.explain(query)` or CLI `query explain`, then run with debug output to inspect the compiled plan without changing the query. ## Why are only 100 query rows returned? `Triples.query` and `queryPage` are bounded and default to 100 rows. This is not evidence that only 100 matches exist. Follow `nextCursor` using the same canonical query, page size, temporal basis, and database scope. Cursors are opaque, versioned, and snapshot-stable; do not decode or edit them. Use `queryAll` only for trusted internal/batch work that deliberately needs an unbounded complete result. Pagination bounds result transfer, not necessarily all database work, especially for joins, aggregation, or counts. Entity timelines use a different newest-first contract: preserve the first page's `snapshotPosition` and pass its `nextBeforePosition` as `beforePosition` on the next request. ## Which historical clock should I set? `recordedAt` asks what the database knew; `validAt` asks what was true in the domain. Setting only `recordedAt` still uses the current valid time, and setting only `validAt` still uses the latest recorded knowledge. For “what did we believe then about what was true then?”, set both explicitly. Both are non-negative epoch milliseconds. Valid intervals are `[validFrom, validTo)`, so a fact is not visible exactly at its `validTo`. For exact transaction order, use journal positions; multiple commits can share one millisecond. Read [the dated example](/concepts#recorded-time-and-valid-time). ## What should happen on a duplicate command ID? Command IDs are atomically unique within one Triplex database. Reusing a committed ID raises `CommandAlreadyCommittedError` with the original transaction ID; Triplex does not apply the write again. Treat the original transaction as the durable idempotency receipt and retrieve it with `transactionByCommand(commandId)` or CLI `journal receipt`. Do not generate a replacement ID until you know you are issuing a genuinely new domain command. Command IDs must contain 1–1024 characters. ## Why did invalid data commit? Configuration metadata and enforcement are separate. `meta.configSnapshot` records which release governed a write, but constraints are enforced only when the command also passes `meta.enforce.constraints`. The built-in atomic rules cover requiredness, cardinality, uniqueness, and reference-target kinds. Always derive or collect rules from the exact immutable snapshot you pin. Existing violations can remain during unrelated writes or repairs; enforcement rejects a new or worsened violation. Direct adapter writes, commands without `enforce`, authorization, and general Datalog invariants are outside this guarantee. Use validation observations when auditing or migrating existing data. ## Why is a projection stale or empty? Entity validation and derivation materialization are projections over already committed source facts. A projection failure cannot roll back the source transaction. Their read APIs expose freshness explicitly: - `current` means the stored run matches the relevant current source position and basis; - `stale` returns last-known results, which must not be presented as current truth; and - `unmaterialized`/`unvalidated` means no run exists, so an empty result is not proof that no work or violation exists. Run the materializer/validator after relevant journal changes, persist consumer checkpoints only after downstream effects complete, and schedule the next derivation run at `nextTemporalBoundary`. In-process change signals are wake-up hints; journal catch-up is the durable source. ## Does moving a config ref roll back data? No. `setRef` only points a name such as `live` at another existing immutable configuration snapshot. Operational facts, command receipts, configuration pins on old transactions, migrations, and external side effects remain unchanged. See the [worked rollback example](/configuration-versioning#understand-rollback). ## Where should authentication, authorization, and delivery live? In the host application. Authenticate and authorize before selecting a trusted database mapping or providing `Triples`. The host also owns HTTP contracts, task lifecycle, inbox/outbox delivery, timers, retries, monitoring, retention, backup, recovery, and higher-order business invariants. Triplex supplies database primitives, not those operational guarantees. Continue with [Host integration](/host-integration). --- # Releasing Triplex Triplex uses Changesets for one coordinated package-family release. Publishing is performed by GitHub Actions; routine releases should not depend on a maintainer's local npm configuration. ## Release set The public release set contains: - `@triplex-build/triplex` - `@triplex-build/triplex-sql` - `@triplex-build/triplex-sqlite` - `@triplex-build/triplex-postgres` - `@triplex-build/triplex-testkit` - `@triplex-build/triplex-cli` - `@triplex-build/triplex-http` `@triplex-build/triplex-cloudflare` and `@triplex-build/triplex-foundationdb` remain private workspace packages until they pass the supported backend conformance contract. The dashboard and examples are also private. The first release published all seven packages in September 2026. Review pending Changesets and the coordinated release set before each subsequent publication. ## Required repository configuration Create a GitHub environment named `npm-publish` and require maintainer approval for it. Protect `main` with the Node 22, Node 24, package-consumer, and PostgreSQL integration jobs from CI. Repository Actions settings must allow GitHub Actions to create pull requests so the version job can maintain the release PR. The release workflow uses npm trusted publishing. Each public package needs this trusted publisher: | Field | Value | | ----------- | ------------- | | Owner | `bjacobso` | | Repository | `triplex` | | Workflow | `release.yml` | | Environment | `npm-publish` | Allow direct `npm publish` for this workflow. It runs on a GitHub-hosted runner with `id-token: write`; npm therefore issues a short-lived OIDC credential and records provenance. ## One-time npm bootstrap npm package settings do not exist until the package has first been created. The seven public packages were bootstrapped under `@triplex-build` with a short-lived token. Routine releases use GitHub Actions OIDC without an npm token. For a new package added to the family, repeat the minimal bootstrap sequence: 1. Authenticate as an npm owner of the `@triplex-build` organization and confirm the new name is available. 2. Create a granular token limited to the new package with publish access and the minimum useful lifetime. Temporarily add token authentication to the protected release job for its first publish. 3. Publish its first version under the `next` dist-tag and verify the package record exists. 4. Configure its trusted publisher using the values above. 5. Remove the temporary token authentication, delete its GitHub secret, and revoke the token. 6. Verify the next publish uses OIDC. Do not put an npm token in the repository, a shell command, or a checked-in `.npmrc`. The six pre-stable packages under `@bjacobso` are a superseded bootstrap line. After the `@triplex-build` stable release is available, deprecate every old version with a message directing consumers to its corresponding `@triplex-build` package. Do not unpublish the old artifacts. ## Documentation deployment The VitePress site is deployed to Cloudflare as an assets-only Worker with the Effect-native Alchemy stack in `alchemy.run.ts`. Preview the infrastructure diff before applying it: ```sh pnpm docs:plan pnpm docs:deploy ``` The production stage publishes , with also available. The `triplex.build` zone must be active in the deploying Cloudflare account; Alchemy attaches the Worker's custom domain, and Cloudflare manages its DNS record and HTTPS certificate. Only the `prod` stage attaches the custom domain. Local deployment uses the maintainer's authenticated Alchemy profile. The CI workflow deploys documentation after every push to `main` (including PR merges), once verification, package-consumer checks, documentation browser tests, and PostgreSQL integration tests pass. Pull requests do not deploy. Production deployments are serialized and are not canceled mid-deploy. Configure these repository Actions secrets before the first CI deployment: - `CLOUDFLARE_ACCOUNT_ID`: the same account used by the local production deployment. - `CLOUDFLARE_API_TOKEN`: a dedicated deployment token scoped to that account and the `triplex.build` zone. It needs account-level Workers Scripts Edit, Account Settings Read, and Secrets Store Edit, plus zone-level Zone Read and Workers Routes Edit. CI uses Alchemy's existing remote `Cloudflare.state()` store, so local and automated deployments share the `triplex` stack's `prod` state. Secrets Store Edit is required to bind the state-store secret when a fresh runner authenticates; Secrets Store Read alone is insufficient. See [Cloudflare's Secrets Store permissions](https://developers.cloudflare.com/secrets-store/access-control/). ## Verify a canary Install from the registry in a clean directory outside this monorepo: ```sh pnpm init pnpm add effect@4.0.3 \ @triplex-build/triplex@next \ @triplex-build/triplex-sqlite@next \ @triplex-build/triplex-cli@next pnpm exec triplex --help pnpm exec triplex --sqlite ./canary.sqlite describe ``` Also execute a small Effect program that asserts and queries a fact through SQLite. Confirm the installed core contains only documented `dist` exports, there is one Effect runtime, and npm shows provenance for every canary package. ## Stable release Every public API change must include a Changeset. On pushes to `main`, the release workflow either updates a version PR or publishes the already-versioned packages: 1. Review the generated versions, changelogs, lockfile changes, and package set in the release PR. 2. Require a green CI run, including PostgreSQL and the packed external-consumer test. 3. Merge the release PR. 4. Approve the `npm-publish` environment deployment. 5. Verify the `latest` dist-tags, provenance, package contents, Git tags, and GitHub releases. `pnpm release:version`, `pnpm release:publish`, and `pnpm release:canary` are the corresponding low-level commands. The stable commands are intentionally separated so the version changes are reviewed before publication. If a bad release is published, prefer a corrected patch and an npm deprecation notice. Do not unpublish a package version except where npm's security guidance requires it. --- Storage / Backend blueprints # Your database. Triplex's memory. Build on the storage primitives you already trust. Keep facts, time, versioned rules, and derived work behind one TypeScript API. Triplex separates its application model from storage and query execution. These blueprints show how a backend can provide that foundation: the data representation, the transaction boundary, the query path, and the checks an implementation needs to pass. [ Supported **SQLite** A working reference for durable, embedded storage. Follow the rows, indexes, and SQL transaction. Explore SQLite → ](/sqlite) [ Experimental **FoundationDB** Ordered keys and distributed transactions underneath Triplex's existing hexastore engine. Explore FoundationDB → ](/foundationdb) [ Proposed blueprint **Datomic** Assertion entities, immutable database values, and a design for bridging the transaction models. Explore Datomic → ](/datomic) [ Proposed blueprint **XTDB** A temporal SQL backend, with explicit fact identity and an atomic command submission design. Explore XTDB → ](/xtdb) **Supported** means the backend is in Triplex's baseline behavioral test boundary. **Experimental** means an implementation exists but has outstanding operational gates. **Proposed blueprint** means a design to investigate; no adapter or installable integration exists. The [current state](/current-state#backend-maturity) remains the maturity contract for implemented backends, including PostgreSQL and Cloudflare. ## One application model, two implementation paths | Your backend supplies | Composition path | You implement | Triplex supplies | | ---------------------------------------------------------------------- | ---------------- | ------------------------------------ | -------------------------------------------------------- | | Ordered byte keys, bounded range scans, atomic read/write transactions | `Runtime.fromKv` | `KvBackend` | Hexastore storage and Datalog execution | | Fact-oriented or relational storage with its own execution model | `Runtime.define` | `StorageAdapter` and `QueryExecutor` | The public `Triples` service and higher-level primitives | Both paths must preserve atomic facts and journal writes, ordered commit positions, unique command receipts, bitemporal reads, and snapshot-stable query pages. Sharing a query language or a temporal vocabulary alone does not establish those guarantees. Configuration releases, decision proofs, and derivations use Triplex's existing services on top of that contract. Your application still owns authorization, workflow effects, timer delivery, and the meaning of reconciled work. [Host integration](/host-integration) describes that boundary. ## The same correction should mean the same thing Use the [homepage's checked scenario](/#one-scenario) as the first design exercise. Maria's training was recorded as valid from March 1 until June 1. An auditor later corrects its start date to March 5. Every backend must retain the original assertion, record its retraction, and create a new assertion in one command. A March 3 query using the earlier recorded basis must still show training. The same query using current knowledge must show the gap. The placement's receipt must still point to the configuration release that governed it, and an already-open query cursor must retain its original results even after the correction. ## Build a backend 1. Choose an isolated physical database or keyspace and bind a `DatabaseScope` to it. Scope is cursor identity; physical partitioning and authorization are separate host responsibilities. 2. Implement the base storage and query contract with `Capabilities.none`. Establish rollback, duplicate-command behavior, typed values, history, and pinned pages before adding projections. 3. Run `runtimeConformance` from `@triplex-build/triplex-testkit` against a fresh isolated database. Add backend-specific concurrency, interruption, crash, and migration exercises. 4. Add durable snapshot or change-emission providers explicitly if your deployment needs them. Measure their failure behavior separately from source transaction correctness. The [custom runtime guide](/custom-runtimes) contains checked composition examples and the full adapter contract. Passing the shared corpus establishes behavioral compatibility within its coverage; production durability and operational readiness need additional evidence. Have a backend in mind? [Open a design issue](https://github.com/bjacobso/triplex/issues/new) with its transaction model, proposed fact representation, and a plan for preserving this correction. Start with a small conformance-backed implementation, then extend it. --- Backend blueprint / SQLite # Building Triplex on SQLite An embedded database underneath an embedded fact model. Durable history, attributed commands, and versioned rules in one local file. Supported durable baseline · Existing adapter SQLite is the working reference for Triplex's SQL storage path. Use it for local applications, single-process services, and a durable first implementation of a back-office domain. It is in the default shared conformance suite. Triplex remains pre-1.0; see [current state](/current-state). [Run it →](#run-it) [Follow the storage mapping →](#storage-mapping) [All blueprints →](/backends) ## Run it Install the SQLite adapter with the compatible Effect version on Node.js 22 or newer: ```sh npm install @triplex-build/triplex @triplex-build/triplex-sqlite effect@4.0.3 ``` This writes a training fact and reads it through the same `Triples` service used by other backends: ```ts import { Effect } from "effect"; import { EntityId, Triples, string } from "@triplex-build/triplex"; import { SqliteTriples } from "@triplex-build/triplex-sqlite"; const DatabaseLive = SqliteTriples.layer({ filename: "training.db" }); const maria = EntityId.make("worker:maria"); const program = Effect.gen(function* () { const triples = yield* Triples; const receipt = yield* triples.transact( [ { op: "assert", entityId: maria, attribute: ":evidence/site-safety", value: string("site:harbor"), validFrom: Date.parse("2026-03-01T00:00:00Z"), validTo: Date.parse("2026-06-01T00:00:00Z"), }, ], { actor: "user:dana", commandId: "training:maria-harbor:1" }, ); const facts = yield* triples.entity(maria, { validAt: Date.parse("2026-03-03T00:00:00Z"), }); return { position: receipt.position, facts }; }); await Effect.runPromise(program.pipe(Effect.provide(DatabaseLive))); ``` Re-running this command raises `CommandAlreadyCommittedError` with the original transaction ID. Handle that tag and call `transactionByCommand` with the same command ID to load the durable receipt; the retry writes no second fact or journal entry. Create the layer once for the database's intended lifetime. The convenience layer owns the SQLite client, configures WAL mode, and runs the ordered Triplex migrations. Production hosts that own DDL can use `makeSqliteLayerUnmigrated`, `makeSqliteAdapter({ autoMigrate: false })`, and the shared SQL migration entrypoints. See [host integration](/host-integration). ## Storage mapping | Triplex contract | Existing SQLite representation | | ----------------------------- | -------------------------------------------------------------------------------------------- | | Fact identity and typed value | One `triples` row per `TripleId`, with entity, attribute, value tag, and typed value columns | | Recorded and valid time | `recorded_at`, `retracted_at`, `valid_from`, and `valid_to` | | Exact commit visibility | `recorded_position` and `retracted_position` on each row | | Ordered command boundary | Transactional `triplex_commit_position` row | | Retry identity | Unique command claim in `triplex_command_receipts` | | Attributed journal | `_Transaction` facts and typed changes stored with the application writes | | Entity projections | Content-addressed blobs and entity snapshot tables, when composed with snapshot services | | Datalog | Shared SQL compiler/executor with the SQLite dialect | The physical schema stores each assertion separately. Domain attributes such as `:evidence/site-safety` remain data, so applications do not need a physical table per entity type. Indexes cover entity lookup, attribute/value lookup, references, temporal reads, and commit visibility. The additive v2 migration adds the shared numeric range index. ## One command, one SQL transaction The adapter executes Triplex's transaction callback on the ambient Effect SQL transaction. Command claiming, position allocation, constraint checks, fact changes, and journal changes belong to that same boundary. A rejected command must leave none of them committed. For Maria's correction, the adapter marks the old assertion's retraction time, transaction, and position, then inserts the corrected assertion and journal changes. The assertion row is retained. Historical reads filter both clocks; query pages additionally use the pinned commit position so later retractions cannot remove a fact from an older page. Keeping timestamps and positions separate matters when multiple commands share a recorded millisecond. The cursor contract relies on exact commit visibility. ## Read the implementation Follow these modules in order: 1. [SQLite layer and resource lifetime](https://github.com/bjacobso/triplex/blob/main/packages/sqlite/src/SqliteLayer.ts) 2. [SQLite storage adapter](https://github.com/bjacobso/triplex/blob/main/packages/sqlite/src/SqliteAdapter.ts) 3. [Shared SQL schema](https://github.com/bjacobso/triplex/blob/main/packages/sql/src/schema.ts) and [migrations](https://github.com/bjacobso/triplex/blob/main/packages/sql/src/migrations.ts) 4. [Triples composition](https://github.com/bjacobso/triplex/blob/main/packages/sqlite/src/SqliteTriples.ts) Run `pnpm --filter @triplex-build/triplex-sqlite test` from a repository checkout for the adapter's tests; `pnpm --filter triplex-integration-tests test` runs the shared backend corpus. Conformance is a behavioral boundary, not a promise about every workload or storage failure. Backup, restore, deployment migrations, and process lifecycle belong to your application. Continue with the [SQLite quickstart](/getting-started#use-durable-sqlite), or use this implementation as a reference while [building a custom runtime](/custom-runtimes). --- Backend blueprint / FoundationDB # Building Triplex on FoundationDB Let FoundationDB keep the ordered keys. Let Triplex turn them into facts, history, and explainable work. Experimental · Private workspace package An adapter already exists in this repository. It requires compatible native FoundationDB client libraries and is held from npm publication. Native integration tests are opt-in; it is outside the default CI matrix. Evaluate from a source checkout with locked dependencies. [Compose the runtime →](#compose-the-runtime) [Follow the storage mapping →](#storage-mapping) [All blueprints →](/backends) ## Why these primitives fit FoundationDB exposes an ordered key-value store with atomic transactions and range reads. Transaction retries handle optimistic conflicts; transaction size and lifetime limits shape how much work can fit in one command. See its [developer guide](https://apple.github.io/foundationdb/developer-guide.html). That matches Triplex's `KvBackend` path. The backend handles bytes and atomicity; Triplex's existing hexastore handles fact indexes, temporal visibility, Datalog, command receipts, and the journal. The design avoids implementing a second query engine. ## Storage mapping | Triplex needs | FoundationDB adapter responsibility | | -------------------------------------------- | -------------------------------------------------------------------------------- | | Ordered byte keys | Preserve lexicographic ordering within an isolated subspace | | Pattern index scans | Implement inclusive start, exclusive end, direction, and limit | | Atomic fact and journal writes | Run every operation in the callback on the same native transaction | | Ordered commit positions and unique receipts | Preserve the KV engine's counter and claim read/write conflicts | | Abort behavior | Discard all pending mutations when the callback fails | | Large reads | Page range reads without changing the requested scan semantics | | Invalidation hints | Optional FoundationDB watches; consumers still catch up from the durable journal | Hexastore keys and their encoding are implementation details. A backend implements `KvBackend`; it does not introduce a portable on-disk hexastore format. Persistent-data upgrades need their own rehearsal. ## Compose the runtime This checked example uses existing repository exports. Run it only in a checkout with the native client and a configured FoundationDB cluster. It is not an npm installation recipe. ```ts import { Effect } from "effect"; import { EntityId, Triples } from "@triplex-build/triplex"; import { Capabilities, DatabaseScope, Runtime } from "@triplex-build/triplex/runtime"; import { makeFdbKvBackend } from "@triplex-build/triplex-foundationdb"; const FoundationDbRuntime = Runtime.fromKv({ name: "foundationdb", backend: makeFdbKvBackend({ subspace: Buffer.from("triplex/training/") }), }); const DatabaseLive = FoundationDbRuntime.layer({ scope: DatabaseScope.make({ env: "dev", tenant: "example", database: "training", generation: 0, }), capabilities: Capabilities.none, }); const program = Effect.gen(function* () { const triples = yield* Triples; return yield* triples.entity(EntityId.make("worker:maria")); }); await Effect.runPromise(program.pipe(Effect.provide(DatabaseLive))); ``` The existing `FdbTriples.layer({ subspace })` is also a convenience composition. A nonempty subspace is required by default, and clears stay within it. Choose physically disjoint subspaces for separate databases; `DatabaseScope` binds cursors and does not partition cluster keys. `Capabilities.none` deliberately selects the base store. Durable KV entity snapshot persistence is not supplied automatically. Add a database-owned snapshot provider or a change emitter only after testing those capabilities separately. ## One correction across every index Maria's correction touches the old assertion's visibility, the new assertion, pattern indexes, the command claim, the commit position, and the journal. All those mutations must share one FoundationDB transaction, so a failed correction leaves the old belief intact. A retried callback must keep effects inside storage. Sending an email or advancing a host workflow inside it would repeat on conflict. Use the committed journal to drive host-owned effects. Splitting one logical command across native transactions would lose Triplex's atomicity. The adapter's bulk `setAll` helper can chunk large loads; those chunks are separate commits. It must not be used to make an oversized atomic Triplex command appear supported. Establish and measure command limits before production use. ## Verify and extend the adapter Start with the [KV adapter](https://github.com/bjacobso/triplex/blob/main/packages/foundationdb/src/FdbKvBackend.ts) and [package guide](https://github.com/bjacobso/triplex/blob/main/packages/foundationdb/README.md). Run `pnpm test:foundationdb:integration` with the native client and a disposable cluster. Use the [shared runtime conformance helper](/custom-runtimes#correctness-contract-and-conformance) against an isolated subspace as well. Before promoting this backend, establish multi-process contention behavior, interruption and lost-acknowledgement handling, crash recovery, persistent-key upgrades, and measured transaction limits. A command interrupted during submission must not leave an unexplained commit; receipts must resolve any uncertain outcome. These are additional gates beyond the shared corpus. See [current state](/current-state#backend-maturity) for maturity and [custom runtimes](/custom-runtimes) for the complete KV and capability contract. --- Backend blueprint / Datomic # Building Triplex on Datomic A fact-oriented foundation for a TypeScript application that needs to remember which rules governed every decision. Proposed blueprint · No adapter exists This page proposes an implementation to investigate. There is no Datomic package, running integration, or compatibility claim. The mappings below are design choices to validate against Triplex's public adapter contract. [Explore the mapping →](#storage-mapping) [Build the first slice →](#build-the-first-slice) [All blueprints →](/backends) ## Why Datomic Datomic offers datoms, Datalog, transaction entities, immutable database values, historical views, and ordered log access. Those primitives are useful ingredients for a Triplex adapter. [Datomic's best practices](https://docs.datomic.com/reference/best.html) describe their use for consistent reads and audit queries. On top of an adapter, Triplex would supply its TypeScript domain model, immutable configuration releases, pinned command provenance, and derivation reconciliation. Datomic would own persistence and native query execution. A TypeScript-to-JVM client boundary would be part of the deployment; choose and document that bridge before promising an embedded integration. ## Storage mapping The proposed starting point is **one Datomic entity per Triplex assertion**, with a fixed adapter schema. A direct mapping from a domain triple to one native datom cannot by itself preserve independent `TripleId`s and valid-time intervals for repeated assertions of the same value. | Triplex contract | Proposed representation | | ---------------------------- | ---------------------------------------------------------------------------------------------------- | | Stable entity and triple IDs | Original strings stored as adapter identity attributes; native entity IDs stay internal | | Fact value | Assertion entity with domain entity ID, attribute keyword, entity type, value tag, and typed payload | | Assertion history | Immutable assertion identity and payload; explicit assertion transaction and recorded position | | Retraction | Retraction metadata on the assertion entity, retaining the original payload and valid interval | | Valid time | Explicit `validFrom` / `validTo` fields filtered on every relevant read | | Command receipt | A scoped command identity claimed atomically without overwriting an earlier receipt | | Commit order | A per-logical-database position counter advanced in the same transaction as facts and journal | | Causal journal | Triplex transaction envelopes and typed changes stored atomically with the command | | Query execution | Translate validated Triplex Datalog to joins over assertion entities and shared temporal filters | Keep Triplex's recorded timestamp and commit position explicit. Datomic's native transaction coordinate can help identify an exact database value, but it is not a drop-in implementation of `nextCommitPosition()`, which Triplex calls before inserting the transaction's rows. ## Bridge the transaction models Triplex's `StorageAdapter.withTransaction` accepts an Effect callback whose reads and writes must share an atomic view, including pending changes. Datomic accepts declarative transaction data; its transaction functions inspect `db-before` and return additional data, without seeing one another's output. An arbitrary TypeScript callback cannot simply become a native transaction function. [Datomic transaction functions](https://docs.datomic.com/transactions/transaction-functions.html) define that boundary. One candidate design is an optimistic staging adapter: 1. Acquire one immutable database value, read the logical database's position and existing receipt, and run the callback against that value plus an in-memory overlay of pending mutations. 2. Stage constraint checks, a new receipt, assertion/retraction metadata, the next position, and the complete journal. Reads during the callback must see the overlay consistently. 3. Submit the staged changes with a compare-and-swap from the observed position to the next one. Every writer of that logical database's source data must participate in this guard. 4. On a conflict, discard the staged result, acquire a fresh database value, and re-evaluate. For a duplicate, `claimCommand` must return the original transaction ID so core raises `CommandAlreadyCommittedError`; callers can load its receipt with `transactionByCommand`. Publish a successful callback result only after confirmed commit. This is a proposal, not an implemented algorithm. Test retries, typed failures, defects, interruption, and uncertain submission outcomes before calling it conformant. Constraint queries must read the same protected basis; direct writes that bypass the guard would invalidate the design. A native transaction function could instead perform compatible checks, but translating Triplex's enforcement logic would be additional work. ## Preserve both clocks and the query contract For Maria's correction, stage a retraction of the March 1 assertion and a new March 5 assertion, with distinct triple IDs, in the same command. Retain the old payload. The earlier recorded basis must still expose the original training interval, while current knowledge exposes the gap on March 3. Datomic's historical view alone does not implement the explicit valid-time filtering in this proposed assertion schema. Start with patterns, joins, and temporal filters, then implement the complete validated Triplex query surface. Verify typed scalar comparisons, negation, aggregates, the constrained recursive form, deterministic ordering, pagination, and `explain`. Identical-looking Datalog clauses do not prove equivalent execution. Pinned pages need the original query, temporal basis, database scope, and exact commit boundary. Use retained assertion/retraction positions or a verified mapping to native database values; later corrections must not change which rows an earlier cursor sees. Keep the Triplex journal's typed changes and provenance even if native log access is used to locate commits. ## Build the first slice 1. Specify the JVM bridge, assertion schema, typed codecs, and physical database isolation. 2. Prototype the staged transaction boundary first: one correction, two competing writers, one duplicate command, and one rejected constraint. Establish that failures leave no partial state. 3. Implement `StorageAdapter` and `QueryExecutor`, then compose them with `Runtime.define` and `Capabilities.none`. See the [checked runtime skeleton](/custom-runtimes#runtime-definitions). 4. Run [runtime conformance](/custom-runtimes#correctness-contract-and-conformance) on a fresh database. Extend it with bridge disconnects, lost commit acknowledgements, process restarts, and schema upgrades. Configuration and derivation services can then use the same `Triples` layer. Durable entity snapshots and change emitters are separate capabilities to implement and verify. [Open a Datomic design issue](https://github.com/bjacobso/triplex/issues/new) with a transaction prototype and its test results, or compare the [working SQLite reference](/sqlite) and [XTDB proposal](/xtdb). --- Backend blueprint / XTDB # Building Triplex on XTDB A temporal database underneath a temporal application model. Keep the history, then connect it to the rules and work it explains. Proposed blueprint · No adapter exists This is a design to investigate for current XTDB, using SQL and its temporal primitives. It is not an XTDB 1.x Datalog integration. No Triplex XTDB adapter or installable package exists; the mappings below need a prototype and conformance evidence. [Explore the mapping →](#storage-mapping) [Build the first slice →](#build-the-first-slice) [All blueprints →](/backends) ## Why XTDB XTDB tracks valid and system time and exposes SQL and XTQL for querying historical data. That makes late evidence and corrections a natural area to explore. [XTDB's overview](https://docs.xtdb.com/intro/what-is-xtdb.html) describes its temporal and relational model. Triplex would add its TypeScript-facing fact identity, configuration releases, attributed command receipts, decision provenance, and derived work on top of an adapter. The shared temporal vocabulary is a starting point; the exact representation and transaction behavior still need to match Triplex. ## Storage mapping Start with a compatibility representation: one row per Triplex assertion, retaining explicit Triplex time fields and retraction metadata. Explore native temporal pushdown after that version passes the behavioral corpus. | Triplex contract | Proposed XTDB representation | | ---------------------------- | --------------------------------------------------------------------------------------------------- | | Fact identity | Assertion row keyed by `TripleId`, retaining the original domain `EntityId` | | Typed values | Fixed assertion columns for the value tag and typed payload | | Recorded and valid intervals | Explicit Triplex timestamp fields initially; native temporal optimization requires a proven mapping | | Retractions and history | Retained assertion payload with retraction transaction, timestamp, and position | | Ordered commit boundary | Per-logical-database position record advanced with an atomic expected-position check | | Retry identity | Scoped command-receipt record whose absence is checked in the submitted command | | Attributed journal | Triplex transaction envelope and typed changes written alongside source facts | | Datalog | A dedicated translator/executor from validated Triplex queries to XTDB SQL | The first version should keep adapter records visible independently of the domain fact's valid interval. Applying native default valid-time filtering to a journal or receipt could hide it when a user queries a different business date. Choose explicit temporal clauses for every adapter table and test historical reads; do not rely on the backend's implicit "now" basis. An existing PostgreSQL client connection does not establish compatibility with Triplex's PostgreSQL adapter. XTDB needs its own storage implementation, transaction handling, and verified query dialect. The [SQLite reference](/sqlite) illustrates the responsibilities to cover. ## Submit one atomic command XTDB write transactions are non-interactive: DML and assertions form a submitted atomic batch, and cannot be interleaved with result-returning `SELECT`s. Triplex's Effect callback expects reads and pending writes on one view. See [XTDB transaction consistency](https://docs.xtdb.com/about/txs-in-xtdb.html). One proposed bridge is to evaluate the callback against a stable read basis plus a staged overlay, then submit an atomic batch: 1. Read the logical database's position and command receipt on one consistent basis. 2. Evaluate enforcement and pending reads using that basis plus all staged assertion and retraction changes. Build the journal and callback result without publishing them. 3. Submit an `ASSERT` guarding the expected position and command claim, then the position update, receipt, facts, and journal in the same write batch. XTDB's [SQL transaction reference](https://docs.xtdb.com/reference/main/sql/txs.html) describes `ASSERT`. 4. Confirm successful processing before returning the receipt. On a conflicting position, reacquire a basis and re-evaluate. An existing command claim must return its original transaction ID to core, which raises `CommandAlreadyCommittedError`; callers then resolve the durable receipt with `transactionByCommand`. The guard must cover every source writer for that logical database. Prototype overlay reads, retry behavior, interruption, and rejection before treating this approach as an implementation of `withTransaction`. Native serial submission alone does not protect a constraint checked on an older client-side read. ## Preserve a stable read boundary XTDB distinguishes read snapshot tokens from await tokens used to ensure a node has processed earlier writes. Reusing a snapshot and clock fixes the read basis; waiting for a write alone does not pin future reads. [XTDB's basis documentation](https://docs.xtdb.com/about/txs-in-xtdb.html#repeatable-queries-basis) explains the distinction. Map Triplex's commit position to a reproducible source boundary, keep that boundary on every page, and ensure pooled connections observe confirmed commits. Define how a position is mapped to a native snapshot or reproduced from retained assertion/retraction positions. Timestamp-only pagination cannot establish the same contract. Maria's correction is the first useful exercise. Retain the original March 1 assertion, mark its retraction, and assert the March 5 replacement atomically. Earlier recorded knowledge must still show training on March 3, current knowledge must show the gap, and a cursor opened before the correction must keep its original results. The governing release stays on the placement receipt. Native system time and Triplex's recorded time must be mapped explicitly, including timestamp precision, same-millisecond commits, open interval ends, and imported or scheduled valid times. Any native temporal optimization must preserve the reference behavior of direct reads, Datalog, history, and derivation wakeups. ## Build the first slice 1. Specify the supported XTDB version, driver, typed codecs, table temporal policy, and physical database isolation. Record the mapping of Triplex positions to consistent read boundaries. 2. Prototype atomic command submission before the query compiler: correction, failed assertion, competing writers, duplicate commands, and read-after-write across two connections. 3. Implement `StorageAdapter` and a dedicated `QueryExecutor`, including validation, joins, negation, aggregation, constrained recursion, stable ordering, pagination, and `explain`. 4. Compose with `Runtime.define` and `Capabilities.none`, then run [runtime conformance](/custom-runtimes#correctness-contract-and-conformance) against fresh storage. Add delayed processing, lost acknowledgements, restart, and upgrade exercises. Once the base layer is coherent, Triplex's configuration and derivation services can reuse it. Snapshot and emission capabilities need their own providers and failure tests. [Open an XTDB design issue](https://github.com/bjacobso/triplex/issues/new) with the submission prototype and read-boundary evidence, or compare the [Datomic proposal](/datomic). --- # Custom runtimes `@triplex-build/triplex/runtime` is the public adapter-authoring surface in this source checkout. It provides `Runtime.define`, `Runtime.fromKv`, `DatabaseScope`, and declarative capabilities. These APIs are pre-1.0; their availability here does not imply a published stable release. For application-level database composition, see [Host integration](/host-integration). This page describes implementing a storage or execution backend. Workflows, authorization, routing, timers, and external effects remain host responsibilities. For concrete storage mappings and implementation plans, explore the [backend blueprints](/backends): [SQLite](/sqlite) is a working reference, [FoundationDB](/foundationdb) is experimental, and [Datomic](/datomic) and [XTDB](/xtdb) are proposed designs with no adapters yet. ## Runtime definitions A definition supplies Effect layers. Binding a database identity produces a layer providing the existing `Triples` service: ```ts import { Effect, Layer } from "effect"; import { EntityId, Triples } from "@triplex-build/triplex"; import { Runtime, DatabaseScope, Capabilities, StorageAdapter, QueryExecutor, } from "@triplex-build/triplex/runtime"; declare const storage: Layer.Layer; declare const queries: Layer.Layer; const CustomRuntime = Runtime.define({ name: "custom", storage, queries }); const DatabaseLive = CustomRuntime.layer({ scope: DatabaseScope.make({ env: "prod", tenant: "acme", database: "main", generation: 3, }), capabilities: Capabilities.none, }); const program = Effect.gen(function* () { const triples = yield* Triples; return yield* triples.entity(EntityId.make("customer:alice")); }).pipe(Effect.provide(DatabaseLive)); ``` The declared layers stand for your implementations. Already-built services can be supplied with `Layer.succeed`; the builder accepts layers only. Backend acquisition errors and external requirements are preserved. A layer needing an ambient `SqlClient` leaves that dependency on the database layer. `Runtime.define` also exposes initialization's `MigrationError`. Capability-provider errors propagate, and invalid capability dependencies produce `CapabilityError`. Unexpected exceptions in custom wrappers remain defects. ### Public contracts | Service | Responsibility | | -------------------- | -------------------------------------------------------------------------------------------- | | `StorageAdapter` | Atomic transactions, commit positions, command receipts, triple reads/writes, initialization | | `QueryExecutor` | Validated Datalog execution, pagination, explain | | `TripleStoreRuntime` | Database scope, clock, triple IDs, transaction IDs | The runtime subpath exports their service interfaces, `TripleRow`, `TripleInput`, `QueryPattern`, query result types, adapter errors, snapshot and emission contracts, and `StoreCapability`. `TriplesLive`, SQL compilation functions, and hexastore key encoders remain implementation details. `StorageAdapterService` has no `rawQuery` requirement. SQL adapters can implement the separate `SqlStorageAdapterService` refinement exported by `@triplex-build/triplex-sql`. For SQL execution, `makeSqlQueryExecutorLayer(runner, dialect)` captures the dialect at construction. Custom runtime authors do not need `CurrentDialect`. The older `SqlQueryExecutorLive` retains its ambient-dialect behavior for existing callers. ### KV backends Implement ordered key-value operations and let Triplex supply the hexastore query engine: ```ts import { Layer } from "effect"; import { Runtime, KvBackend, DatabaseScope, Capabilities } from "@triplex-build/triplex/runtime"; declare const backend: Layer.Layer; const CustomKvRuntime = Runtime.fromKv({ name: "custom-kv", backend }); const DatabaseLive = CustomKvRuntime.layer({ scope: DatabaseScope.test("custom-kv"), capabilities: Capabilities.none, }); ``` This supports the `KvBackend` contract and shared `Triples` behavior. It does not make the hexastore byte encoding a public API or promise that independently implemented engines can share its persisted keys. Rehearse upgrades against persistent KV data. `KvTriples.layerBackend` remains available for existing low-level compositions. ## Database identity `DatabaseScope.make` requires nonempty environment, tenant, and database strings, plus a nonnegative safe-integer generation. A versioned JSON tuple avoids collisions from concatenating strings with separators. Every field participates in the cursor identity. There is no implicit scope. The builder validates JavaScript callers as well as enforcing the brand in TypeScript. `DatabaseScope.test(name)` is repeatable, not globally unique: use it only for isolated fixtures. Older internal helpers retain legacy string defaults; the public builder does not use them as database identities. The host must map an authorized caller to the correct physical database and identity. A scope binds pagination cursors; it neither creates a tenant partition nor authorizes access. Never reuse an identity for different databases, and advance generation when replacing a database. ## Capabilities Omitting `capabilities` selects `Capabilities.default`: entity snapshots and change emission. **The default requires `SnapshotService`, `SnapshotWriter`, and `ChangeEmitter`.** Missing providers remain visible in the Effect layer's requirements. The builder does not silently substitute an in-memory snapshot database or discard change events. Configure persistence and emission explicitly when constructing them against the raw store: ```ts import { Effect } from "effect"; import { Capabilities, DatabaseScope, entitySnapshots, changeEmission, type ChangeEmitterService, } from "@triplex-build/triplex/runtime"; import { SqlSnapshotsLive } from "@triplex-build/triplex-sql"; import { makeCloudflareRuntime, type DOState } from "@triplex-build/triplex-cloudflare"; declare const state: DOState; declare const emitter: ChangeEmitterService; const DatabaseLive = makeCloudflareRuntime(state).layer({ scope: DatabaseScope.make({ env: "prod", tenant: "acme", database: "main", generation: 3 }), capabilities: Capabilities.of(entitySnapshots(SqlSnapshotsLive), changeEmission(emitter)), }); // The builder supplies its adapter and raw Triples to the snapshot layer. // This layer provides Triples, SnapshotService, and SnapshotWriter. const ready = Effect.void.pipe(Effect.provide(DatabaseLive)); ``` `entitySnapshots(layer)` receives the raw `Triples` and the definition's backend services during construction. `Runtime.define` makes its `StorageAdapter` and `QueryExecutor` available there; `Runtime.fromKv` makes its `KvBackend` available. Snapshot persistence must belong to that same database. Other requirements stay on the returned layer. `SqlSnapshotsLive` uses the SQL adapter's snapshot tables. KV authors must provide their own snapshot reader/writer layer, use only `changeEmission`, or explicitly select `Capabilities.none`. An automatic durable KV snapshot implementation is not included. Additional options: - `Capabilities.none`: expose the base store with no snapshot or emission wrappers. - `Capabilities.of(...)`: combine providers, retaining their output, error, and dependency types. - `Capabilities.custom(...wrappers)`: install `StoreCapability` implementations. Names must be unique, dependencies must exist, and higher priorities wrap lower priorities. - `NoopChangeEmitter`: explicitly discard invalidation hints when appropriate. Snapshots are post-commit projections. A snapshot failure returns a `WriteError` even though the source facts have committed; use command receipts to resolve retries. Change emission runs inside the snapshot wrapper, and emission failures are swallowed because the journal is the durable source of truth. An emitted event does not prove the snapshot is already current. Snapshot transaction wrappers use the committed journal's actual changes, including broad pattern retractions. Custom capability behavior needs separate tests. ## Deterministic fixtures ```ts import { Layer } from "effect"; import { Runtime, KvBackend, DatabaseScope, Capabilities } from "@triplex-build/triplex/runtime"; declare const freshBackend: Layer.Layer; const TestRuntime = Runtime.fromKv({ name: "test-kv", backend: freshBackend }); const TestDatabase = TestRuntime.layer({ scope: DatabaseScope.test("checkout"), capabilities: Capabilities.none, deterministic: { startTime: 0, seed: "checkout" }, }); ``` This reuses Triplex's deterministic clock and ID generator. `startTime` is a fixed clock value, not an automatically advancing clock. Counters restart on a fresh build. Use a fresh isolated backend too: restarting deterministic IDs against existing data is unsafe. Determinism covers runtime-owned metadata; migrations and custom providers may have their own clocks. ## Lifecycle and ownership 1. Supplied layers acquire resources through Effect's scoped acquisition APIs. 2. `Runtime.define` calls `adapter.initialize()` before installing capabilities. Initialization failures remain typed and release scoped resources. 3. Capability providers receive the same raw store; snapshot materialization cannot recursively invoke the decorated store through the builder. 4. Closing the runtime's Effect scope runs the supplied layers' finalizers, including when initialization or capability construction fails. The builder **does not call `adapter.close()`**. The adapter layer owns release and should register it through `Effect.acquireRelease` if it owns the connection. Calling both a layer finalizer and `close()` could close a shared host connection twice. Existing SQL adapters leave connection ownership to their SQL-client layer. Initialization is not necessarily migration: host-managed adapters can supply a check or a no-op after a separate deployment migration. `Runtime.fromKv` relies on its backend layer for initialization. Keep each database runtime alive for its intended lifetime; repeatedly constructing an ephemeral backend creates new databases. ## Correctness contract and conformance Adapter authors must preserve these guarantees: - `withTransaction` rolls back facts, retractions, journal metadata, command claims, and commit position allocation together on typed failures, defects, or interruption. Concurrent writers cannot observe partial commits or invalidate enforced preconditions. - `nextCommitPosition` allocates an ordered position inside that transaction. Committed positions strictly increase; allocation participates in rollback. `currentCommitPosition` identifies a consistent committed read boundary. - `claimCommand` reserves a command identity atomically. A competing duplicate sees the original transaction ID; an aborted claimant cannot leave a durable receipt. - Temporal reads honor the resolved recorded/valid basis, assertion and retraction boundaries, and commit positions used for pagination. Results cannot drift while traversing a pinned page. - Query executors validate inputs and preserve typed-value, join, ordering, and pagination semantics. Use the exported Datalog validation helpers before backend execution. - KV scans use lexicographic byte ordering, inclusive start and exclusive end bounds, and the requested direction and limit. Transaction callbacks operate on one atomic view. Run the shared suite in one expression using testkit: ```ts import { Effect } from "effect"; import { DatabaseScope, Capabilities } from "@triplex-build/triplex/runtime"; import { makeCloudflareRuntime, type DOState } from "@triplex-build/triplex-cloudflare"; import { runtimeConformance } from "@triplex-build/triplex-testkit"; declare const isolatedState: DOState; await Effect.runPromise( runtimeConformance(makeCloudflareRuntime(isolatedState), { scope: DatabaseScope.test("cloudflare-conformance"), capabilities: Capabilities.none, }), ); ``` The helper belongs to testkit rather than `Runtime.conformance`, so core has no dependency on its own tests. It runs `makeTriplesConformanceSuite` against the supplied definition and options. Use a fresh database: the suite writes fixtures and does not provision or clear storage. The corpus covers atomic writes, temporal reads, Datalog, pagination, the journal, receipts, checkpoints, derivations, and graph constraints. **Passing is not full compliance.** It does not establish production crash durability, cross-process isolation, resource ownership, migration safety, or complete snapshot/emitter correctness. This repository also tests runtime cleanup and types, Cloudflare snapshot persistence and pattern retractions, projection failure after commit, and change events. Test those boundaries for each new adapter. ## Existing backends and release sequencing `makeCloudflareRuntime` is the executable example: the Cloudflare package now uses public core and SQL exports. Its additive v2 migration creates snapshot tables for existing v1 databases; v3 adds the numeric range index. It does not backfill existing entities; use `SnapshotWriter.backfill()` if those projections are needed. `CloudflareTriples.layer` remains a convenience API with an explicit `Capabilities.none`; its old string scope is wrapped in the new versioned identity format. Previously issued Cloudflare cursors must be restarted after upgrading. SQLite and PostgreSQL convenience layers retain their existing behavior. The SQL database manager still supplies its existing snapshots and emitter. The builder makes capability selection explicit for new compositions; it does not enable capabilities on every legacy entry point. See [Roadmap](/roadmap) for remaining release gates. Public composition support does not promote experimental Cloudflare or FoundationDB deployments to production-ready backends. --- # Triplex roadmap This document outlines strategic enhancements to make `@triplex-build/triplex` a world-class, open-source triple store and Datalog engine. For the implemented surface, backend maturity, and release status, start with [`current-state.md`](current-state.md). Items below are future work unless explicitly marked delivered. The operational foundation and its ordering constraints are specified in [`operational-primitives.md`](operational-primitives.md). Atomic KV transactions, compare-and-retract conditions, causal transaction envelopes, and per-transaction change journals are implemented, including a backend-issued commit cursor and resumable transaction pages. The portable derivation candidates and their immutable, freshness-aware materialization runs are also implemented, including durable temporal wakeups for future-effective and expiring evidence. The wrapped Datalog API now has typed, scope-bound, commit-position-stable keyset cursors. Fixed attribute dependency sets now have indexed freshness positions and temporal schedules across KV, SQLite, and PostgreSQL. Graph constraints are content-addressed, produce durable queryable observations, and can be enforced atomically across their full valid-time intervals. Command receipts are atomically unique and durable consumer checkpoints are available through the `operational` subpath. Indexed entity transaction timelines and PostgreSQL composition over an ambient host-owned `SqlClient` or validated database-scoped pool are also delivered; relational host rows, Triplex facts/journal, and outbox writes can share one Effect SQL transaction. ## Immediate release gate - Delivered: a Changesets v3 GitHub workflow maintains the version PR, publishes stable releases, and can manually publish `next` snapshots without committing snapshot versions. - Delivered: package checks install all tarballs and exercise the CLI as an external consumer. - Delivered: the PostgreSQL multi-connection isolation and shared conformance suite run in CI. - Delivered: Cloudflare and FoundationDB are private experimental workspace packages and cannot be included in the first release accidentally. - Delivered: the GitHub repository cutover to `bjacobso/triplex`. - Delivered: the initial version PR and the first publication of all seven public packages under `@triplex-build`. - Remaining: verify trusted publishing for all seven public packages with a tokenless `next` release and test the registry packages in a clean external consumer. ## Immediate correctness gate: backend parity - [Custom runtime composition](/custom-runtimes) now has public adapter contracts, structured scopes, typed capability providers, and a public-only Cloudflare implementation. Before stable adoption, validate each adapter's migrations, durability, isolation, and capability behavior; the shared Triples conformance suite alone does not establish those guarantees. - The conformance corpus now runs identical typed projections, scalar joins, predicates, negation, booleans, ref joins, recursive transitive closure, grouped aggregation, bitemporal reads, and snapshot-stable pagination against KV, SQLite, and PostgreSQL. Grow it with every query-engine bug. - Datalog conjunctions are clause-order independent across engines: positive relations establish outer bindings and patterns inside negation establish local bindings before predicates run. - Runtime query shape, truly unbound variables, result/aggregate target collisions, empty disjunctions, undefined rules, unsupported recursive bodies, and wrapper projection references now fail through one backend-neutral typed preflight. Projection and joins cover all seven stored value types, and aggregate duplicate, distinct-count, and empty-input semantics are differential tested. Wrapped filters now validate operand arity and use typed projection columns so numeric, text-family, boolean, and null behavior agrees across backends. Direct ordering and keyset pagination now share a typed total order for mixed scalar families, including null placement and deterministic ties. Raw ordered predicates now accept only numeric-capable value bindings and numeric constants, rejecting backend-dependent text and identity coercions during typed preflight. Equality keeps entity, attribute, transaction, and rule identities string-typed rather than leaking SQL coercion. Group-key equality in `having` uses those same scalar families, while aggregate equality is numeric-only and optional projections are rejected before SQL compilation. Pattern constants now use the same scalar-family identity through positive patterns, negation, and disjunction, while typed refs remain exact. Pattern identity positions now reject non-string constants before backend execution. The `?` prefix is reserved for schema-valid variables, and constant projections retain typed primitive values through direct and wrapped execution. Continue expanding hostile schema-valid input. - SQL projection now carries hidden canonical scalar-family columns through execution, so strings such as `"007"`, numeric-looking entity IDs and refs, datetimes, booleans, and JSON are decoded without guessing. Distinctness, grouping, counts, and page boundaries use that same flattened public identity across KV, SQLite, and PostgreSQL. - Recursive SQL rule definitions, applications, optional projection attributes, and depth bounds are parameterized; validated rule names are quoted identifiers. The portable recursive shape is binary and identity-only. Same-named definitions union, repeated variables unify, and unsupported recursion fails during typed preflight. - Keep PostgreSQL labelled as a pre-1.0 production candidate while its now-required CI corpus gains operational history. Keep FoundationDB and Cloudflare private and experimental until each passes the shared conformance and differential suites. ## 1. Schema-Aware Constraints & Enforcement **Goal:** Move from a "bag of facts" to a reliable, structured database. - Delivered: independently content-addressed required, cardinality, uniqueness, and reference-target nodes generated by the ontology DSL. - Delivered: bitemporal read-only evaluation and immutable first-class findings through `EntityValidation`, with shared KV/SQLite/PostgreSQL conformance coverage. - Delivered: opt-in transaction enforcement that rejects new or worsened post-state violations at every valid-time boundary and serializes concurrent absence/uniqueness decisions across KV, SQLite, and PostgreSQL. - Delivered: indexed constraint candidate loading by source entity type and referenced target type, with batched subject expansion instead of a full live-fact scan. - Graph constraints remain separate from value-local `TypeExpr` definitions. - Next: explicitly modelled general Datalog invariants. Observation mode remains available for migrations and audit. ## 2. Reactive "Live" Queries (Watch API) **Goal:** Power real-time, reactive user interfaces. - Delivered foundation: dependency extraction, `SubscriptionManager.checkAffected`, indexed dependency positions, an ordered transaction feed, durable consumer checkpoints, and best-effort wakeup transport. - Provide a `watchQuery` function that emits new results when relevant facts change. - Enable incremental result updates to minimize re-computation. - Build reliable invalidation on the ordered transaction feed; `ChangeEmitter` remains a best-effort wake-up mechanism. ## 3. Application-level tenancy and authorization **Goal:** Native, safe isolation for SaaS applications. - Delivered foundation: explicit database scope, branded `DatabaseId`, scope-bound cursors, and PostgreSQL schema-per-database isolation. SQLite hosts use one file/layer per database and KV callers use an explicitly named scope. - Keep authenticated account-to-database selection and authorization above raw `Triples` until there is a complete threat model and a backend-portable query/write policy contract. - Do not add an ambient `tenantId` column that silently changes query semantics. ## Delivered foundation: Bitemporality (Valid Time) **Goal:** Distinguish between when a fact was recorded and when it became true. - Implemented across KV, SQLite, and PostgreSQL with a shared recorded/valid basis for direct reads and Datalog. - Differential tests cover historical corrections, future-effective facts, retractions, joins, and negation. - Structural derivations expose the earliest recorded future `validFrom` or `validTo` across their dependency attributes. Immutable materialization runs persist and content-bind that schedule, including zero-candidate runs suppressed by negated evidence. A host scheduler can therefore reopen expiry-driven projections without a daily full scan. Fixed attribute sets use backend dependency indexes; dynamic-attribute definitions retain an explicit journal fallback. ## 4. Integrated Full-Text Search (FTS) **Goal:** Seamlessly combine graph traversal with text search. - Expose a `(fts ?entity "search term")` predicate within Datalog. - Support relevance scoring and highlighting in query results. - Leverage underlying SQL engine FTS capabilities (SQLite FTS5, Postgres GIN/GiST). ## 5. Performance & Query Optimization **Goal:** Handle millions of triples with sub-millisecond latency. - **Cost-Based Optimizer:** Reorder Datalog clauses based on attribute cardinality. - **JSON Indexing:** Optimize `value_json` lookups for complex data types. - **Recursive Rule Performance:** Optimize CTE generation for deep graph traversals. - **Scoped concurrency (deferred):** Separate global journal ordering from entity and invariant conflict scopes only after the atomic multi-scope design in [`operational-primitives.md`](operational-primitives.md#deferred-scoped-optimistic-concurrency) has cross-backend conformance coverage. ## 6. Client-Side Sync & Offline-First **Goal:** Enable seamless data replication to edge devices and browsers. - Develop a sync protocol for replicating filtered triple subsets to a client. - Provide a lightweight browser-side Hexastore for local querying. - Implement conflict resolution strategies for offline writes. ## 7. Developer experience and tooling **Goal:** Make the database "inspectable" and easy to use. - **Database Studio:** A web-based UI for exploring entities, history, and snapshots. - **OpenTelemetry Integration:** Built-in tracing for query execution and performance bottlenecks. --- # Datalog pagination and performance `Triples.query` and `Triples.queryPage` return at most 100 bindings by default. Page sizes are validated in the shared service boundary and may not exceed 1,000. SQL fetches one extra row to determine whether `nextCursor` exists; only the requested page reaches the caller. Total counts are opt-in. `queryAll` is the explicit complete-result API for trusted batch processing. ## Measurements Measured on September 6, 2026 in the development Linux VM using Node 24.14.1, SQLite 3.51.2 (in memory), and local PostgreSQL 16.14. The synthetic fixture contains 10,000 entities and 30,000 facts: a name, numeric score, and group per entity. It has no retracted facts or journal traffic. Both databases were analyzed after seeding. Each measurement is the median wall time of five warm executions; seeding, warm-up, and EXPLAIN are excluded. These are local diagnostic measurements, not production latency guarantees or a backend ranking. | Query | Backend | Complete result | First 100 | Page 6 (100) | First 100 + count | | --------------------------------------------- | ---------- | --------------: | --------: | -----------: | ----------------: | | All facts (30,000 rows) | SQLite | 99.76 ms | 35.30 ms | 43.33 ms | 63.89 ms | | All facts (30,000 rows) | PostgreSQL | 68.32 ms | 56.67 ms | 50.78 ms | 76.59 ms | | Fixed attribute (10,000 rows) | SQLite | 22.49 ms | 12.73 ms | 15.68 ms | 23.17 ms | | Fixed attribute (10,000 rows) | PostgreSQL | 20.93 ms | 16.30 ms | 16.65 ms | 29.43 ms | | Name/score join with score >= 50 (5,000 rows) | SQLite | 23.02 ms | 16.00 ms | 15.75 ms | 28.85 ms | | Name/score join with score >= 50 (5,000 rows) | PostgreSQL | 43.00 ms | 18.33 ms | 18.15 ms | 28.68 ms | | Grouped distinct count (250 rows) | SQLite | 14.38 ms | 15.19 ms | — | 29.65 ms | | Grouped distinct count (250 rows) | PostgreSQL | 27.22 ms | 28.76 ms | — | 56.48 ms | The complete-result column uses `queryAll` at the latest recorded state. Paged reads additionally pin a recorded timestamp and commit position, so this compares the public operations rather than isolating SQL LIMIT alone. No offset is used to advance pages. Page 6 is reached by following five cursors; only the continuation request is timed. ## What the execution plans show Both SQL backends receive LIMIT 101 for a 100-row page. They do not send the full matching relation to JavaScript. However, generic pagination does **not** bound the amount of database work needed to produce that page: - SQLite uses temporary B-trees for projection distinctness and outer ordering. Fixed-attribute snapshot queries use `idx_attribute_history`; snapshot joins also use the entity temporal index. - PostgreSQL's all-facts first page scans 30,000 rows, deduplicates 30,000 projected rows, then applies a top-N sort and LIMIT. The fixed-attribute case uses `idx_attribute_history` to read 10,000 candidates. The join reads its matching attributes and uses a hash join. - Grouped queries still process all contributing facts before returning a page of groups. Recursive queries may likewise compute a large closure before applying the result limit. - `includeCount` executes a second query over the complete filtered relation. It excludes the cursor boundary so the count remains the total size of the snapshot, even on later pages. The wrapper compiler removes inner ordering when there is no inner limit or offset, avoiding an unnecessary sort. Inner limits and offsets retain their ordering and receive deterministic projected-row tie-breakers. Recursive wrappers use the recursive compiler. Debug output reports actual compiler metrics and includes count execution time in `executionTimeMs`, with `countExecutionTimeMs` available separately. `debug.generatedSql`, `debug.params`, and `debug.queryPlan` describe the actual page, including the snapshot and continuation boundary; `explain` describes the logical Datalog query without capturing a read snapshot. For large catalogs, select fixed attributes and restrictive patterns, project only needed bindings, and request counts only where they are useful. An index on a public sort value alone cannot eliminate arbitrary joins, aggregation, mixed-type canonicalization, or distinctness. Further index/compiler changes should be evaluated against representative history density, attribute fan-out, recursive graphs, and deep cursors. The KV executor still materializes intermediate bindings before sorting and slicing; bounded output is not bounded executor memory. ## Read-path audit The Datalog bounds apply to raw query execution in the CLI and dashboard as well as to the core service. The dashboard's All facts preset no longer embeds a total limit of 100, so its Next page control can traverse the entire snapshot. CLI continuation uses `query run --cursor` or `query page` with a wrapped request. Low-level `match`, `entity`, `entities`, and `history` are complete materialization APIs. The existing dashboard overview and reflected entity browser, CLI fact matching/entity listing, and journal implementation still contain complete fact reads. Their array slicing is not storage-level pagination. They need separate fact/entity/journal paging contracts before the application can claim that **every** read avoids loading a catalog. This change establishes the bounded Datalog contract; it does not make that broader claim. ## Reproduce The benchmark lives in `test/stress/scripts/datalog-pagination.ts` and is opt-in: ```sh pnpm exec turbo run build --filter=@triplex-build/triplex-sqlite --filter=@triplex-build/triplex-postgres pnpm --filter triplex-stress benchmark:datalog PG_BENCH_URL=postgresql://localhost/triplex_bench pnpm --filter triplex-stress benchmark:datalog ``` Use an empty, disposable PostgreSQL database. The script refuses a nonempty facts table and leaves its seeded facts behind for inspection. `DATALOG_BENCH_ENTITIES` changes the fixture size. JSON output includes every timing, generated statement, SQLite EXPLAIN QUERY PLAN, and PostgreSQL EXPLAIN ANALYZE with buffer statistics. Backend suites can also use an explicitly supplied `PG_TEST_URL` pointing to a disposable database when Docker is unavailable: ```sh PG_TEST_URL=postgresql://localhost/triplex_test pnpm test:postgres:integration ``` ## Numeric ranges and actor due-work Triplex provisions `idx_attr_numeric` on `(attribute, COALESCE(value_number, value_datetime))` for facts whose `value_type` is `number` or `datetime`. It matches the compiler's existing scalar expression: both storage types compare numerically, and equal numeric values still collapse in Datalog projections. The existing number-only index remains available for typed storage reads. This covers [Runfold PR #4](https://github.com/bjacobso/runfold/pull/4)'s `src/actor.ts` due-work query without an application index declaration or per-actor DDL: ```ts import { Effect } from "effect"; import { Triples } from "@triplex-build/triplex"; const dueRows = (attribute: string, marker: string, now: number) => Effect.gen(function* () { const triples = yield* Triples; return yield* triples.query( { find: ["?item", "?actor", "?due"], where: [ ["?item", attribute, "?due"], ["<=", "?due", now], ["?item", marker, "?actor"], ], orderBy: [{ variable: "?due", direction: "asc" }], limit: 128, }, { pageSize: 128 }, ); }); ``` `limit` is the logical result bound; `pageSize` controls the public page. Without the explicit `pageSize`, current Triplex returns up to 100 rows and a continuation even with `limit: 128`. Unlike Runfold's `runfold_actor_due` workaround, this index includes retracted numeric facts. `Triples.query` pins a recorded position and time, and can see facts retracted after that snapshot. A partial index restricted to `retracted_at IS NULL` cannot serve those reads. Valid-time and recorded-time predicates remain residual filters; the index does not change visibility. The regression fixture has 10,000 pending items: 160 due, 9,840 future, alternating numeric and datetime storage, plus 80 retracted numeric facts and 10,000 pending markers. After `ANALYZE`, SQLite and PostgreSQL select the expression index with **both attribute equality and a numeric range bound**, for live reads and the actual SQL emitted by `Triples.query`. PostgreSQL's bitmap index scan visits 240 numeric candidates (160 live plus 80 historical), excluding the 9,840 future values. Removing the new index restores an attribute/temporal scan: PostgreSQL's snapshot query reads 10,080 due-attribute candidates and filters out 9,920 of them. SQLite likewise switches from the expression range to `idx_attribute_history` for snapshot pages. The regression tests assert a numeric range in the index condition, not merely that an index exists. These are local plan observations, not a promise of a particular plan on every dataset. The PostgreSQL planner may still scan pending markers for its join. Both backends still sort and deduplicate; a 128-row limit does not guarantee only 128 facts are examined. Dense due queues and large numeric histories need their own measurements. KV retains equivalent results using its existing executor and does not gain a SQL-style range-plan optimization. To inspect the plans, run these tests with `TRIPLEX_EXPLAIN=1` (add `--disableConsoleIntercept` if the test reporter suppresses output): ```sh TRIPLEX_EXPLAIN=1 pnpm --filter @triplex-build/triplex-sqlite exec vitest run test/numeric-index.test.ts PG_TEST_URL=postgresql://localhost/disposable_test TRIPLEX_EXPLAIN=1 pnpm --filter @triplex-build/triplex-postgres exec vitest run test/integration/postgresql.test.ts -t 'expression range' --disableConsoleIntercept ``` ### Scope of consumer indexing This change takes the core-index route rather than adding a new portable declaration API: the concrete due-work requirement is shared by numeric Datalog queries across attributes. Consumers express the query through the same backend-portable `Triples` API and provision its index once through the [migration API](/host-integration#host-controlled-migrations). Triplex does **not** yet expose arbitrary consumer index declarations. Workloads needing narrower attribute-specific indexes, compound application projections, custom uniqueness, or text search still need a separate design. Host-owned SQL DDL remains a backend-specific escape hatch at provisioning/migration time; it is not a portable indexing contract. A future declaration API must define backend capabilities, unsupported requirements, naming, changes/removal, and migration ownership, including KV behavior. Do not create indexes per actor, request, or query.