# 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.1.1`. Triplex requires Effect 4 (`effect@4.1.1`; 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 and frozen. Coding agents modeling a back-office domain should read the modeling guide first and write through the schema-bound `Database` client (`@triplex-build/triplex/database`), which pins and enforces the live release on every command. 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. --- Triplex · embedded fact database · pre-1.0 # The database that remembers why. Facts change. Rules change. Your record of what happened shouldn’t. Triplex is a bitemporal fact database that keeps facts, time, and versioned rules together in your TypeScript process. 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) [See the whole stack ↓](#one-scenario) `npm install @triplex-build/triplex effect` Effect 4 required · MIT licensed · In-memory and SQLite supported · PostgreSQL is a production candidate · [Current state](/current-state) ## 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. “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. The training row was corrected in place. The placement rules have changed since. The audit log knows who acted, but not what they saw. The job queue can’t say whether a task should have existed. ## What if the answer were a query? 01 ## One store, one identity model, one clock. Each system holds part of the answer, and each one has lost something along the way. | 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. The auditor’s question becomes a query. t₁ / t₂[Two clocks.](#what-did-we-know) What was true, and what you knew when. #[Pinned rules.](#which-rules) The release behind every decision. =>[Derived work.](#work-is-a-query) What should happen next. 02 ## From schema to an answer you can explain. Start with the [Database client](/database): declare a schema, publish it, and handle each action as one command. Every TypeScript block below comes from the [same checked, executable file](https://github.com/bjacobso/triplex/blob/main/docs/snippets/home/client.ts). CI runs it to verify receipts, typed reads, historical state, and attribution. **The story.** Dana records Maria under release `workers-v1`. An auditor later corrects her name. Today’s read shows the correction; a view at Dana’s receipt still shows what Dana recorded, with the actor and rules that explain it. For durable SQLite, add `@effect/sql-sqlite-node` to the install line. Follow [Getting started](/getting-started) for a complete first program. Step 01 · Model ### Declare the domain once. Attributes own global keys and value types. Entity types declare aliases, required fields, cardinality, and reference targets. The same declarations drive command inputs and typed reads. [Typed configuration →](/configuration#ontology-dsl) client.tsmodel ```ts import { Effect, Layer } from "effect"; import { EntityId, KvTriples } from "@triplex-build/triplex"; import { Attribute, ConfigStore, EntityType } from "@triplex-build/triplex/config"; import * as Database from "@triplex-build/triplex/database"; import { Why } from "@triplex-build/triplex/why"; const Worker = EntityType.make("Worker", { name: Attribute.text(":worker/name").required(), }); const Model = { types: [Worker] } as const; const maria = EntityId.make("worker:maria"); const MAR_2 = Date.UTC(2026, 2, 2); ``` Step 02 · Rules ### Publish the rules as a release. The host publishes an immutable, content-addressed release before acquiring the client. `Database.layer` checks the model against `live`; every new command pins and enforces the release it admits. [Releases, refs, and rollback →](/configuration-versioning) client.tsrelease ```ts const publish = Effect.gen(function* () { const config = yield* ConfigStore.ConfigStore; return yield* config.commit({ label: "workers-v1", objects: yield* EntityType.nodesOf(...Model.types), ref: "live", }); }); const Client = Database.layer({ schema: Model }); ``` Step 03 · Command ### One command, one atomic transaction. `db.command` returns a persisted receipt with actor, command ID, release, and commit position. The builder’s `tx.create` encodes typed values and checks that the entity is new. Retrying the same command ID returns the original receipt. [Commands and receipts →](/database#build-one-command) client.tscommand ```ts const db = yield* Database.Database; const createWorker = () => db.command({ actor: "user:dana", commandId: "create:worker:maria" }, (tx) => tx.create(Worker, maria, { name: "Maria" }, { validFrom: MAR_2 }), ); const receipt = yield* createWorker(); const retry = yield* createWorker(); // same receipt, no second write ``` Step 04 · Read ### Read through your schema. `db.get` returns alias-keyed values or `null`. `db.list` returns a bounded page and a cursor. For joins, `db.query` checks attributes against the release and decodes each binding with an Effect Schema projection. [Typed reads and queries →](/database#typed-reads-and-bounded-queries) client.tsread ```ts const worker = yield* db.get(Worker, maria); // { entityId, values } | null const page = yield* db.list(Worker, { limit: 25 }); // worker?.values.name is typed from Worker; page.nextCursor continues the list. ``` Step 05 · Audit ### Keep the correction and the original belief. `tx.correct` replaces a fact over its original valid interval and keeps the recorded history. `db.asOf` at the receipt’s exact position returns a stable, read-only view: what was known then, for the business date in question. [Recorded time and valid time →](/concepts#recorded-time-and-valid-time) client.tsaudit ```ts // Correct the original belief without losing its history. const nameFact = receipt.changes.find( (change) => change.op === "assert" && change.attribute === Worker.attributes.name.key, ); if (nameFact === undefined) return yield* Effect.die("Missing name assertion"); yield* db.command({ actor: "user:auditor", commandId: "correct:worker:maria:name" }, (tx) => Effect.gen(function* () { const fact = yield* tx.fact(Worker, nameFact.tripleId); yield* tx.correct(Worker, fact, { name: "Maria Rivera" }); }), ); const basis = { recordedPosition: receipt.position, validAt: MAR_2 }; const then = yield* db.asOf(basis); const knownThen = yield* then.get(Worker, maria); // name: "Maria" const current = yield* db.get(Worker, maria); // name: "Maria Rivera" ``` Step 06 · Explain + run ### Ask why, at the same cut. The `Why` service explains the visible facts, their asserting transactions, actors, and pinned releases. Pass the historical view’s basis so the explanation and typed read agree. Compose the client and explanation services over one shared store. [Explain a fact with why →](/why) client.tswhy ```ts const why = yield* Why; const explanation = yield* why.why( { kind: "entity", entityId: maria }, { basis }, // explain the same recorded cut and business time ); // explanation.transactions includes Dana's command and its workers-v1 release. ``` One store for the application lifetimerun ```ts const Store = ConfigStore.layer.pipe(Layer.provideMerge(KvTriples.layer)); const Services = Layer.merge(Client, Why.layer); // Publish before acquiring the client. Both effects share one Store instance. export const main = publish.pipe( Effect.andThen(program.pipe(Effect.provide(Services))), Effect.provide(Store), ); // await Effect.runPromise(main) at your application boundary. // For SQLite, import SqliteTriples from "@triplex-build/triplex/sqlite" and use // SqliteTriples.layer({ filename: "./app.db" }) in place of KvTriples.layer. ``` 03 ## 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. 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 ## From a back-office domain to client commands. Most back-office systems are built from the same parts. This is where each part goes, and where it shows up in Maria’s story. [Read the full modeling guide →](/agents) | In your domain | In Triplex | In the example or guide | | --------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------- | | Things: workers, sites, cases, accounts | Declare an `EntityType`; create with `tx.create` | `Worker`, `worker:maria` | | Properties and relationships | Typed `Attribute` keys and `Attribute.ref` | `:worker/name` | | Schemas, policies, forms | Publish a release; acquire `Database.layer` | `workers-v1` | | User and system actions | One `db.command` per action, with actor and stable command ID | Dana’s create command and receipt | | Evidence that starts or expires | `validFrom` and `validTo` on builder writes | Maria’s facts start on March 2 | | Corrections and changes from a date | `tx.correct`, `tx.replace`, or `tx.end` inside a command | The auditor’s name correction | | Current state and lists | `db.get`, `db.list`, and Schema-projected `db.query` | Step 04, Read | | Audit questions | `db.asOf`, `db.receipt`, `db.history`, and `why.why` | Steps 05–06 | | Obligations and to-dos | A `Derivation` over `Triples`, with an explicit release | [Modeling guide](/agents#_4-model-obligations-as-derivations) | | Tickets, notifications, SLAs | Host code driven by reconciliation diffs and wake-up boundaries | [Host integration](/host-integration) | 05 ## A library, not another server. Triplex runs inside your process as Effect layers. Switching storage changes the layer you provide, as in [step 06](#runtime), 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. - **In-memory KV**`@triplex-build/triplex` · tests, browser, edgeSupported - **SQLite**`@triplex-build/triplex/sqlite` · durable baselineSupported - **PostgreSQL**`@triplex-build/triplex/postgres`Pre-1.0 production candidate **Cloudflare Durable Objects**`@triplex-build/triplex/cloudflare`*Experimental* **FoundationDB**`@triplex-build/triplex/foundationdb`*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 ## When to use Triplex, and when not to. ### A 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. - 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. - Workloads that only need a relational table and never ask about history or provenance. - Replacing every table. Triplex is a system of record for facts. Triplex embeds in TypeScript and connects facts to versioned rules and reconciled work. Here is how it relates. ### [Datomic](/datomic) Facts, Datalog, and historical database views. ### [Current XTDB](/xtdb) Bitemporal data through SQL and XTQL. ### [WorldVM](https://worldvm.com/) WorldVM keeps each world’s subject records in Triplex. Its runtime uses recorded state and bases to explain what it knows. ### 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. Part of the WorldVM family ## Small experiments. Bigger worlds. Triplex is one thread in a family of open experiments in software that can describe, remember, coordinate, and explain itself. WorldVM is where they meet: a runtime for living domain models, built on facts that Triplex remembers. **Triplex***A memory for your world.*You are here: an embedded fact database that keeps time, rules, and provenance together. - [**Forma ↗***Little language. Big ideas.*A typed Lisp for building domain-specific languages.](https://forma-lang.com) - [**Schematics ↗***Every app hides a second program.*Small Effect libraries that make application rules inspectable.](https://schematics.run) - [**Foldworks ↗***Give the machinery a face.*Outliners, data grids, forms, and diagrams for Foldkit.](https://foldworks.dev) - [**Open Ontology ↗***Every platform, checked as one program.*Configuration as code, type-checked across platforms.](https://open-ontology.com) [Meet the whole family at worldvm.com ↗](https://worldvm.com/) Keep reading [Quickstart →](/getting-started) [Modeling guide →](/agents) [Core concepts →](/concepts) [Datalog →](/datalog) [Typed configuration →](/configuration) [Derivations →](/derivations) [Host integration →](/host-integration) [Current state →](/current-state) --- # Database client `@triplex-build/triplex/database` is the application front door over `Triples` and `ConfigStore`. Each command has a required actor and command ID, pins the current release, enforces its constraints, and returns the persisted transaction receipt. Typed entity reads use your declarations; queries use an explicit Effect Schema projection. KV and SQLite are supported; PostgreSQL is a pre-1.0 production candidate. ## Compose once per database The host publishes the model and chooses the backend. Layer acquisition resolves `live` (or the supplied `ref`) and checks every declared node and reference-target closure by logical identity and CID. Extra release objects are allowed, and all release constraints are enforced. Missing or incompatible releases fail acquisition. The client never publishes configuration. ```ts import { Effect, Layer, Schema } from "effect"; import { EntityId, KvTriples } from "@triplex-build/triplex"; import { Attribute, ConfigStore, EntityType } from "@triplex-build/triplex/config"; import * as Database from "@triplex-build/triplex/database"; const Site = EntityType.make("Site", { name: Attribute.text(":site/name").required(), }); const Worker = EntityType.make("Worker", { name: Attribute.text(":worker/name").required(), trainedFor: Attribute.ref(":worker/trained-for", Site).many(), }); const Model = { types: [Worker] } as const; const Store = ConfigStore.layer.pipe(Layer.provideMerge(KvTriples.layer)); const World = Database.layer({ schema: Model }); const worker = EntityId.make("worker:maria"); // Publishing is host-owned and precedes Database acquisition. const publish = Effect.gen(function* () { const config = yield* ConfigStore.ConfigStore; yield* config.commit({ label: "workers-v1", objects: yield* EntityType.nodesOf(Worker), // includes the referenced Site ref: "live", }); }); const program = Effect.gen(function* () { const db = yield* Database.Database; const receipt = yield* db.command( { actor: "user:dana", commandId: "create:worker:maria" }, (tx) => tx.create(Worker, worker, { name: "Maria" }, { validFrom: 0 }), ); const entity = yield* db.get(Worker, worker); const page = yield* db.list(Worker, { limit: 100 }); const names = yield* db.query( { find: ["?name"], where: Worker.where("?worker", { name: "?name" }) }, { decode: Schema.Struct({ "?name": Schema.String }), pageSize: 100 }, ); const then = yield* db.asOf({ recordedPosition: receipt.position, validAt: 0 }); return { receipt, entity, page, names, knownThen: yield* then.get(Worker, worker) }; }); // Both effects share one Store instance. Real hosts publish during deployment. const application = publish.pipe(Effect.andThen(program.pipe(Effect.provide(World)))); const main = application.pipe(Effect.provide(Store)); void main; ``` Substitute `SqliteTriples.layer({ filename: "world.db" })` or `PgTriples.layerFromUrl(...)` for `KvTriples.layer`. Build once per world lifetime; provide a `ConfigStore` backed by the same `Triples`. Sensitive models require the existing [Sensitive layer and codec](/sensitive-values) beneath the client. ## Build one command `command(meta, build)` returns `Effect`; the callback returns `Effect`. Callback domain errors stay unchanged. `correlationId` and `causationId` are optional attribution metadata. Authorization belongs to the host. | Builder operation | Compilation | | ------------------------------------------------- | ---------------------------------------------------------------------------------- | | `put(Type, id, values, interval?)` | Append `Type.assert` operations. | | `create(Type, id, values, interval?)` | The same assertions plus an `EntityAbsent` precondition across all valid time. | | `get(Type, id, { validAt? }?)` | Decode persisted state; guard the complete unretracted history with `EntityState`. | | `fact(Type, tripleId)` | Fetch and validate a raw source fact for a temporal edit. | | `end(Type, fact, at)` | Retract and preserve the past through `Type.end`. | | `replace(Type, fact, { alias: value }, at)` | Preserve the past and original end through `Type.replace`. | | `correct(Type, fact, { alias: value })` | Replace over the original interval through `Type.correct`. | | `retract(Type, fact)` | Retract the whole interval with a `TripleLive` guard. | | `inbox.append(input)`, `outbox.append(input)` | Append an operational entry in the same transaction. | | `inbox.acknowledge(id)`, `outbox.acknowledge(id)` | Acknowledge an entry in the same transaction. | `put` appends assertions. For a cardinality-one change, use a temporal verb. Empty puts and undeclared aliases/types fail validation. Explicit intervals are shared across assertions; omitted `validFrom` remains omitted for the engine to assign at commit. Required fields and reference targets are checked in the atomic post-state, across valid-time boundaries. Builder methods are Effects and must run sequentially in the callback's fiber. The builder becomes invalid when the callback ends. Failed callbacks submit nothing; all successful builds submit exactly one `Triples.transact`. There is no read-your-writes overlay. `tx.get` and `tx.fact` read persisted facts, even after queued writes. `tx.get` observes each entity once through the indexed `Triples.liveFacts` read, loading only unretracted facts, including past and future intervals; different `validAt` reads filter that observation. Its default time is the command-start Effect Clock. A `tx.fact` read alone adds no guard; consuming it in a retracting verb adds `TripleLive`. There are no builder Datalog or list methods; use [commit validators](/commit-validators) for predicate or absence invariants. ## Preview before committing `preview(meta, build, { derivations? }?)` runs the same sequential builder and pins one release, without calling `transact`, claiming the command ID, or running commit validators. It returns a JSON-serializable `Preview`: - `release`: the pinned release ID. - `operations`: `{ ops, preconditions }` from the shared command compiler. Sensitive assertion values are sealed before this object is returned. - `changes`: assertion and retraction descriptions with entity, attribute, typed value and valid interval. Retractions include the existing `tripleId`. Assertions omit their not-yet-allocated IDs. Commit IDs and timestamps are omitted. - `violations`: new or worsened graph violations, including `constraintKey`, `subject` and `validAt`, computed by the transaction enforcement functions. Violations are returned successfully so an approver can inspect them. - `derivations`: one reconciliation per supplied definition, identified by `definition`, with `added`, `removed`, `changed` (`before`/`after`) and `unchanged` candidates. Evaluation uses the existing read-only derivation overlay at preview time, including hypothetical source provenance. - `commandAlreadyCommitted`: the existing receipt when the ID was already used. In that case the callback is skipped, `release` is the original receipt's release, and operations, changes, violations and derivations are empty. A receipt created through primitive `Triples` without a release returns `release: null`. ```ts import { Effect } from "effect"; import { EntityId } from "@triplex-build/triplex"; import { Attribute, EntityType } from "@triplex-build/triplex/config"; import * as Database from "@triplex-build/triplex/database"; const Person = EntityType.make("Person", { name: Attribute.text(":person/name").required(), }); const meta = { actor: "user:dana", commandId: "person:ada:create" }; const build = (tx: Database.Command) => tx.create(Person, EntityId.make("person:ada"), { name: "Ada" }, { validFrom: 0 }); const preview = Effect.gen(function* () { const db = yield* Database.Database; return yield* db.preview(meta, build); }); // After approval, provide the same configured Database layer and command callback. const commit = Effect.gen(function* () { const db = yield* Database.Database; return yield* db.command(meta, build); }); void preview; void commit; ``` A preview is advisory: commit rebuilds and checks the command against the then-current store and release. Use the same command ID and callback; keep callbacks deterministic and supply explicit intervals when comparing changes. Omitted `validFrom` projects at preview time and is assigned again at commit. Randomized sensitive ciphertext also differs between preview and commit; plaintext is never returned. Journal change ordering is backend-dependent, so compare semantic change sets. Preconditions, operational entry conflicts, and host commit validators are checked at commit. Inbox/outbox operations appear in `operations`; they produce no triple changes, matching the journal. Unsupported derivation overlays fail with `read.failed`. Constraint preview discovers typed subjects through the transaction journal and loads unretracted histories, including past and future intervals. This can read substantial history on a large database. Preview does not reserve state against concurrent writers. ## Releases, retries, and receipts Every new command and ordinary read resolves and checks the current ref. A command admitted under release R finishes under R if `live` moves during its callback. This is admission-time pinning. A host needing a stronger promotion boundary must drain in-flight commands. Before resolving the ref or running the callback, `command` looks up the command ID. An existing ID returns its original receipt, including its original actor and release. Reusing an ID with another payload never executes that payload. Concurrent claim losers also recover the winner's receipt. A missing or unreadable receipt fails with `read.failed`; retry the same ID after an uncertain response. Other conflicts are returned to the host. Reload and retry the complete command with the same ID. Callbacks must contain no irreversible external actions; record outgoing intents in the outbox. Delivery, timers, reconciliation, checkpoints, authorization and retention remain host responsibilities. ## Typed reads and bounded queries `get(Type, id)` returns `{ entityId, values }` or `null`. Missing state is successful absence; malformed facts fail validation. The shared `Type.decode` owns aliases, cardinality, requiredness and sealed markers. Sensitive values stay sealed. `list(Type, { limit?, cursor? })` returns `{ entities, snapshot, nextCursor? }`. It delegates to `entityPage`; default 100, maximum 1,000. It preserves ordering and page boundaries. `query(query, { decode, pageSize?, cursor?, debug? })` decodes each result binding with the supplied Schema, retaining count/debug/continuation fields. References remain strings unless the projection decodes them differently. A failed decode fails the whole page. `raw(query, options?)` forwards bounded Datalog without projection decoding. `rawPage(wrappedQuery, { debug? }?)` supports the existing wrapped-query shape, including its cursor. Both use `SchemaBound` against the captured release. Unknown attributes fail before backend execution. Use `Triples.queryAll` explicitly for trusted batch work; there is no unbounded client query. Return the client cursor unchanged to the same operation with the same query, type and basis. The envelope retains the release and local model fingerprint; continuations load that immutable release even when `live` changes. The inner backend cursor retains the exact read cut and database scope. Malformed or mismatched continuations fail with `pagination.invalid-cursor`. Cursors are not authorization tokens. Give each world a distinct backend cursor scope. ## Receipts, entity history, and operational entries `receipt(commandId)` calls `Triples.transactionByCommand` and returns the persisted `TransactionRecord` returned by `command`, or `null` when the ID is absent. `history(Type, id, { limit?, snapshotPosition?, beforePosition? }?)` calls `Triples.transactionsForEntity`. It returns `{ transactions, snapshotPosition, nextBeforePosition? }`, newest commit first, with a default limit of 100 and a maximum of 1,000. Pass both the returned `snapshotPosition` and `nextBeforePosition` (as `beforePosition`) to continue without including later commits. An entity with no journal entries returns an empty page. Each transaction retains its complete raw `changes`, including other entities and unknown attributes, and adds `typedChanges` for the requested entity and declared entity type. Each typed change has `{ alias, change, value }`: `alias` discriminates the declaration's TypeScript aliases, `change` retains the original journal change and source triple ID, and `value` is the validated declaration value. A many-valued alias has one scalar value per changed fact. Instants decode to epoch milliseconds. Sealed values decode to the same `$sealed` marker as entity reads; the original journal change retains its sealed envelope. Unknown attributes and legacy changes without entity type or value remain in raw `changes` only. Malformed known values fail validation. Transaction change order is backend-dependent; select by alias, operation and interval rather than array index. History is a commit timeline, so `validAt` does not filter it. `outbox.pending({ scope?, limit?, cursor? }?)` and `inbox.pending(...)` delegate to `Outbox.pending` and `Inbox.pending`. They return `{ entries, nextCursor? }` using the operational API's bounded pagination and append-position/ID ordering. These are live pending scans: acknowledgements can remove entries between pages. `outbox.get(id)` and `inbox.get(id)` delegate to the corresponding mailbox `get`, returning an entry (including acknowledgement metadata) or `null`. IDs of the wrong mailbox kind return `null`. `outbox.id(scope, key)` and `inbox.id(scope, key)` expose the same deterministic IDs as the operational helpers. Reading entries never acknowledges them; acknowledgements remain command-builder operations. ```ts import { Effect } from "effect"; import { EntityId } from "@triplex-build/triplex"; import { Attribute, EntityType } from "@triplex-build/triplex/config"; import * as Database from "@triplex-build/triplex/database"; const Person = EntityType.make("Person", { name: Attribute.text(":person/name"), age: Attribute.integer(":person/age"), }); const person = EntityId.make("person:ada"); const read = Effect.gen(function* () { const db = yield* Database.Database; const receipt = yield* db.receipt("person:ada:create"); const page = yield* db.history(Person, person, { limit: 10 }); for (const transaction of page.transactions) { for (const change of transaction.typedChanges) { if (change.alias === "age") { const age: number | EntityType.SealedMarker = change.value; void age; } } } if (page.nextBeforePosition !== undefined) { yield* db.history(Person, person, { limit: 10, snapshotPosition: page.snapshotPosition, beforePosition: page.nextBeforePosition, }); } const pending = yield* db.outbox.pending({ scope: person, limit: 10 }); if (pending.nextCursor !== undefined) { yield* db.outbox.pending({ scope: person, limit: 10, cursor: pending.nextCursor }); } const outgoing = yield* db.outbox.get(db.outbox.id(person, "welcome")); const incoming = yield* db.inbox.pending({ scope: person }); const received = yield* db.inbox.get(db.inbox.id(person, "signup")); if (receipt !== null) { const then = yield* db.asOf({ recordedPosition: receipt.position, validAt: 0 }); yield* then.receipt("person:ada:create"); yield* then.history(Person, person); } return { outgoing, incoming, received }; }); void read; ``` ## Stable read views `yield* db.asOf(basis)` returns a read-only view with `get`, `list`, `query`, `raw`, `rawPage`, `receipt`, `history`, `basis` and `releaseId`. It captures omitted `validAt` once with Effect Clock, and omitted recorded time once with `Triples.currentPosition`. Supply at most one of `recordedAt` and `recordedPosition`. Positions are safe integers from zero through the captured head; instants must be finite and nonnegative. `recordedAt` uses `Triples.resolveRecordedBasis` to find the greatest eligible position through one captured head, without paging the journal. Equal instants include every commit at that instant visible at capture; future timestamps resolve to the captured head, and timestamps before the first commit resolve to zero. Exact positions keep views stable across same-millisecond commits and later corrections. Views retain the schema selected at acquisition. They do not infer which release was live at a historical time. For an incompatible older model, provide a matching layer/ref. A view supplies its fixed basis on entity/query reads and cannot write. Receipt reads return `null` for commands committed after the captured recorded position. History fixes `snapshotPosition` to that recorded position; an explicitly different snapshot fails validation. Both journal reads ignore `validAt`. Inbox/outbox reads are available only on the live client: the operational API has no historical entry or acknowledgement reads, so views do not expose mailboxes. ## Errors and the primitive escape `DatabaseError` names the client error channel. Client input and decode-shape failures use `validation.failed`; storage/config corruption and receipt failures use `read.failed`. Unknown attributes, Datalog, constraints, conflicts, operational entries, cursors and sensitive planning keep their existing diagnostic codes. See [error diagnostics](/errors#database-client-mappings). Acquire `Triples` explicitly in host or migration code for lower-level operations. The client has no flags to disable pinning, enforcement or guarded entity reads. --- # Getting started Start with the [Database client](/database) from `@triplex-build/triplex/database`. This guide declares entity types, publishes a release, opens the client, and runs one command, a typed read, a Schema-projected query and a historical read. > **Pre-1.0 package** The `@triplex-build/triplex` package is available from npm and requires `effect@4.1.1`. Effect 3 is incompatible. Do not use the superseded `@bjacobso` package names for new work. ## Prerequisites - Git - Node.js 22.19.0 or newer - Corepack and pnpm 10.11.0 (the repository declares the exact package-manager version) For an application, install the single published package and its Effect peer dependency: ```sh npm install @triplex-build/triplex effect ``` ## Run the schema-bound client 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/client-getting-started.ts ``` The complete program is: ```ts import { Effect, Layer, Schema } from "effect"; import { EntityId, KvTriples } from "@triplex-build/triplex"; import { Attribute, ConfigStore, EntityType } from "@triplex-build/triplex/config"; import * as Database from "@triplex-build/triplex/database"; const Company = EntityType.make("Company", { name: Attribute.text(":company/name").required(), }); const Person = EntityType.make("Person", { name: Attribute.text(":person/name").required(), employer: Attribute.ref(":person/employer", Company).required(), }); const alice = EntityId.make("person:alice"); const acme = EntityId.make("company:acme"); const Store = ConfigStore.layer.pipe(Layer.provideMerge(KvTriples.layer)); const Client = Database.layer({ schema: { types: [Person, Company] } }); const publish = Effect.gen(function* () { const config = yield* ConfigStore.ConfigStore; yield* config.commit({ label: "people-v1", objects: yield* EntityType.nodesOf(Person, Company), ref: "live", }); }); const program = Effect.gen(function* () { const db = yield* Database.Database; const receipt = yield* db.command( { actor: "user:pat", commandId: "quickstart/create-alice/v1" }, (tx) => Effect.gen(function* () { yield* tx.create(Company, acme, { name: "Acme" }, { validFrom: 0 }); yield* tx.create(Person, alice, { name: "Alice", employer: acme }, { validFrom: 0 }); }), ); const person = yield* db.get(Person, alice); const relationships = yield* db.query( { find: ["?person", "?company"], where: [ ...Person.where("?id", { name: "?person", employer: "?employer" }), ...Company.where("?employer", { name: "?company" }), ], }, { decode: Schema.Struct({ "?person": Schema.String, "?company": Schema.String }) }, ); const then = yield* db.asOf({ recordedPosition: receipt.position, validAt: 0 }); return { person: person?.values, relationships: relationships.results, knownThen: (yield* then.get(Person, alice))?.values, }; }); // Publish before acquiring the client, and share the same Store for both effects. export const result = await Effect.runPromise( publish.pipe(Effect.andThen(program.pipe(Effect.provide(Client))), Effect.provide(Store)), ); console.log(JSON.stringify(result, null, 2)); ``` It prints: ```json { "person": { "name": "Alice", "employer": "company:acme" }, "relationships": [{ "?person": "Alice", "?company": "Acme" }], "knownThen": { "name": "Alice", "employer": "company:acme" } } ``` ### What the Effect code is doing 1. `EntityType` declares aliases, attribute types, required fields and reference targets. 2. `ConfigStore.commit` publishes the model as an immutable release and moves `live` to it. 3. `Database.layer` checks the published model before providing the `Database.Database` service. 4. `db.command` requires actor and command ID, pins and enforces the release, and commits the builder's Effects atomically. Retrying the same ID returns the persisted receipt. 5. `db.get` uses `Type.decode` to return alias-keyed typed values. `db.query` checks attributes against the release and decodes each binding with the supplied Effect Schema. 6. `db.asOf` captures a stable read-only view. The receipt's exact position selects what was known at that command; `validAt` selects business time. Use the [`Why` service](/why) at the same basis to explain actors, commands, and pinned releases. The [homepage example](https://github.com/bjacobso/triplex/blob/main/docs/snippets/home/client.ts) demonstrates a correction followed by a historical read and its explanation. `Effect.gen` sequences Effects with generator syntax. `Effect.provide` supplies services and `Effect.runPromise` runs the fully provided program. Publishing precedes client acquisition and both share one `Store`. Real hosts generally publish during deployment. Create and share layers at the application boundary. A new `KvTriples.layer` runtime owns a fresh in-memory database, whose contents disappear when that runtime/process ends. The query returns one bounded page (100 rows by default); follow `nextCursor` for more. Client command callbacks should contain database work, with external delivery handled by the host. ## Raw Triples quick start Use the primitive service when building host infrastructure or exploring facts without a model. This second program writes raw facts and runs a Datalog join: ```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)); ``` Run it with `pnpm exec tsx --tsconfig docs/snippets/tsconfig.json docs/snippets/getting-started.ts`. It prints a `relationships` array containing `{ "person": "Alice", "company": "Acme" }`. Raw `Triples` does not automatically pin/enforce a release, check attribute spelling, decode an entity, or recover retry receipts. Prefer the client for application commands and reads; see [Underneath the client](/agents#underneath-the-client) for the explicit primitive contracts. ## Use durable SQLite SQLite is the supported local persistent backend. Install it alongside core and Effect: ```sh npm install @triplex-build/triplex effect @effect/sql-sqlite-node ``` In the first program, import `SqliteTriples` from `@triplex-build/triplex/sqlite` and replace `KvTriples.layer` inside `Store` with `SqliteTriples.layer({ filename: "./triplex.db" })`. Keep `ConfigStore.layer` over that same store and provide the client after publishing. On a persistent database, issue a new command ID only for a new domain command; rerunning the example with its original ID returns the original receipt. The convenience layer opens the file and applies Triplex's current migration. Construct it once for the application lifetime. Hosts that separate schema migration from runtime startup should use the explicit composition in [Host integration](/host-integration). ## Use Triplex from a source checkout To try unreleased changes in an outside project, build and pack the single public package from `main`. Its exports point to generated files, so build before packing: ```sh cd triplex pnpm install --frozen-lockfile pnpm turbo run build --filter=@triplex-build/triplex pnpm --dir packages/triplex pack --pack-destination /tmp ``` Install the resulting tarball in your project alongside Effect (and the SQLite driver if needed): ```sh cd ../my-project npm install /tmp/triplex-build-triplex-*.tgz effect @effect/sql-sqlite-node ``` Rebuild and pack again after changing the checkout. One tarball contains all public subpaths; there are no separate backend packages to link or install. Don't use `docs/snippets/tsconfig.json` for an outside project. It maps package names to source so repository examples can type-check unbuilt changes, bypassing the public exports your application will use. ## Next steps - Read [Database client](/database) for commands, typed reads, projections and historical views. - 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. Start with `@triplex-build/triplex/database`: declare your model, publish it, then open the [schema-bound client](/database). The examples below come from the checked, executable [client site-safety scenario](https://github.com/bjacobso/triplex/blob/main/docs/snippets/client-site-safety.ts). CI type-checks and runs it alongside the lower-level site-safety scenario. This page is also available as [Markdown](/agents.md), and the full documentation is available as [`/llms-full.txt`](/llms-full.txt). Agents can also inspect and write a database through the experimental [MCP server](/mcp). Start with its `describe` tool to discover the live release and example transactions, then use the same CLI operations through MCP with an actor and command ID on every write. ## Decide whether Triplex fits Use Triplex when the domain has one or more of these properties: - People ask what was true, or what the system believed, at an earlier date. - Policies, schemas, or forms change, and earlier decisions must still be explained under the rules that applied then. - Work follows from the data: missing evidence, expiring certifications, cases waiting for review. - Several actors write, whether people, services, or agents, and every change must be attributed and safe to retry. Do not use it for high-volume telemetry, blobs, caches, queues, or tables that never need history or provenance. Those belong in your existing database, and Triplex can live [beside it](/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.1.1`. Effect 3 is not compatible | | Runtime | ESM-only, Node.js 22+, modern browsers and edge runtimes for the core | | Durable stores | SQLite is supported. PostgreSQL is a production candidate | | Host duties | Authentication, authorization, task lifecycle, timers, and retries are yours | ## Map the domain | In your domain | In Triplex | | --------------------------------------- | -------------------------------------------------------------------- | | Things: workers, sites, cases, accounts | Stable `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 `db.command` call per action | | Evidence that starts or expires | `validFrom` and `validTo` on the assertion | | Corrections | `tx.correct`, `tx.end` or `tx.replace` inside one command | | Obligations and to-dos | A `Derivation` whose candidates you reconcile | | Tickets, notifications, SLAs | Your code, driven by reconciliation diffs | | Audit questions | A temporal basis, command receipts, and the journal | ## 1. Declare entity types and attributes - Give every entity a stable, typed ID such as `worker: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. Declare a use with `.required()`, `.many()`, or `.unique()`, chaining in any order, such as `.required().unique()` or `.many().required()`. A bare attribute is optional and single-valued. - Model relationships as `Attribute.ref(key, TargetType)`. There are no join tables. Datalog joins on shared values. - Available value types: `text`, `number`, `integer`, `boolean`, `date`, `instant`, `enumOf`, and `ref`. `date` is a calendar string that ordered predicates (`<`, `>`) cannot compare; use `instant` (epoch milliseconds or a `Date`) for anything you compare or expire. See the [value-type table](/configuration#ontology-dsl). - The client's `tx.create` and `tx.put` set `entityType` and encode values through the declared attributes so type-level reads and constraints can find the entity. ```ts const Site = EntityType.make("Site", { name: Attribute.text(":site/name").required(), }); const Worker = EntityType.make("Worker", { name: Attribute.text(":worker/name").required(), trainedFor: Attribute.ref(":worker/trained-for", Site).many(), }); const Placement = EntityType.make("Placement", { worker: Attribute.ref(":placement/worker", Worker).required(), site: Attribute.ref(":placement/site", Site).required(), }); const Model = { types: [Site, Worker, Placement] } as const; ``` Read more in [Typed configuration](/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. - Publish before acquiring the client; acquisition checks that your declarations match the release. The client resolves and pins the current release when each new command is admitted. ```ts const publish = Effect.gen(function* () { const config = yield* ConfigStore.ConfigStore; return yield* config.commit({ label: "site-safety-v1", objects: yield* EntityType.nodesOf(...Model.types), ref: "live", }); }); ``` Read more in [Releases, refs, and rollback](/configuration-versioning). ## 3. Open the client and write commands The client requires `actor` and `commandId` on every command; derive the ID from the request and reuse it on retries. It pins and enforces the same release automatically. A retry returns the original receipt without running the callback or applying another write. ```ts const Client = Database.layer({ schema: Model }); const placeWorker = Effect.gen(function* () { const db = yield* Database.Database; return yield* db.command( { actor: "user:dana", commandId: `create:${placement}`, // keep this ID on retries correlationId: "request:7f3a", }, (tx) => tx.create(Placement, placement, { worker: maria, site: harbor }, { validFrom: MAR_2 }), ); }); ``` ```ts const receipt = yield* placeWorker; const retry = yield* placeWorker; // original receipt; callback does not run again ``` - Run builder Effects sequentially: one `db.command` callback becomes one atomic transaction. - Authenticate and authorize in the host before calling the client. `correlationId` and `causationId` can connect the command to its request, case, or triggering event. - Put business time in `validFrom` and `validTo`; intervals are half-open: `[validFrom, validTo)`. Omitted `validFrom` means commit time. Builder writes share an interval across the entity's attributes. Referenced targets must be valid from an earlier or equal time. Constraint failures report the failing `validAt` in `error.violations`. - Use `tx.get(Type, id)` for check-then-write decisions; it decodes through `Type.decode` and automatically guards the entity's observed history against concurrent writes. - For temporal edits, fetch a fact with `tx.fact(Type, tripleId)`, then use `tx.correct`, `tx.replace`, or `tx.end`. These include the needed concurrency preconditions. `correct` replaces the whole interval; `replace` changes from a date on; `end` preserves only the past. A retraction alone removes the whole valid interval from current recorded knowledge. - Keep irreversible external actions outside callbacks. Record outgoing intents with `tx.outbox.append`; the host delivers them after commit. Read [Database client](/database) for typed `get`, bounded queries with Schema projections, builder semantics and stable `asOf` views. See [Operational primitives](/operational-primitives) for the underlying transaction contract. ## 4. Model obligations as derivations Use [Live queries](/live-queries) for journal-driven snapshots and valid-time wakeups. - 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. `Type.where(entity, { alias: "?var" })` and `Type.not(...)` build those clauses from aliases. - 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 // Derivations use the underlying Triples service and an explicit release. const openWork = (configSnapshot: string) => Effect.gen(function* () { const training = yield* Derivation.make({ name: "task.site-safety-training", query: { find: ["?worker", "?site"], where: [ ...Placement.where("?placement", { worker: "?worker", site: "?site" }), Worker.not("?worker", { trainedFor: "?site" }), ], }, identity: ["?worker", "?site"], configSnapshot, }); const { candidates, nextTemporalBoundary } = yield* Derivation.evaluate( yield* Triples, training, { basis: { validAt: MAR_2 } }, ); return { open: candidates.map((task) => task.result), recheckAt: nextTemporalBoundary }; }); ``` The [compliance host example](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 Use [Why](/why) to explain a fact, attribute, entity or derivation candidate at an exact recorded cut. It returns the asserting transaction and its pinned release; optional bounded command causation follows the parent chain. - **What did we believe then about what was true then?** Set both `recordedAt` and `validAt`. Setting only one leaves the other at "now". - **What did we know when we made a decision?** Use `db.asOf({ recordedPosition: receipt.position, validAt })` for an exact read cut. The receipt is returned by `db.command`; raw journal lookup uses `transactionByCommand`. - **Who changed this entity, and why?** Page `transactionsForEntity(entityId)`, newest first. Each record carries its actor, command, correlation, causation, release, and typed changes. - **Who asserted the state that held at a past date?** Bind the assertion transaction in a fourth tuple term and join `["?tx", ":_tx/actor", "?actor"]` in the same query. Reserved `:_tx/*` metadata uses the recorded cut and ignores valid time, so the join works for backdated assertions. A recorded cut before the commit still hides its metadata. See [Datalog](/datalog#query-shape). - **What was retracted?** `history(entityId)` includes retracted assertions. `entity` and `match` show only facts visible at the basis you ask for. ```ts const audit = (receipt: TransactionRecord) => Effect.gen(function* () { const db = yield* Database.Database; const config = yield* ConfigStore.ConfigStore; const then = yield* db.asOf({ recordedPosition: receipt.position, validAt: MAR_2 }); const now = yield* db.asOf({ validAt: MAR_2 }); return { actor: receipt.actor, governedBy: !ContentId.isContentId(receipt.configSnapshot) ? undefined : (yield* config.snapshotById(receipt.configSnapshot))?.label, trainedAsKnownThen: (yield* then.get(Worker, maria))?.values.trainedFor.includes(harbor) ?? false, trainedAsKnownNow: (yield* now.get(Worker, maria))?.values.trainedFor.includes(harbor) ?? false, }; }); ``` ## 6. Choose a runtime Publish first, then provide `Database.layer({ schema: Model })` to the application. Keep publishing and the client over the same `ConfigStore` and `Triples` instance. The complete example creates the referenced site and worker before placing them. Construct one layer per database lifetime, at the application boundary. Building an in-memory layer again creates a new empty database, and building a SQL layer per request defeats connection sharing. ```ts const Store = ConfigStore.layer.pipe(Layer.provideMerge(KvTriples.layer)); const application = Effect.gen(function* () { const release = yield* publish; // deployment step, before client acquisition const program = Effect.gen(function* () { const db = yield* Database.Database; yield* db.command({ actor: "seed", commandId: "seed:site-safety" }, (tx) => Effect.gen(function* () { yield* tx.create(Site, harbor, { name: "Harbor" }, { validFrom: MAR_2 }); yield* tx.create(Worker, maria, { name: "Maria" }, { validFrom: MAR_2 }); }), ); const receipt = yield* placeWorker; const retry = yield* placeWorker; // original receipt; callback does not run again return { receipt, retry, audit: yield* audit(receipt), work: yield* openWork(release.snapshot.id), }; }); return yield* program.pipe(Effect.provide(Client)); }); export const main = application.pipe(Effect.provide(Store)); ``` On PostgreSQL, Triplex can join your application's own Effect SQL transaction, so your rows, Triplex facts, the journal, and an outbox row commit together. See [Host integration](/host-integration#choose-a-runtime). ## Underneath the client Use raw `Triples` explicitly for host infrastructure, migrations, derivations and journal consumers. It is the primitive API; it does not add the client's defaults. Derivations above still take a `Triples` service and an explicit release. A raw write supplies `actor` and a stable `commandId`, plus both `configSnapshot` and `enforce` from `GraphConstraint.pinned(release)`. The lower-level equivalent of the placement command is: ```ts // One command is one atomic transaction: who acted, which command, under which rules. export const placeWorker = ( release: InMemoryConfigStore.ConfigSnapshot, placement: EntityId, worker: EntityId, site: EntityId, ) => Effect.gen(function* () { const triples = yield* Triples; const facts = Placement.assert(placement, { worker, site }, { validFrom: MAR_2 }); return yield* triples.transact(facts, { actor: "user:dana", commandId: `create:${placement}`, // unique: a retry can never apply twice correlationId: "request:7f3a", ...(yield* GraphConstraint.pinned(release)), // the rules it answered to, enforced }); }); ``` Raw duplicate commands fail with `CommandAlreadyCommittedError`; recover the receipt with `transactionByCommand`. Raw read-decide-write commands must pass `EntityState` preconditions, and `Change.correct`, `Change.replace` and `Change.end` return operations and preconditions that must be submitted together. Install [SchemaBound](/datalog#query-shape) for release-checked raw queries. The [primitive site-safety scenario](https://github.com/bjacobso/triplex/tree/main/docs/snippets/home/site-safety) executes this primitive form and its receipt recovery as a separate checked example. ## Anti-patterns | Don't | Do instead | | ------------------------------------------------------------------- | ------------------------------------------------------------------------- | | Generate a new random `commandId` on every retry | Reuse the request ID; `db.command` returns the original receipt | | Pin `configSnapshot` and assume constraints were checked | Use `db.command`, which pins and enforces together | | Retract a fact in one command and assert its replacement in another | Use `tx.correct` inside one command | | Retract an open state to change it from a date on | Use `tx.replace`; use `tx.end` to end without replacement | | Store a date attribute when the date says when the fact holds | Use `validFrom` and `validTo` | | Backdate one attribute of an entity and let the others default | Give all of an entity's facts the same `validFrom` | | Re-run derivations on a fixed timer | Follow the journal and wake up at `nextTemporalBoundary` | | Treat an empty `stale` or `unmaterialized` result as "no work" | Check freshness and rematerialize | | Put task status, assignees, or SLAs in the derivation | Keep the task lifecycle in host code, driven by reconciliation | | Read provenance in a second query with a different recorded cut | Join the asserting transaction and `:_tx/actor` at one recorded cut | | Call `queryAll` on request paths | Use `query` and follow `nextCursor` (100 rows per page by default) | | Encode authorization as Datalog over the same store | Authorize in the host before providing the client | | Check a write rule before `transact`, then write | Enforce it in a commit validator, which reads the state that would commit | ## Inspect with the CLI The CLI is non-interactive and prints one JSON envelope per command: `{ "ok": true, "command": ..., "data": ... }` on success, and `{ "ok": false, "command": ..., "error": Diagnostic }` on stderr with a nonzero exit on failure. The diagnostic has `code`, `message`, `retryable`, and optional `path`, `fix`, and `details`. Branch on `code`; use `fix` to correct the input. Only retry a retryable conflict after reloading facts. Add `--debug` to include the original `cause` string; see [Error diagnostics](/errors). Writes require `meta.actor` and `meta.commandId`. ```sh npm install @triplex-build/triplex effect @effect/sql-sqlite-node \ @effect/platform-node @effect/platform-node-shared triplex --sqlite ./app.db describe # capabilities and live release schema triplex --sqlite ./app.db entity types triplex --sqlite ./app.db entity facts worker:maria triplex --sqlite ./app.db entity history worker:maria --limit 25 triplex --sqlite ./app.db query explain --input query.json triplex --sqlite ./app.db why fact 01ARZ3NDEKTSV4RRFFQ69G5FAV triplex --sqlite ./app.db why entity worker:maria --recorded-position 12 triplex --sqlite ./app.db transaction preview --input command.json triplex --sqlite ./app.db transaction apply --input command.json triplex --sqlite ./app.db journal receipt placement:create:maria-harbor triplex --sqlite ./app.db config release --ref live ``` Start with `describe`: `data.schema` includes the release identity, all entity types and attributes, usage constraints and sensitivity, graph constraints, configuration/derivation objects, write rules, and one `{ entityType, input }` example per type. Each input pins and enforces the same release. Replace its synthetic placeholders with application values, create referenced targets first, and submit the input to `transaction apply`. Use `describe --ref preview` to inspect another ref. If the ref has no release, `data.schema` is `null` and `data.hint` explains how to publish one. Use `why attribute ` for every visible value of one attribute. Stored subjects accept `--recorded-at` or `--recorded-position`, `--valid-at`, and `--causation-depth`. For candidates, `why candidate --input candidate.json` accepts the full definition, candidate ID, revision and explicit basis in the [shared input shape](/tools#explain-and-preview). Preview takes apply's input and command ID, reports operations, changes and constraint violations without writing, and returns an existing receipt when that ID has committed. Preserve both the release pin and enforcement from describe. Preview is advisory; apply checks current state again. The unscoped `triplex` package on npm is an unrelated project. See [CLI and dashboard](/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. The [Database client](/database) enforces the published release on every command; raw `Triples` writes opt in explicitly. ## 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 `db.command` using the [client temporal verbs](/database#build-one-command), or one attributed raw `Triples.transact` call. `history(entityId)` includes retracted assertions. Current `entity` and `match` reads show only facts visible at their requested bitemporal basis. ### End a fact at a time Use `Change.replace(fact, value, at)` to change a value from a date on while preserving its past. The pure builder returns `ops` and `preconditions` for one transaction. A retraction alone removes an assertion from the current recorded view for its **whole** valid interval, including its past. ```ts import { Effect, Option } from "effect"; import { Change, EntityId, KvTriples, Triples, string } from "@triplex-build/triplex"; const vendor = EntityId.make("vendor:acme"); const KNOWN = Date.UTC(2026, 0, 1); const ACTIVATED = Date.UTC(2026, 2, 15); const program = Effect.gen(function* () { const triples = yield* Triples; const created = yield* triples.transact( [ { op: "assert", entityId: vendor, entityType: "Vendor", attribute: ":vendor/status", value: string("prospect"), validFrom: KNOWN, }, ], { actor: "user:pat", commandId: "vendor:acme:create" }, ); const prospect = created.triples[0]!; // Prospect until March 15, active from then on: one command, one transaction. const change = Change.replace(prospect, string("active"), ACTIVATED); yield* triples.transact(change.ops, { actor: "user:pat", commandId: "vendor:acme:activate", preconditions: change.preconditions, }); const before = yield* triples.match( { entityId: vendor, attribute: ":vendor/status" }, { validAt: Date.UTC(2026, 1, 1) }, ); // Triple.validFrom is a number; Triple.validTo is an Option (None when open-ended). return before.map((fact) => ({ value: fact.value, validFrom: new Date(fact.validFrom).toISOString(), validTo: Option.match(fact.validTo, { onNone: () => "open", onSome: (end) => new Date(end).toISOString(), }), })); }); console.log(await Effect.runPromise(program.pipe(Effect.provide(KvTriples.layer)))); // [{ value: { type: "string", value: "prospect" }, validFrom: "2026-01-01T00:00:00.000Z", // validTo: "2026-03-15T00:00:00.000Z" }] ``` For this open-ended fact, the verb produces the following hand-written transaction: ```ts const activate = Effect.gen(function* () { yield* triples.transact( [ { op: "retract", id: prospect.id }, { op: "assert", entityId: vendor, entityType: "Vendor", attribute: ":vendor/status", value: string("prospect"), validFrom: KNOWN, validTo: ACTIVATED, }, { op: "assert", entityId: vendor, entityType: "Vendor", attribute: ":vendor/status", value: string("active"), validFrom: ACTIVATED, }, ], { actor: "user:pat", commandId: "vendor:acme:activate", preconditions: [{ _tag: "TripleLive", id: prospect.id }], }, ); }); ``` `Change.end(fact, at)` produces the retract and shortened old assertion, with no replacement. `Change.correct(fact, value)` corrects the value over exactly its original interval. `Change.combine(...changes)` concatenates operations and guards for a command affecting several facts; pass its `ops` and `preconditions` to one `transact`. Combine independent facts rather than multiple edits of the same fact. Every verb carries the fact's `entityType`; `replace` also keeps its original `validTo` when present. Fractional bounds throw `ValidationError` with diagnostic code `time.invalid-bound`. `end` and `replace` throw `Change.ChangeRangeError`, a `RangeError`, unless `at` is finite and strictly between `validFrom` and `validTo` (unbounded when absent). Ending at `validFrom` would leave an empty interval; use `correct` for a whole-interval correction. Entity types also expose `Vendor.end(fact, at)`, `Vendor.replace(fact, { status: "active" }, at)`, and `Vendor.correct(fact, { status: "active" })`. These check the fact's entity type and attribute and encode exactly one alias through its attribute definition. For cardinality-many attributes, the alias takes one value because the change targets one fact. The builders never open sensitive values. `end` and `replace` preserve the old sealed value for the past; `Sensitive.layer` verifies it against the explicitly retracted fact before reusing it. `replace` and `correct` take plaintext replacements, which the layer seals on write as usual. Operations are `{ op: "assert", entityId, attribute, value, entityType?, validFrom?, validTo? }`, `{ op: "retract", id }` for one assertion by its `TripleId`, and `{ op: "retract-pattern", pattern }` for every live assertion matching an entity, attribute, type, or value pattern. Retract by `id` when you mean one assertion; the ID comes from `TransactionResult.triples`, `match`, `entity`, or `history`. A stored `Triple` carries `validFrom` as integer epoch milliseconds and `validTo`, `retractedAt`, `entityType`, and `txId` as Effect `Option`s. Its `value` is a tagged union: `string`, `number`, `boolean`, `datetime`, `ref`, `json`, `blob`, or `sealed` for a [sensitive value](/sensitive-values). The same rule applies with constraint enforcement: retracting the open assertion alone leaves the entity's past without the attribute, which a `required` rule rejects. The [client temporal verbs](/database#build-one-command) add concurrency guards automatically. With raw `Triples`, add a `TripleLive` precondition (generated by the verbs) or an [`EntityState` precondition](/operational-primitives#entity-state-preconditions) so a concurrent change fails instead of being overwritten. ## 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?, recordedPosition?, validAt? }` basis for the complete read, including every clause of a Datalog query. Times are non-negative epoch milliseconds; stored valid-time bounds are integers and interval ends are exclusive. Supply either `recordedAt` or `recordedPosition`; giving both is rejected. A position is a non-negative safe integer, with zero selecting the empty cut. Position cuts distinguish commits even when their recorded timestamps are identical. If both recorded fields are omitted, Triplex uses the latest recorded state; if `validAt` is omitted, it uses the runtime's current time (except Datalog `validTime: "all"`). Setting only one axis does not freeze the other. Recorded time is the commit clock. The Triplex transaction boundary assigns it, and there is no option to supply or backdate it, so a historical import records everything at the import's commit time. Put the domain dates in valid time; if you also need "when did we learn this", model it as an ordinary attribute (for example `:review/received-at`) on the entity. Tests of recorded-time travel must capture instants from real commits, such as a receipt's `instant`, rather than inventing them. Use the journal's ordered commit positions when exact ordering matters; timestamps alone may not distinguish commits in the same millisecond. ### Read an assertion's interval Datalog can bind the stored bounds in the same query: ```ts { pattern: ["?exception", ":exception/status", "approved", "?tx"], valid: { from: "?start", to: "?end" } } ``` Bounds are complete stored intervals: integer epoch-millisecond numbers, with `null` for an open end. Binding bounds preserves point filtering. Use query-wide `validTime: "all"` to enumerate intervals at one recorded cut, without `validAt`. All-time joins do not imply interval overlap, and retracted assertions remain absent unless you select an earlier recorded cut. To ask who asserted a backdated fact, join its fourth-term transaction to `:_tx/actor` in the same query. Reserved transaction metadata obeys only the recorded cut and ignores valid-time filtering. Other attributes on that entity retain ordinary domain validity; direct fact reads keep their existing semantics. See the [worked queries](/datalog#intervals-and-all-time-queries). Store a date as an ordinary `instant` attribute only when it is domain data in its own right, not to duplicate the assertion's interval. ## 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. The [Database client](/database#releases-retries-and-receipts) pins the release automatically. Raw 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. The [Database client](/database) pins and enforces these rules automatically. Raw `Triples` writes turn them into atomic guards by passing `meta.enforce` 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 November 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, fixed and all-attribute dependency state, indexed recorded-basis resolution, 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. - [Commit validators](/commit-validators): host checks inside the transaction boundary over db-before and db-after views, with typed rejections, runtime-wide installation for every write path, and optional durable rejection records. Shared conformance covers KV, SQLite, PostgreSQL, and Cloudflare. - Content-addressed Datalog derivations with explicit candidate identity, source provenance, immutable materialization runs, freshness positions, temporal wakeups, pure reconciliation, and read-only hypothetical overlays. - A [durable derivation runner](/runner) with acknowledged candidate snapshots, prepared batches, Outbox delivery, bounded retries, poison pause/resume, checkpoints and offline temporal replay. The compliance host uses runner handlers and schema-bound commands for open → satisfied → reopened. - 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. - Opt-in sensitive attribute values. `Attribute.sensitive` marks an attribute in its content-addressed node. `Sensitive.layer` decorates any `Triples` so a pluggable `ValueCodec` seals values before storage: a reference AES-256-GCM codec over WebCrypto with a keyed blind index, or a non-confidential identity codec for development. Reads, the journal, snapshots, the CLI, the dashboard and HTTP return sealed values, and `Sensitive.open` is the only path to plaintext. Datalog, uniqueness and derivations support presence and blind equality; every other use of a sealed value fails during planning with `SensitiveQueryError`. SQL migration v3 and Cloudflare migration v4 add the ciphertext and blind-index columns. KV, SQLite, PostgreSQL and Cloudflare pass shared sealed-value conformance and raw-storage plaintext scans. See [Sensitive values](/sensitive-values). - 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 portable host subpath 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 and API policy New surface targets in-memory KV, SQLite and PostgreSQL. KV and SQLite are supported; PostgreSQL remains a pre-1.0 production candidate. Cloudflare and FoundationDB are frozen, experimental backends: they must keep compiling and their existing tests must keep passing, but new features do not have to cover them. Triplex is greenfield before 1.0. Breaking public API changes are allowed when they make the API right. Update callers and record each break in a Changeset rather than retaining compatibility shims, deprecations or opt-in flags for old shapes. ## 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, frozen | | FoundationDB | Yes | Native + shared | Yes | Experimental, frozen | 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 for local opt-in runs. CI now provisions a pinned client and runs the shared Triples/sensitive corpora, native watches, cursor isolation and backfill tests. It remains experimental; these tests do not establish production operations or scale. ## Honest limitations - The single `@triplex-build/triplex` package replaces the original seven public packages. The GitHub repository is `bjacobso/triplex`; see [releasing](/releasing) for old-name deprecations. - 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. - Sensitive values: crypto-shredding is limited to key destruction; scrubbing an erased subject's blind tokens, rewrapping ciphertext under new keys and rotating the blind key are designed but not built. `KeyProvider.test` keys are for tests only; production hosts supply their own `KeyProvider`. Range, ordering, aggregation and comparison over sealed values are intentionally unsupported. - 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, external inbox/outbox transport, and application workflow lifecycle are host responsibilities. Hosts can enforce authorization and invariants atomically with commit validators, but Triplex ships no policy grammar. Validators run while the commit is serialized, and on Cloudflare they must be synchronous. - 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 the single package and verify a tokenless `next` publish. 2. Verify the single package 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 ship as experimental, frozen subpaths in the single package. Their implementation workspaces stay private and are excluded from Changesets. The six pre-stable `@bjacobso` canary packages must be deprecated after the corresponding single `@triplex-build/triplex` release is available. The old artifacts remain available so existing canary installs receive a migration message instead of breaking. --- # Datalog For application queries, start with the [Database client](/database#typed-reads-and-bounded-queries): `db.query` checks attributes against the release and decodes results with your Schema projection. `db.asOf` supplies a stable basis across reads. The primitive APIs below expose the same query language. 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 The [Database client](/database) applies `SchemaBound` automatically to its queries. For release-bound checking on raw `Triples`, install `SchemaBound.layer` from the config subpath. It checks constant attributes in patterns, negation, every disjunction alternative, rules, and wrapped queries before `query`, `queryAll`, `queryPage`, `explain`, `explainPage`, and constant attribute `match` reads. Unknown keys fail with `UnknownAttributeError` including `key`, `path`, and a nearby spelling in `suggestion` when available; `match` exposes it through `ReadError.cause`. Variable attributes are skipped. Reserved `:triplex/*` and `:_tx/*` keys remain legal. ```ts import { Layer } from "effect"; import { KvTriples } from "@triplex-build/triplex"; import { ConfigStore, SchemaBound } from "@triplex-build/triplex/config"; const storage = KvTriples.layer; const config = ConfigStore.layer.pipe(Layer.provideMerge(storage)); const bound = SchemaBound.layer({ ref: "live" }).pipe(Layer.provideMerge(config)); ``` Commit a release with `ConfigStore.commit({ label, objects, ref: "live" })` before using bound reads. The default ref is `"live"`; it is resolved per call, so moving it changes which attributes are legal. A missing ref fails with `DatalogError` (`ReadError.cause` for `match`). Keep `ConfigStore` built over the underlying storage as above. Without this layer, unknown attributes retain the ordinary empty-result behavior. ```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; }); ``` Transaction metadata (`:_tx/*`) obeys the query's recorded cut and ignores valid-time filtering. The join above therefore works for a backdated assertion at a past `validAt`, even when its transaction committed later. This applies to negation, disjunction, rules, and variable-attribute scans too. Other attributes on a transaction entity retain ordinary domain validity. Interval bindings on metadata return its stored commit-based bounds. The fourth term identifies the **asserting** transaction. After a correction, it names the replacement assertion's transaction; use history or the journal for the retracting actor. `Triples.transaction(txId)` and `Triples.transactionsForEntity(entityId)` also read the envelope. A recorded cut before the commit hides both the assertion and its metadata, so the join never reveals knowledge from later commits. 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"))]], }; ``` A `{ type: "blind", value }` typed constant matches a sealed value by its blind-index token. Applications normally write plaintext constants and let the [sensitive values](/sensitive-values) layer rewrite them. That layer also rejects projecting, ordering, comparing or aggregating sensitive attributes during planning. 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 sees the same recorded cut. Domain assertions also use the same valid point; reserved `:_tx/*` metadata follows recorded time only. Use `basis: { recordedPosition: receipt.position, validAt: businessInstant }` to read an exact commit cut, including across joins, rules, and negation. This works with `query`, `queryAll`, and `queryPage`, as well as `entity`, `entities`, `match`, and `entityPage`. Do not supply `recordedAt` with `recordedPosition`. Pages retain the requested cut in their cursor, and reject reuse with a different position. A requested position beyond the current head is capped at that head for pagination so later pages cannot gain new commits. Omit the basis on continuation to reuse the captured cut. Explicit position pages do not add a wall-clock filter; their entity-page snapshot therefore omits `recordedAt`. Existing timestamp-based and default pages keep their timestamp and position snapshots. ## Intervals and all-time queries Use `{ pattern: [e, a, v] | [e, a, v, tx], valid: { from?, to? } }` wherever a fact pattern is allowed: positive conjunction, conjunctive `not`, or a single `or` alternative. At least one bound is required. A bound accepts a variable or a non-negative integer epoch-millisecond number; `to` also accepts literal `null` for an open end. Unknown fields, invalid variables, and concrete empty/reversed intervals fail preflight. Recursive rule bodies currently accept only ordinary tuples. Bounds project as JavaScript numbers, with open ends as JSON `null`. They are the complete stored bounds, never clipped to the query point. Binding them retains ordinary point membership: start included, end excluded. Set `validTime: "all"` to skip the valid point for every domain pattern, including ordinary tuples, negation and rule scans. For wrappers, set it on `inner`. Do not supply `basis.validAt` or `optionalProjection` in all-time mode. All-time queries return assertions that coexist at one recorded cut. They exclude assertions retracted by that cut and do not imply that joined valid intervals overlap. Use an older recorded cut for an earlier belief, or history/the journal for recorded changes. Project both bounds and, when needed, the assertion transaction to distinguish periods; ordinary distinct-row semantics still apply. Separate, adjacent or overlapping assertions are not merged. These three worked queries share this runnable fixture. All dates are midnight UTC in 2026; the commit receipts supply actual positions and transaction IDs. ```ts import { Effect } from "effect"; import { EntityId, KvTriples, Triples, string } from "@triplex-build/triplex"; const J = Date.UTC(2026, 0, 1), F = Date.UTC(2026, 1, 1), M = Date.UTC(2026, 2, 1); const E = Date.UTC(2026, 2, 20), C = Date.UTC(2026, 2, 31); const A = Date.UTC(2026, 3, 1), Y = Date.UTC(2026, 4, 1); const workedQueries = Effect.gen(function* () { const triples = yield* Triples; const fact = (entity: string, attribute: string, value: string, from: number, to?: number) => ({ op: "assert" as const, entityId: EntityId.make(entity), attribute, value: string(value), validFrom: from, ...(to === undefined ? {} : { validTo: to }), }); yield* triples.transact( [ fact("vendor:acme", ":vendor/status", "active", J, F), fact("vendor:acme", ":vendor/status", "active", M), fact("exception:one", ":exception/status", "approved", J, E), fact("exception:open", ":exception/status", "approved", J), ], { actor: "user:pat" }, ); const review = yield* triples.transact([fact("review:one", ":review/status", "approved", F, A)], { actor: "user:lee", }); const correction = yield* triples.transact( [ { op: "retract", id: review.triples[0]!.id }, fact("review:one", ":review/status", "approved", F, Y), ], { actor: "user:kim" }, ); // 1. Active exceptions expiring in the window (M, M + 30 days]. const expiry = yield* triples.query( { find: ["?exception", "?end"], where: [ { pattern: ["?exception", ":exception/status", "approved"], valid: { to: "?end" } }, [">", "?end", M], ["<=", "?end", C], ], orderBy: [{ variable: "?end" }], }, { basis: { recordedPosition: correction.position, validAt: M } }, ); // 2. Who asserted the review believed to cover March 1 at the earlier cut? const provenance = yield* triples.query( { find: ["?review", "?actor", "?tx"], where: [ ["?review", ":review/status", "approved", "?tx"], ["?tx", ":_tx/actor", "?actor"], ], }, { basis: { recordedPosition: review.position, validAt: M } }, ); // 3. Every active interval in the vendor's recorded view, including January. const intervals = yield* triples.query( { validTime: "all", find: ["?start", "?end"], where: [ { pattern: ["vendor:acme", ":vendor/status", "active"], valid: { from: "?start", to: "?end" }, }, ], orderBy: [{ variable: "?start" }], }, { basis: { recordedPosition: correction.position } }, ); return { expiry: expiry.results, provenance: provenance.results, intervals: intervals.results }; }); const answer = Effect.runPromise(workedQueries.pipe(Effect.provide(KvTriples.layer))); ``` Expiry returns `exception:one` with end `E`; the open exception is excluded. Provenance returns `review:one`, `user:lee`, and the generated `review.txId`. At the correction's position it instead returns `user:kim` and the replacement transaction. The vendor intervals are `[J, F)` and `[M, null)`. In point mode at M only the second interval remains. Null equals null and differs from a finite number. Ordered bound predicates require a finite number at runtime, so open ends fail `[">", "?end", cutoff]` and `["<=", "?end", cutoff]`. Use `["=", "?end", null]` or wrapper `is-null` to select open ends. Repeated variables unify across tuple and interval positions; identity variables cannot also be interval bounds. `count` counts distinct bound values including null; numeric aggregates skip null and return null when no numeric input remains. Bounds support grouping, having, total ordering and stable pagination, with open ends in the existing null family. Cursor v2 binds the mode, bounds, filters, recorded cut and (in point mode) the resolved point; restart older cursors. KV reads intervals directly from batched assertion metadata. Constant-attribute end/start ranges use history-preserving interval indexes after their resumable backfill completes. SQL migration v8 adds the corresponding non-partial indexes; PostgreSQL interval columns become double precision to preserve fractional milliseconds. Expiry queries use one native scan/join rather than per-entity history calls. Exact derivation provenance and hypothetical overlays currently reject interval patterns and all-time mode. Sensitive-value presence/blind equality can project bounds while sealed value projection, ordering, aggregation and bound aliases remain planning errors. ## 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"], ], ], ], }; ``` `not` takes one or more clauses. With several, it is conjunctive: it excludes a binding only when all of its clauses match together, like `NOT EXISTS` over a join. Its clauses are patterns or predicates, at most eight of them. Variables that appear only inside the `not` are local to it; every variable it shares with the rest of the query must be bound by a positive pattern outside it: ```ts // Vendors with no completed review: excludes ?vendor only when one review both // references it and is complete. const unreviewed = { find: ["?vendor"], where: [ ["?vendor", ":vendor/name", "?name"], ["not", ["?review", ":review/vendor", "?vendor"], ["?review", ":review/complete", true]], ], }; ``` There is no `and` clause. The top-level `where` list is already a conjunction, and `["not", ["and", ...]]` is written `["not", clause1, clause2]`. Each `or` alternative is a single clause: one pattern, one predicate, or one `not` (which may itself hold several clauses). Alternatives filter bindings that the rest of the query establishes; they do not bind new variables for `find`. A multi-clause branch such as `["or", [[c1, c2], [c3]]]` is not supported and fails preflight with a message naming the alternative. To express "c1 and c2, or c3", use one of these: - negate the complement with a conjunctive `not`, when the alternatives are absence checks; or - run one query per alternative and union the results in host code. 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", { title: Attribute.use(CourseTitle, { required: true }), }); const CourseV2 = EntityType.make("Course", { 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 Use [Live queries](/live-queries) to observe complete query snapshots across commits and expiry. `@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 conservative `nextTemporalBoundary`, including empty runs suppressed by negation. The [durable runner](/runner) owns delivery, retries and boundary replay for registered definitions. It uses the same public dependency planner as live queries; it does not consume their observation stream. 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. Manual materialization remains available for previews and audit projections; callers using it without the runner own their timer delivery. ## 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; - integrations, idempotent outgoing effects, inboxes, and application 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. --- # Live queries `LiveQuery` from `@triplex-build/triplex/live-query` observes complete Datalog snapshots. Install one scoped layer per database runtime, using the same server-resolved `DatabaseScope` as the store. Its generation must survive a process restart and change after a database restore. Hosts authorize queries and own quotas, retention and token persistence. ```ts import { Effect, Stream } from "effect"; import { KvTriples } from "@triplex-build/triplex"; import { DatabaseScope } from "@triplex-build/triplex/runtime"; import { LiveQuery } from "@triplex-build/triplex/live-query"; const scope = DatabaseScope.make({ env: "dev", tenant: "demo", database: "safety", generation: 1, }); const observations = Effect.gen(function* () { const live = yield* LiveQuery; return yield* live .watch({ find: ["?worker", "?site"], where: [ ["?placement", ":placement/worker", "?worker"], ["?placement", ":placement/site", "?site"], ["not", ["?worker", ":worker/trained-for", "?site"]], ], }) .pipe(Stream.take(1), Stream.runCollect); }).pipe( Effect.provide(LiveQuery.layer({ scope })), Effect.provide(KvTriples.layerWithScope(scope)), Effect.scoped, ); ``` The initial emission includes an empty relation. Each possibly relevant commit then emits one complete snapshot, even if its rows are unchanged. A multi-operation transaction emits once. Raw watches use `queryAll`, preserving query ordering, aggregation and explicit restrictions. `maxRows` defaults to 10,000 (range 1–100,000): exceeding it fails with `WatchResultTooLarge`. This check occurs after execution and is **not a query-memory bound**. Use a wrapped query for a large relation. The watch calls `queryPage` for that moving window, including its filters, ordering, count and limit (default 100, maximum 1,000). Input continuation cursors are rejected. Returned cursors remain ordinary continuations of that exact snapshot; they do not create another live page. Rows outside the visible window can change membership. Each version-1 envelope contains `watchId`, `eventId`, exact `position`, `validAt`, `reason`, `coveredAfter`, `result`, `nextTemporalBoundary` and `resume`. Coverage starts exclusively after the preceding emission; initial coverage is null. Journal gaps conservatively invalidate, including internal checkpoint maintenance that has no journal envelope. Watches never write checkpoints. The global ordered journal is authoritative; emitter delivery is unnecessary. ## Valid time and restart The default `validTime: { mode: "now" }` resolves Effect's clock once per evaluation. One shared deadline scheduler wakes queries at the earliest dependency boundary, including future starts, expired negated facts, and facts outside a visible window. The interval is half open. A late observation uses actual evaluation time. Idle journal checks discover remote writes; they do not repeatedly query results to discover expiry. `validTime: { mode: "fixed", at: timestamp }` follows recorded corrections to a fixed business instant without timers. Queries with Datalog `validTime: "all"` emit `validAt: null` and have no temporal timer; explicit point options are rejected. Literal predicate cutoffs stay literal. Recorded bases in watch inputs are rejected: use ordinary reads for a frozen recorded view. ```ts import { Effect, Stream } from "effect"; import { LiveQuery } from "@triplex-build/triplex/live-query"; const reconnect = (resume: string) => Effect.gen(function* () { const live = yield* LiveQuery; return yield* live .watch( { find: ["?name"], where: [["?person", ":person/name", "?name"]], }, { resume }, ) .pipe(Stream.take(1), Stream.runCollect); }); ``` Persist `resume` only after handling the corresponding result. Restart first replays the exact old basis with reason `resume` and the same `eventId`, catches up commits at current valid time, then observes elapsed time at the current head. Tokens bind the complete query, window, options and database generation. Malformed, modified, incompatible, changed-query and ahead tokens fail with `WatchResumeError`. They are not authorization credentials. Position alone cannot deduplicate a timer observation: two events may have the same position and different valid instants. ## Scheduling and limits Default delivery backpressures: each pull evaluates at most one snapshot, retaining a journal position rather than queuing transaction bodies. Slow consumers replay storage independently. `coalesceMs` (default 0, integer 0–1,000) allows an explicit burst window: all envelopes are scanned, then one `coalesced` snapshot covers the range. Assert/retract intermediates can disappear. A temporal deadline bounds the coalescing wait. Offline temporal boundaries collapse into a current observation; this stream is not a historical timer log or an exactly-once task runner. The layer accepts `idleCheckMs` (default 1,000, range 1–1,000) and `evaluationConcurrency` (default four). Cancellation releases registrations and pending pulls. With no consumer demand, reads and deadlines wait for demand. The coordinator retains at most one 1,000-envelope cache page; a lagging subscriber catches up directly from the journal. `live.metrics` reports scanned head, last delivered position, last evaluation duration, subscriber count and timer lateness. Dependency analysis includes negation, every disjunction alternative and reachable rule body. Dynamic attributes use `dependencyStateAll`; fixed attributes use indexed `dependencyState`. Conservative invalidation may emit unchanged rows. Sensitive-query restrictions continue to apply. KV and SQLite run the shared conformance cases. PostgreSQL uses the same public composition; its integration gate remains opt-in. Cloudflare and FoundationDB remain experimental. For durable candidate reconciliation and business work, see [Derivations](/derivations) and [Operational primitives](/operational-primitives). --- # Derivation runner `@triplex-build/triplex/runner` follows the ordered journal and delivers candidate diffs to Effect handlers. It persists prepared evaluations, immutable Outbox payloads and acknowledged candidate snapshots. A materialization by another caller never becomes the delivered base. Publish `Runner.schema` with your application's release before acquisition. The schema uses ordinary `runner/control` entities, `runner:` IDs and `:runner/*` attributes. Host validators must authorize these attributed infrastructure writes; the runner has no authorization bypass. ```ts import { Clock, Effect, Layer } from "effect"; import { KvTriples } from "@triplex-build/triplex"; import { ConfigStore } from "@triplex-build/triplex/config"; import * as Derivation from "@triplex-build/triplex/derivation"; import * as Runner from "@triplex-build/triplex/runner"; import { DatabaseScope } from "@triplex-build/triplex/runtime"; const application = Effect.gen(function* () { const config = yield* ConfigStore.ConfigStore; const release = yield* config.commit({ label: "runner-example", objects: yield* Runner.schema, ref: "live", }); const definition = yield* Derivation.make({ name: "untrained-workers", configSnapshot: release.snapshot.id, identity: ["?worker"], query: { find: ["?worker"], where: [ ["?worker", ":worker/active", true], ["not", ["?worker", ":worker/trained", true]], ], }, }); const handle = yield* Runner.start( [ { key: "training", definition, onDiff: (delivery) => Effect.log({ batch: delivery.batchId, added: delivery.diff.added.length, removed: delivery.diff.removed.length, changed: delivery.diff.changed.length, }), }, ], { scope: DatabaseScope.make({ env: "prod", tenant: "acme", database: "work", generation: 1 }), id: "requirements", }, ); yield* handle.await; }).pipe(Effect.scoped); const Store = ConfigStore.layer.pipe( Layer.provideMerge(KvTriples.layerWithClock(Clock.currentTimeMillis)), ); const main = application.pipe(Effect.provide(Store)); void main; ``` The logging handler illustrates the envelope. Application handlers normally use [Database commands](/database) with `delivery.commandId(kind, candidate.id)` as the command ID and `delivery.batchId` as the correlation ID. Record outgoing integration intents inside that command, then deliver them with an idempotent transport. See the complete [compliance host](https://github.com/bjacobso/triplex/tree/main/examples/compliance-host). ## Delivery and recovery Delivery is at least once and ordered per registration. Bootstrap captures the current journal head and valid time, then compares current candidates with an empty set. Every relevant later commit evaluates its exact recorded position; unchanged candidates produce no callback. Removed candidates retain their old explanation, while `delivery.basis` identifies the cut that observed absence. Hosts decide whether a removal means satisfaction or cancellation. Batch identity includes the generation, runner ID, registration key, predecessor delivery, definition and target basis. Attempts and retry times never change command IDs. A reopened candidate receives a new batch identity even when its logical candidate ID is unchanged. Handlers must remain replay-compatible across restarts and deployment. Prepared and ready batches survive interruption. Acknowledgement atomically updates the control head and acknowledges the Outbox entry; a separate completed-cut marker repairs a lagging consumer checkpoint. No arbitrary application effect is atomic with runner acknowledgement. ## Time and supervision Future starts and expiries, including empty results suppressed by negation, drive one minimum deadline timer. Offline catch-up replays every recorded boundary at its exact instant. Commits win ties with boundaries; equal or decreasing timestamps still follow journal position order. The durable valid-time frontier never rewinds after clock rollback. A bounded head check (default one second) discovers other-process writes and lost hints. This check does not poll queries for expiry. Process suspension can delay callbacks. `handle.status` reports scanned/delivered positions, lag, acknowledged runs, pending attempts, retry deadlines, failures, evaluation duration and timer lateness. Typed handler failures retry with jittered exponential backoff (eight attempts, one second to one minute by default). Exhaustion pauses that registration; others continue, while the shared checkpoint remains at the minimum delivered coverage. `handle.retryPaused(key)` starts another retry cycle for the same batch. Defects and storage/scheduler failures reach `handle.await`. Scope closure and `handle.stop` interrupt and join work without acknowledging an interrupted handler. Acquisition rejects duplicate keys, unsupported definitions, changed database generations, missing recovery definitions and malformed control records. Keep old definitions in `recoveryDefinitions` while prepared or ready work still references them. After that work finishes, a revised definition produces a definition-change evaluation at the captured current cut. Drain before removing registrations or making incompatible handler/schema changes. ## Ownership and limits Use one active process per world/database generation. Local acquisition rejects a second owner; this registry is not a distributed lease. Hosts must stop or fence old processes before failover. CAS detects conflicting control transitions but cannot prevent duplicate external effects. There is no poison-discard API. Evaluation concurrency defaults to four. Complete candidate snapshots default to a 10,000-candidate and 16 MB payload budget. Exceeding a budget pauses the prepared batch before application delivery; increase the budget and resume it after restart. Nothing is truncated. Compaction and chunked payloads are not provided. Sensitive results in infrastructure snapshots are not automatically encrypted; hosts control storage access and payload protection. KV and SQLite are supported targets; PostgreSQL remains a pre-1.0 production candidate. In-memory KV supports runner restarts while its backend remains alive, and does not promise process-crash persistence. Persistent deployments need durable storage. Cloudflare and FoundationDB remain experimental and are not certified for the runner. --- # Explain a fact with `why` `Why` answers who asserted a fact, which command wrote it, and which immutable release that transaction pinned. It returns a versioned JSON DTO with complete fact intervals and journal attribution. It captures one recorded position and uses that cut for every read. By default it stops at the assertion and release. ```ts import { Effect, Layer } from "effect"; import { EntityId, KvTriples, Triples, ref } from "@triplex-build/triplex"; import { ConfigStore } from "@triplex-build/triplex/config"; import { Why } from "@triplex-build/triplex/why"; const Store = ConfigStore.layer.pipe(Layer.provideMerge(KvTriples.layer)); const Explained = Why.layer.pipe(Layer.provideMerge(Store)); const example = Effect.gen(function* () { const triples = yield* Triples; const why = yield* Why; const entityId = EntityId.make("placement:maria-harbor"); const written = yield* triples.transact( [ { op: "assert", entityId, attribute: ":placement/site", value: ref(EntityId.make("site:harbor")), validFrom: Date.UTC(2026, 2, 2), }, ], { actor: "user:dana", commandId: "create:placement:maria-harbor" }, ); const explanation = yield* why.why( { kind: "attribute", entityId, attribute: ":placement/site" }, { basis: { recordedPosition: written.position, validAt: Date.UTC(2026, 2, 3) } }, ); return JSON.stringify(explanation); }).pipe(Effect.provide(Explained)); ``` Use the same service over SQLite or PostgreSQL by replacing the `Triples` layer. `Why.layer` requires only `Triples` and `ConfigStore`; it has no codec or opener. ## Choose a subject - `{ kind: "fact", tripleId }` explains that exact assertion, including a retracted or future-valid assertion. An ID first recorded after the cut is not found. - `{ kind: "attribute", entityId, attribute }` explains every visible value of an attribute. It preserves multi-valued attributes. - `{ kind: "entity", entityId }` explains every visible fact of the entity. Entity results are complete and unpaginated. Empty state returns `facts: []`. - `{ kind: "candidate", definition, candidateId, revision }` replays the supplied immutable derivation at an explicit recorded basis and valid time. Retain the definition alongside stored candidates; an ID alone cannot recover it. ```ts import { Effect } from "effect"; import { Triples } from "@triplex-build/triplex"; import * as Derivation from "@triplex-build/triplex/derivation"; import { Why } from "@triplex-build/triplex/why"; const explainCandidate = (definition: Derivation.Definition, validAt: number) => Effect.gen(function* () { const triples = yield* Triples; const why = yield* Why; const basis = { ...(yield* triples.resolveRecordedBasis()), validAt }; const evaluation = yield* Derivation.evaluate(triples, definition, { basis }); const candidate = evaluation.candidates[0]; if (candidate === undefined) return null; return yield* why.why( { kind: "candidate", definition, candidateId: candidate.id, revision: candidate.revision }, { basis }, ); }); ``` Candidate facts expand positive source witnesses. Negation is evaluated at the same cut; it creates no fact or correction receipt as an absence witness. A candidate's definition release and its source transactions' releases can differ. Replaying an old basis explains knowledge then; it does not claim freshness today. ## Time and causation `basis` accepts `validAt` plus either `recordedPosition` or `recordedAt`. Omitted valid time uses the Effect clock once. Omitted recorded time captures the committed head once. A recorded timestamp resolves to an exact position, including all equal-timestamp commits visible at capture. Reuse the returned position for replay. Visibility uses half-open valid intervals, `[from, to)`. A historical result excludes later retraction metadata. A fact explanation after retraction reports the retractor without shortening the original valid interval. The implementation reads journal receipts independently of valid time, so a backdated March assertion written in November still reports its November actor. Set `causationDepth` to an integer from 0 through 32 to follow command parents. Zero performs no parent lookups. A `causationId` names an exact case-sensitive `commandId` in the same database; correlation IDs do not establish causation. Each assertion/retraction receipt starts its own trace. Stops report `disabled`, `root`, `depth-limit`, `missing`, `after-basis`, or `cycle`. Missing external commands and cycles are successful partial explanations. There is no forward fan-out. ## Reading the result Every top-level DTO field is present. Missing scalar metadata uses `null`; `derivation` is `null` for stored facts. Facts sort by entity, attribute and triple ID; transactions deduplicate and sort by position then ID. Traces sort by starting transaction ID. Embedded candidates retain their existing optional-field JSON contract and evaluator source ordering. The result contains no Effect `Option`. Release lookup uses the transaction's `configSnapshot`, never today's `live` ref. A release is `unpinned`, `missing` with its stored ID, or `found` with ID, label and root CID. A pin records declared governance; it does not prove enforcement. Legacy facts retain missing attribution as `gaps` when an exact public state read can classify them. Ambiguous or inconsistent history fails `WhyIncompleteHistory` rather than guessing an actor, position or release. Storage/configuration failures remain typed failures. Other explanation errors are `WhyInvalidRequest`, `WhySubjectNotFound` and `WhyCandidateRevisionMismatch`; see [error codes](/errors). Sealed values remain opaque sealed envelopes. `why` never opens them or returns a definition's query and sensitive equality constants. Hosts still control access to actors, ciphertext and equality tokens. Whole-entity history and broad candidate evaluations can be expensive; host time or response budgets must fail explicitly instead of returning an unexplained subset. --- # 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", { name: EmployerName.required().unique(), }); // A bare attribute is an optional, single-valued use. export const EmployerSummary = EntityType.make("EmployerSummary", { name: EmployerName }); export const Employment = EntityType.make("Employment", { employer: Attribute.ref(":employment/employer", Employer).required(), }); 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 }); ``` Attribute constructors fix the stored value type: | Constructor | TypeScript input | Stored as | Datalog projects | Ordered `<` `>`? | Use for | | ------------------- | ------------------ | ---------- | ---------------- | ---------------- | -------------------------------------------------- | | `Attribute.text` | `string` | `string` | string | No | Names, free text | | `Attribute.number` | `number` | `number` | number | Yes | Quantities, scores | | `Attribute.integer` | `number` | `number` | number | Yes | Counts | | `Attribute.boolean` | `boolean` | `boolean` | boolean | No | Flags | | `Attribute.date` | `string` | `string` | string | No | Calendar dates you display, such as `"2026-03-15"` | | `Attribute.instant` | `number` or `Date` | `datetime` | epoch ms number | Yes | Times you compare, sort, or expire | | `Attribute.enumOf` | one of the values | `string` | string | No | Closed sets such as statuses | | `Attribute.ref` | `EntityId` | `ref` | string ID | No | Relationships to another entity type | `date` is a calendar string, so ordered Datalog predicates (`<`, `<=`, `>`, `>=`), which are numeric-only, cannot compare it. Use `instant` for any time you compare or expire. When a date says when the fact itself holds, prefer the assertion's `validFrom` and `validTo` over either attribute. A handle's `assertion(value, { validFrom, validTo })` returns `{ attribute, value, validFrom?, validTo? }`, ready to spread into an `assert` operation next to `op`, `entityId`, and `entityType`. Both options are epoch milliseconds and describe the half-open valid interval `[validFrom, validTo)`; omit `validFrom` to start at the commit time and omit `validTo` for an open-ended fact. A non-finite value, or a `validTo` that is not later than `validFrom`, throws. Requiredness is not inherently true of `:employer/name`; it is true of the way `Employer` uses the attribute. Reference attributes follow the same separation. `.required()`, `.many()`, and `.unique()` describe a use and chain in any order: `.required().unique()`, `.unique().many()`, or `.many().required()`. Each call returns a new use, preserving the other constraints and leaving the original unchanged. Inspect a use with `isRequired`, `cardinality`, and `isUnique`; entity handles retain `required`, `cardinality`, and `unique` data fields. The first modifier on a definition also accepts the remaining options, for example `.required({ unique: true })` or `.many({ required: true })`. These and `Attribute.use(attribute, options)` produce the same configuration content IDs as chaining. Declare entity types with `EntityType.make(name, fields)`, passing aliased fields directly. `Employer.nodes` yields the independently identified attribute definitions followed by the entity schema. Use `EntityType.nodesOf(Employer, EmployerSummary, Employment)` to publish several types together: it deduplicates shared attribute nodes by content ID before `ConfigStore.commit`. `Employer.constraints` yields independently content-addressed required, cardinality, uniqueness, and reference-target rules. ## Reading typed entities `Type.decode(facts, options?)` returns an Effect that turns visible facts into an object keyed by the declaration's aliases. Read the desired temporal basis first; the decoder does not filter historical or retracted facts itself. ```ts import { Effect } from "effect"; import { EntityId, Triples } from "@triplex-build/triplex"; import { Attribute, EntityType } from "@triplex-build/triplex/config"; const Worker = EntityType.make("Worker", { name: Attribute.text(":worker/name").required(), roles: Attribute.text(":worker/role").many(), joined: Attribute.instant(":worker/joined"), }); export const readWorker = Effect.gen(function* () { const triples = yield* Triples; const facts = yield* triples.match( { entityId: EntityId.make("worker:maria") }, { validAt: Date.UTC(2026, 0, 1) }, ); const decoded = yield* Worker.decode(facts, { withFacts: true }); // name is present; roles is an array; joined may be undefined. // Sealable values also admit a {$sealed: {valueType, keyId}} marker. const name = decoded.values.name; if (typeof name === "string") console.log(name); // Original facts include IDs and intervals for Worker.replace/end/correct. return { values: decoded.values, nameFacts: decoded.facts.name }; }); ``` Optional scalar aliases decode to `undefined` when absent; cardinality-many aliases always return readonly arrays, including `[]` when absent. Required aliases must have at least one fact unless `{ allowPartial: true }` is passed, which also makes required scalar types admit `undefined`. Two visible facts for a scalar alias fail even if their values are equal. Cardinality-many values are deduplicated and sorted by canonical identity, so their order need not match assertion order. Instants decode to epoch-millisecond numbers, including when written with a `Date`; calendar dates remain strings, enums retain their literal union, and references retain `EntityId`. All facts must have the declared entity type and belong to one entity. Incompatible stored values, missing required aliases, and ambiguous scalar aliases produce typed `EntityType.EntityDecodeError` failures with a `reason` and optional `alias`. An empty input is a missing entity body: it fails if required aliases exist, otherwise it decodes to empty arrays and undefined scalars. The decoder cannot distinguish a nonexistent entity from an entity with no visible facts. Undeclared attributes are ignored by default. `{ unknownAttributes: "reject" }` rejects them; `{ withFacts: true }` returns `{ values, facts, unknownFacts }` with original facts grouped by alias and undeclared facts separately. Metadata preserves every supplied fact, including multiple facts that decode to the same many-value. A sealed stored value always becomes a marker containing only its value type and key ID, even if the declaration no longer marks it sensitive. For this reason sealable output types include `EntityType.SealedMarker`; narrow them before using plaintext. Sensitive attributes with unsealed stored values fail. Opening stays an explicit `Sensitive.open` call on the original facts; the decoder never opens values. Metadata contains those original facts, including their sealed envelopes when present, so use `values` as the display object. ## Writing entities `Type.assert(id, values, options?)` turns attribute values, keyed by the TypeScript aliases, into `assert` operations tagged with the entity type. Values are typed by their attribute, and a cardinality-many use takes an array, so `trainedFor: [harbor]` asserts one fact per element. Every alias is optional and `undefined` is skipped: requiredness is a property of the entity's state after the transaction, which `enforce` checks atomically, not of each write. `validFrom` and `validTo` set the half-open business-time interval `[validFrom, validTo)` on every operation; call `assert` more than once when facts hold for different intervals. `GraphConstraint.pinned(release)` returns the `configSnapshot` and `enforce` metadata for one release, so a write records and enforces the same rules: ```ts import { Effect } from "effect"; import { EntityId, Triples } from "@triplex-build/triplex"; import { Attribute, ConfigStore, EntityType, GraphConstraint } from "@triplex-build/triplex/config"; const Site = EntityType.make("Site", { name: Attribute.text(":site/name").required() }); const Worker = EntityType.make("Worker", { name: Attribute.text(":worker/name").required(), trainedFor: Attribute.ref(":worker/trained-for", Site).many(), }); const harbor = EntityId.make("site:harbor"); const maria = EntityId.make("worker:maria"); const training = { validFrom: Date.UTC(2026, 2, 1), validTo: Date.UTC(2026, 5, 1) }; export const onboard = Effect.gen(function* () { const triples = yield* Triples; const release = yield* (yield* ConfigStore.ConfigStore).resolveRef("live"); if (release === undefined) return yield* Effect.die("no live release"); return yield* triples.transact( [ ...Site.assert(harbor, { name: "Harbor" }), ...Worker.assert(maria, { name: "Maria" }), ...Worker.assert(maria, { trainedFor: [harbor] }, training), ], { actor: "user:hr", commandId: "onboard:maria", ...(yield* GraphConstraint.pinned(release)) }, ); }); ``` Single-attribute handles remain available for hand-built operations: `Worker.name.assertion("Maria", { validFrom, validTo })` returns the `attribute`, encoded `value`, and valid-time fields to spread into an `assert` operation. ## Querying by alias `Type.where(entity, patterns)` returns one Datalog pattern clause per alias, and `Type.not(entity, patterns)` returns a `not` clause over them. Values are variables or constants typed by the attribute; reference constants become typed `ref` constants, so they match exactly. A cardinality-many attribute matches one value per pattern. ```ts const untrained = { find: ["?worker", "?site"], where: [ ...Placement.where("?placement", { worker: "?worker", site: "?site" }), Worker.not("?worker", { trainedFor: "?site" }), ], }; ``` The clauses are ordinary Datalog, so they combine with raw patterns, predicates, and rules. Datetime, JSON, and blob values are not Datalog constants; bind them to a variable and compare with a predicate. ## 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. ### Constraints hold at every valid instant Enforcement does not only check "now". It evaluates each rule at every valid-time edge (each `validFrom` and `validTo`) of the facts it loads for the affected entities, before and after the transaction. An entity exists at an instant when any of its typed facts is valid then. At each such instant: - `required`: the entity must also have a valid value for each required attribute; - `cardinality` and `unique`: the counts are taken over the facts valid at that instant; and - reference target: the referenced entity must have a fact of the target type valid at that same instant. Two modeling rules follow: 1. **All of an entity's facts share one interval.** Give every attribute of a new entity the same `validFrom`. If `:vendor/status` starts on 2026-03-15 but `:vendor/name` defaults to the commit time, the vendor exists on 2026-03-15 without a name, and a required name rule fails at that instant. 2. **Referenced entities must be valid from an earlier (or the same) time.** A review dated 2026-03-01 that references a vendor whose facts start on 2026-04-01 fails the reference-target rule at 2026-03-01. Load the referenced entity from a `validFrom` at or before the earliest fact that points at it. Assertions without `validFrom` start at the commit time, so a backdated assertion of one attribute next to defaulted assertions of others creates exactly this gap. To change one attribute's value from a date on, end the old value at that date and assert the new value from it in the same transaction instead of retracting the old value outright. `ConstraintViolationError.message` names the first two violations with their constraint key, subject, and `validAt` (as an ISO instant). `error.violations` holds the complete list. Each entry carries `constraintKey`, `code`, `subject`, `entityType`, `attribute`, `message`, and the `validAt` epoch-millisecond instant at which it fails: ```ts const write = Effect.gen(function* () { const triples = yield* Triples; return yield* triples .transact(operations, meta) .pipe( Effect.tapError((error) => error._tag === "ConstraintViolationError" ? Effect.logWarning( error.violations.map( (v) => `${v.constraintKey} ${v.subject} at ${new Date(v.validAt).toISOString()}`, ), ) : Effect.void, ), ); }); ``` 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. `TripleLive` cannot detect an insertion: a fact asserted between your read and your write has no ID you could name. For that, use an `EntityState` precondition. The narrower graph-constraint enforcement path also atomically protects declared required, cardinality, uniqueness, and reference-target invariants by evaluating them inside the serialized commit boundary. `TransactionConflictError.isRetryable` is always true: reload the state used to decide the command, then retry its complete transaction. PostgreSQL also uses this type for native serialization and deadlock failures, with `reason` set to `serialization` or `deadlock` and the original failure retained as `cause`. `PgTriples.withTransaction` extends that typed boundary over host statements and the host's outer commit. Other write failures are not reclassified as conflicts, and Triplex does not choose retry/backoff policy for the host. ### Entity-state preconditions `{ _tag: "EntityState", entityId, tripleIds }` is the check-then-write guard for one entity. After the transaction enters the serialized commit boundary, Triplex compares the entity's complete set of live (not retracted) fact IDs, across all valid time, with `tripleIds`. Any difference (an assertion, retraction, or replacement by another writer since your read) fails the whole transaction with `TransactionConflictError`; nothing commits. Unlike `TripleLive`, it needs no matching `retract` operation, so it also guards an "assert only when nothing changed" write. Observe the set from `liveFacts(entityId)`, an indexed read of unretracted assertions across every valid time at the current recorded cut. Its cost follows live state rather than retained history. `entity(entityId)` applies a valid-time basis and would omit future or expired facts, so its IDs can differ from the set the precondition compares. ```ts const activate = Effect.gen(function* () { const triples = yield* Triples; const observed = yield* triples.liveFacts(vendor); // Decide from `observed` (and any Datalog check) whether the command is allowed, then: yield* triples.transact(operations, { actor, commandId, preconditions: [ { _tag: "EntityState", entityId: vendor, tripleIds: observed.map((fact) => fact.id) }, ], }); }); ``` On conflict, reload and decide again. The precondition guards only the named entity: a check that reads other entities (such as a vendor's reviews) needs an `EntityState` precondition for each of them, or graph-constraint enforcement. It is also not a declarative rule: a write that omits it is not checked, and the passage of valid time can still make a guarded state invalid later. ### Attribute-value and absence preconditions Use `AttributeValue` to compare one attribute without loading the entity or its history. It requires **exactly one unretracted fact** for the entity/attribute, with the expected typed value. JSON object key order is ignored through canonical encoding; scalar value types remain separate. Two live facts conflict even when their values are equal. `AttributeAbsent` requires no unretracted fact on one attribute; `EntityAbsent` requires none on the entity. A previously used but fully retracted entity is absent. Like `EntityState`, these guards consider every valid time: unretracted future and expired facts count. They observe the pre-state before any operation in the transaction, within the existing serialized commit boundary. They require no matching retract. A mismatch fails with retryable `TransactionConflictError` and rolls back facts, journal, receipt, and position. Unrelated attributes do not invalidate an attribute guard. Hosts must still guard every entity or invariant their command depends on. ```ts import { Effect } from "effect"; import { EntityId, Triples, number } from "@triplex-build/triplex"; const createHome = Effect.gen(function* () { const triples = yield* Triples; const home = EntityId.make("home:alice"); return yield* triples.transact( [{ op: "assert", entityId: home, attribute: ":home/version", value: number(1) }], { preconditions: [{ _tag: "EntityAbsent", entityId: home }] }, ); }); const checkVersion = Effect.gen(function* () { const triples = yield* Triples; const home = EntityId.make("home:alice"); return yield* triples.transact([], { preconditions: [ { _tag: "AttributeValue", entityId: home, attribute: ":home/version", value: number(1) }, { _tag: "AttributeAbsent", entityId: home, attribute: ":home/deleted" }, ], }); }); ``` The sensitive decorator rewrites plaintext value guards through the attribute's blind-token policy. A codec without blind equality rejects that plaintext comparison with `WriteError`; plaintext never enters the storage lookup or journal. Raw stored sealed envelopes can still be compared exactly; envelopes with blind tokens compare their value type and token. Lookups fetch at most two live facts for a value guard and one for absence. SQL uses partial live indexes. PostgreSQL migration **v6** adds a length-framed entity/attribute expression index and analyzes `triples`: its index-expression statistics describe live rows, avoiding the historical attribute skew that made the planner scan old facts. Keep automatic analysis enabled as data changes. SQLite and Cloudflare use their existing `(entity_id, attribute)` live index; SQLite records migration v6 without extra DDL, and Cloudflare needs no additional migration. KV retains its historical indexes and adds one live-only ID key per unretracted fact. Assertions, batch assertions, and retractions update that key within the same transaction. Fresh empty stores are ready automatically. An existing store keeps all existing APIs working, but the new cheap guards return `ReadError` until its live index is backfilled. Upgrade **every writer** first; old writers do not maintain the new index. Run bounded batches against the same backend until complete: ```ts import { Effect } from "effect"; import { KvTriples } from "@triplex-build/triplex"; // Provide the existing database's KvBackend layer when running this effect. const backfill = Effect.gen(function* () { for (;;) { const progress = yield* KvTriples.migrateLiveFacts({ batchSize: 500 }); if (progress.complete) return; } }); ``` For FoundationDB, provide `makeFdbKvBackend(config)` with the same cluster and subspace settings as the existing store. Each batch atomically persists its cursor and ID keys. It is safe to resume after a crash; upgraded transactional writers maintain the index while backfill proceeds. Retractions conflict with overlapping source reads and remove live keys atomically, while new assertions before an already processed cursor write their own keys. Historical keys remain unchanged. Batch sizes are 1–1000 (default 500); reduce the size to fit the backend's transaction time/size limits. Malformed source data aborts the batch. A completed backfill is a no-op on repetition; unknown index versions are rejected. Mixing old writers or writing raw KV facts outside Triplex's transaction/index protocol invalidates the guarantee. Custom storage adapters must implement `livePreconditionFacts` as a bounded indexed lookup, including all unretracted valid times. Exhaustive precondition switches must handle the three new variants. Existing `TripleLive` and `EntityState` behavior is unchanged. ### Commit validators `TransactionMeta.validate` runs host validators after the operations and `_Transaction` envelope are applied and before commit. Preconditions and `enforce` run first. Each validator receives the attempted envelope, the resolved changes, and read-only `before` and `after` views at exact commit positions. It can return typed rejections, which roll back the whole transaction with `CommitRejectedError`. `CommitValidation.layer` installs validators for every application write path and can record each rejection durably, as a reserved entity written after rollback. See [Commit validators](/commit-validators) for the contract, ordering, and per-backend semantics. ### 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. Their valid time starts at the commit instant, so query them at the current valid time; a past `validAt` hides them (see [Datalog](/datalog#query-shape)). 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. Receipt and journal ordering is backend-independent. `TransactionResult.triples` contains only assertions, in the order their operations were submitted to `transact`, including assertions with `validTo`. `TransactionRecord.changes` follows the submitted operation order with retractions and assertions interleaved. This applies to `transaction`, `transactionByCommand`, `transactions`, and `transactionsForEntity`, as well as the resolved changes passed to commit validators. A `retract-pattern` expands at its operation position in ascending triple-ID order. Retractions that change no fact and inbox/outbox operations contribute no fact changes. The receipt's `retracted` field counts successful retractions; it does not add retracted facts to `triples`. New `:_tx/change` JSON facts store an internal `changeIndex` so journal reads do not depend on storage scan order. Existing journals remain readable using metadata fact IDs as the ordering fallback; their original submitted order cannot be recovered if an older writer reordered changes. `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. ### Entity-prefix transaction feeds `transactions({ after, limit, entityIdPrefix })` selects the ordered subsequence of the global journal touching any entity whose ID starts with the literal prefix. Matching is case-sensitive; `%`, `_`, and backslashes have no wildcard meaning. A transaction touching several matching entities appears once, with its complete metadata and all changes, including changes to other entities. Assertions and retractions both count. Omit the filter for the existing global feed. An explicit empty prefix selects transactions with at least one fact change, so empty commands and entry-only inbox/outbox commands do not match it. ```ts import { Effect } from "effect"; import { Triples } from "@triplex-build/triplex"; const followHomes = Effect.gen(function* () { const triples = yield* Triples; return yield* triples.transactions({ entityIdPrefix: "home:", after: 0, limit: 200, }); }); ``` Resume with the returned `next` as the next `after`; it remains an exclusive global commit position, with gaps for non-matching commands. Limits keep their existing 1–1000 range and default 100. Empty pages return no `next`. Keep the same prefix when resuming a consumer checkpoint; changing it intentionally changes the selected subsequence after that global position. The tenant protocol's `Transactions` operation accepts the same optional `entityIdPrefix` field. Discovery uses changed-entity references before hydrating only matching command records. SQLite/Cloudflare reuse the existing reference index. PostgreSQL SQL migration **v7** adds a binary-collation covering prefix/position index and analyzes triples; host-owned clients must migrate before layer acquisition. SQLite records v7 without extra DDL, and Cloudflare needs no new migration beyond v6. KV uses its existing AVET changed-entity prefix, pins the committed head for the scan, and retains a bounded candidate set before reading complete envelopes. No KV feed backfill is needed. Custom storage adapters implement `transactionIdsForEntityPrefix`. The filter avoids unrelated transaction metadata. Work can still grow with the matching prefix's history: KV scans those references and SQL can visit matching index candidates before sorting/deduplicating. The page limit bounds returned records, not a promise of history-independent candidate work. No entity-type membership or type-change semantics are introduced by this prefix filter. ### 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?, recordedPosition?, 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. Supply at most one of `recordedAt` and `recordedPosition`. The latter selects an exact committed fact cut and survives later assertions and retractions without a history/transaction lookup loop. Positions must be non-negative safe integers; zero selects no facts. Specify `validAt` too when replay must preserve the same business-time instant. Facts returned at an explicit position cut omit later retraction metadata, reproducing the earlier read. Full `history` and `get` audit reads retain the stored retraction information. ```ts import { Effect } from "effect"; import { EntityId, KvTriples, Triples, string } from "@triplex-build/triplex"; export const replay = Effect.gen(function* () { const triples = yield* Triples; const entityId = EntityId.make("home:1"); const original = yield* triples.transact([ { op: "assert", entityId, attribute: ":home/status", value: string("draft"), validFrom: 0 }, ]); yield* triples.transact([ { op: "retract", id: original.triples[0]!.id }, { op: "assert", entityId, attribute: ":home/status", value: string("ready"), validFrom: 0 }, ]); return yield* triples.entity(entityId, { recordedPosition: original.position, validAt: 1 }); }).pipe(Effect.provide(KvTriples.layer)); ``` The returned fact still has value `draft`. SQL migration v4 adds `idx_entity_recorded_position` for selective entity replay; Cloudflare's equivalent is migration v5. Host-owned SQL migrations must run before acquiring an unmigrated Postgres layer. KV uses its existing temporal hexastore; no KV migration is needed. ### 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 `Inbox` and `Outbox` from `@triplex-build/triplex/operational` build operations for the existing `Triples.transact` boundary. Facts, command receipts, entries, and acknowledgements commit together. Delivery vendors, scheduling, leasing, and retry policy remain host concerns. ```ts import { Effect } from "effect"; import { EntityId, Triples, string } from "@triplex-build/triplex"; import { Outbox } from "@triplex-build/triplex/operational"; const home = EntityId.make("home:alice"); const commitIntent = Effect.gen(function* () { const triples = yield* Triples; yield* triples.transact( [ { op: "assert", entityId: home, attribute: ":home/status", value: string("ready") }, Outbox.append({ scope: home, idempotencyKey: "command:123:send", payload: { destination: "worker", command: "refresh" }, }), ], { commandId: "command:123" }, ); const page = yield* Outbox.pending(triples, { scope: home, limit: 25 }); return page; }); // Run this only after the host has successfully delivered the stored payload. const acknowledge = Effect.gen(function* () { const triples = yield* Triples; yield* triples.transact([Outbox.acknowledge(Outbox.id(home, "command:123:send"))]); return yield* Outbox.get(triples, Outbox.id(home, "command:123:send")); }); ``` Identity is `(inbox or outbox, entity scope, idempotency key)`. Keys contain 1–1024 JavaScript UTF-16 code units. Payloads use the existing canonical JSON encoding: object key order is irrelevant, negative zero becomes zero, and unsupported or non-finite values fail with `WriteError`. Appending an existing identity with the same canonical payload leaves its original record unchanged, including after acknowledgement. A different payload fails with `OperationalEntryConflictError` (`isRetryable: false`); the entire transaction rolls back. Ordinary SQL serialization/deadlock conflicts retain their separate retryable error. Omit `scope` from `pending` to drain a whole database. Inbox and outbox directions are separate. Pages order entries by original append position, then ASCII content ID; this also disambiguates several entries appended in one transaction. Pass `nextCursor` into the next `pending` call. Limits default to 100 and must be integers from 1 to 1000. Cursors bind the database runtime scope, entry direction, and entity filter. They remain valid if their last entry is acknowledged. They are live pending cursors, with no pinned snapshot or ownership claim: another worker may acknowledge an entry between reads. Exhaustion returns no `nextCursor`; start another drain without a cursor to discover new work or retry unacknowledged deliveries. Reading pending work does not claim or mutate it. A crash after commit or delivery and before acknowledgement redelivers the exact canonical stored payload. Consumers must handle at-least-once delivery; a cursor does not establish successful delivery. `acknowledge` retains the first acknowledgement's transaction ID, position, and time, removes only the pending index entries, and is idempotent. A missing or wrong-direction target fails with `WriteError`. `get` retains both original append metadata and optional acknowledgement metadata as history. An append followed by acknowledgement in one transaction is allowed and never becomes pending. Each entry has its own record. SQL uses a primary-key lookup and separate partial pending indexes for an entity scope and the whole database. KV uses primary records and separate global/scoped pending ranges. Reads fetch at most `limit + 1` candidates; acknowledged history is absent from those indexes. No operation materializes a home entity or scans acknowledged entries. Entries link to their original and acknowledgement receipts; their payloads are not copied into `:_tx/change`, which continues to describe fact changes. Transaction results still report only asserted/retracted facts. Entry storage is a separate operational record store, so fact history, Datalog, temporal cuts, and entity snapshots do not include these entries. Entry payloads do not pass through attribute-based `Sensitive` policies: they have no attribute. Hosts must encrypt sensitive payload contents before appending them and control who can read operational records. The API adds no automatic decryption or transport authorization. SQL migration **v5** and Cloudflare migration **v6** create the operational table and pending indexes. Host-owned SQL clients must run `runMigrations` before acquiring the updated layer. No KV data migration is required. Custom `StorageAdapterService` implementations must add the four operational storage methods exported through the runtime contract. The `TriplesService` read contract gains `operationalEntry` and `pendingOperationalEntries`; `TransactOp` gains two variants and its error union gains `OperationalEntryConflictError`. Update custom service implementations, exhaustive switches, and explicit transaction error annotations when adopting. ### 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. Hosts can enforce the last two atomically with [commit validators](/commit-validators). ### 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. ### Resolving recorded bases and wildcard dependencies `Triples.resolveRecordedBasis({ recordedAt })` returns an exact `{ recordedPosition }` from one captured committed head. Omit the basis to capture the head, or supply a `recordedPosition` to validate it against that head. Supplying both fields, a non-finite timestamp, or a negative, fractional, or ahead position fails with `ReadError`. A timestamp before the first commit resolves to zero; equal timestamps include every commit visible at capture; a future timestamp resolves to the captured head. Timestamp resolution selects the greatest qualifying position, including after clock rollback. `Triples.dependencyStateAll(basis)` returns the same freshness position and earliest future valid-time boundary as the union of `dependencyState` over every attribute, including journal and system attributes. Historical retractions advance the position at their recorded cut and remove their former future boundaries. `dependencyState([])` still means no dependencies and returns `{ sourcePosition: 0 }`. SQL migration **v9** adds a committed-journal timestamp/position index and a covering all-attribute temporal index. KV maintains a covering temporal projection and a position-ordered timestamp trie: timestamp resolution takes at most 65 bounded index seeks, even with clock rollback, without reading journal envelopes. Existing KV stores backfill these indexes once when opened. Wildcard dependency reads traverse the covering temporal index, without loading fact values. Cloudflare is frozen and returns `ReadError` for wildcard dependencies and historical timestamp resolution; FoundationDB inherits the working KV implementation. ### Assertion identity reads `Triples.get(id)` returns only unretracted assertions, independent of valid-time visibility. `Triples.assertion(id)` returns the retained assertion whether or not it is retracted, including `retractedAt` and `retractTxId`. Both return `null` for an unknown ID. Use `entity` or `match` with a temporal basis for state selection; use `assertion` for historical identity reads and [Why](/why) for an exact-cut explanation. Sensitive values remain sealed on both identity reads. Custom `TriplesService` implementations must provide `assertion`. Storage adapters' `getById(id, includeRetracted?)` must honor `true` by returning historical rows. The default remains a live lookup. KV `get` now matches SQL by excluding retracted assertions; callers needing its former historical behavior must use `assertion`. --- # Sensitive values Status: **pre-1.0, opt-in.** This page is the design record and reference for sensitive attribute values: what they are, which guarantees Triplex gives, and what remains host work. [Implementation status](#implementation-status) at the end separates built features from designed ones. ## Problem Triplex stores every value in typed plaintext columns or KV records, and Datalog, indexes, uniqueness checks and graph constraints compare those values directly. Nothing marked an attribute as sensitive, so a database that holds national identifiers, birth dates or document numbers kept them readable in every table, journal payload, snapshot and backup. The configuration layer's observation digests (`config/World.ts`) avoid copying values into decision records, but they are not encryption and do not protect the facts themselves. A sensitive attribute needs four properties together: 1. **Declared, not inferred.** Sensitivity is part of the attribute's content-addressed identity, so changing it appears as a changed configuration node in a release rather than as a runtime toggle. 2. **Sealed at rest.** Writes seal the value through a pluggable codec before it reaches storage. Plaintext never lands in a value column, index key, journal payload, snapshot table or error message. 3. **Opened only on purpose.** Reads return the sealed form. Plaintext requires an explicit, typed `open` effect that needs the codec, and therefore key access, in context. 4. **Honest queries.** Datalog, constraints and derivations either compute the right answer from what the store can compare (presence and keyed equality), or fail during planning with a typed error that names the attribute. They never return a silently wrong answer. ## Quick start ```ts import { Effect, Layer } from "effect"; import { EntityId, KvTriples, Triples } from "@triplex-build/triplex"; import { Attribute, EntityType } from "@triplex-build/triplex/config"; import { KeyProvider, Sensitive, ValueCodec } from "@triplex-build/triplex/sensitive"; const ssn = Attribute.sensitive(Attribute.text(":employee/ssn")); const name = Attribute.text(":employee/name"); const Employee = EntityType.make("Employee", { ssn: Attribute.use(ssn, { unique: true }), name: Attribute.use(name, { required: true }), }); // Decorate any backend's Triples. Test keys are for tests only. const layer = Sensitive.layer({ database: "acme/main", attributes: [Employee] }).pipe( Layer.provide(ValueCodec.aesGcm), Layer.provide(KeyProvider.test({ seed: "test-only" })), Layer.provide(KvTriples.layer), ); const program = Effect.gen(function* () { const triples = yield* Triples; const employee = EntityId.make("employee:1"); yield* triples.assertBatch([ { entityId: employee, attribute: ":employee/name", value: { type: "string", value: "Ada" } }, { entityId: employee, attribute: ":employee/ssn", value: { type: "string", value: "123-45-6789" }, }, ]); // Equality goes through the blind index; the plaintext never reaches storage. const { results } = yield* triples.queryAll({ find: ["?name"], where: [ ["?e", ":employee/ssn", "123-45-6789"], ["?e", ":employee/name", "?name"], ], }); // Reads return sealed values. Opening is explicit and needs the codec. const sealed = yield* triples.entity(employee); const opened = yield* Sensitive.open(sealed); return { results, sealed, opened }; }); export const main = program.pipe(Effect.provide(layer)); ``` ## Model ### Marking an attribute `Attribute.sensitive(definition)` returns a definition with `sensitive: true`. Its config node records `sensitive: true` in its attributes, so the node's content ID changes and every release that adopts it shows a changed attribute node. Non-sensitive attributes keep their existing content IDs because the field is omitted when false. `EntityType` handles expose `sensitive` per attribute. Reference attributes cannot be sensitive. A ref is graph identity, and sealing it would break traversal, reference-target constraints and entity expansion. Blob attributes already store a content hash rather than the content, so they are rejected too. Sensitive values may be `string` (text, date and enum attributes), `number`, `boolean`, `datetime` or `json`. The policy that the runtime enforces comes from one of three sources: - `Sensitive.attributesOf([...])` collects marked definitions and entity types. It also accepts explicit `{ key, valueType }` descriptors. - `Sensitive.attributesFromNodes(nodes)` reads `sensitive: true` attribute nodes, for example every node of a pinned release, so the release is the source of truth. - `Sensitive.layer({ attributes })` accepts either form. ### The sealed value Storage is self-describing. A sealed fact carries the `sealed` variant of `TripleValue`: ```ts interface SealedValue { readonly type: "sealed"; /** The declared plaintext type: string, number, boolean, datetime or json. */ readonly valueType: SealableValueType; /** Codec-owned identity of the key that sealed the value (used by rotation). */ readonly keyId: string; /** Codec-owned envelope, base64url. Opaque to Triplex. */ readonly ciphertext: string; /** Keyed equality token, base64url, absent when the codec has no blind index. */ readonly blind?: string; } ``` Because storage records whether a value is sealed, a reader never has to guess. A policy that no longer lists an attribute still sees older sealed facts as sealed. A sealed fact never becomes plaintext through a configuration change; only an explicit open can produce plaintext. Adding the variant widens `TripleValue`, `ValueType` and `Triple["value"]`. That is an additive data change: databases without sensitive attributes never produce it, and existing rows decode exactly as before. It is still a source-level change for TypeScript code that switches exhaustively over `value.type`, so the Changeset marks the core package minor (the 0.x breaking slot). The in-repository consumers (snapshots, constraints, validation, dashboard, HTTP and CLI) handle the variant explicitly. ### Services | Service | Role | | ------------- | --------------------------------------------------------------------------------------------------------------- | | `ValueCodec` | Seals, opens and blinds one value, given a `SensitiveContext` (database, entity, attribute, value type). | | `KeyProvider` | Supplies per-subject data keys and the per-database blind key to the reference AES-GCM codec. | | `Sensitivity` | The composed runtime capability: policy, codec and database identity. Used by `open`, derivations and overlays. | ```ts interface SensitiveContext { readonly database: string; readonly entityId: string; readonly attribute: string; readonly valueType: SealableValueType; } interface ValueCodecService { readonly name: string; /** Which equal plaintexts share a blind token. */ readonly blindScope: "database" | "attribute" | "none"; readonly seal: (ctx: SensitiveContext, value: PlainValue) => Effect; readonly open: ( ctx: SensitiveContext, sealed: SealedValue, ) => Effect; readonly blind: ( ctx: SensitiveContext, value: PlainValue, ) => Effect; } ``` Codecs shipped in core (`@triplex-build/triplex/sensitive`): - `ValueCodec.aesGcm` (or `ValueCodec.aesGcmWith({ subjectOf, blindScope })`) is the reference implementation over WebCrypto (`crypto.subtle`, available in browsers, Node.js 22 and workerd; core still imports no Node API). It uses AES-256-GCM with a random 96-bit nonce per value. The envelope is a version byte, the nonce and the ciphertext with its tag. The additional authenticated data binds the database, entity, attribute and declared type, so a ciphertext cannot be moved to another fact. The blind token is HMAC-SHA-256 under the database blind key over a domain tag, the scalar family (number and datetime share one) and the canonical JSON plaintext. It is prefixed with the blind key ID. With the default `blindScope: "database"`, equal values in two sensitive attributes of one database join. - `ValueCodec.identity` stores reversible base64url of the canonical plaintext with an unkeyed SHA-256 blind token. It gives development databases the same storage shape and query semantics as production, so turning encryption on swaps the codec rather than the schema. It is **not** confidential. `aesGcm` can still open identity envelopes until they are rewrapped. - `KeyProvider.test({ seed })` and `KeyProvider.testKeys({ seed })` provide deterministic **test keys only**. They support per-subject keys, `rotate()` and `destroy({ database, subject })`. Production hosts implement `KeyProvider` over their KMS. Triplex never persists key material. There is no implicit codec. `Sensitive.layer` requires `ValueCodec` in context, so declaring sensitive attributes without choosing a codec fails to compose. This deliberately departs from an identity-by-default design: forgetting to configure encryption must not store plaintext silently. ### Composition `Sensitive.layer(options)` decorates any `Triples` and outputs `Triples | Sensitivity`: ```ts const layer = Sensitive.layer({ database: "acme/main", attributes: [Employee] }).pipe( Layer.provide(ValueCodec.aesGcm), Layer.provide(hostKeyProviderLayer), Layer.provide(SqliteTriples.layer({ filename: "app.db" })), ); ``` It sits above the storage adapter, so it works the same way over every backend, including Cloudflare and FoundationDB. Every core service that already reads `Triples` from context gets the sealed behavior without new parameters. That includes `ConfigStore`, `EntityValidation`, derivations and the HTTP handlers. A process that opens the raw backend layer without the decorator still cannot read plaintext: it sees sealed values and has no keys. `database` is the stable logical identity given to the codec. It scopes keys and blind tokens and is bound into every ciphertext, so it must not change for the life of the database. ## Semantics ### Writes `transact`, `assert` and `assertBatch` (which delegate to it): - seal every `assert` whose attribute is sensitive, after checking the value against the declared type (number and datetime are interchangeable), and pass only the sealed value to the inner store; - reject a caller-supplied `sealed` value for any attribute, except when the same transaction explicitly retracts a fact by ID on the same entity and attribute whose stored envelope and blind token are byte-identical. The layer verifies that fact through the inner `Triples.get`; a pattern retraction does not qualify. This lets `Change.end` and `Change.replace` preserve a sealed past without opening it while rejecting foreign ciphertext and forged blind tokens; - rewrite `retract-pattern` value constants on sensitive attributes to blind matches; - reject unique enforcement on a sensitive attribute when the codec has no blind index. Write-side failures surface as `WriteError` whose `cause` is a typed `SensitiveValueError`. It carries `attribute`, `reason` (`sealed-input`, `invalid-value`, `codec`, `key-unavailable`, `key-destroyed`, `integrity` or `blind-index-unavailable`) and a message that never contains the value. This keeps the `Triples` write signature unchanged. Plaintext exists only in the caller's request and in the codec call. The commit's `TransactionResult.triples` already carry the sealed values. Use [the temporal verbs](/concepts#end-a-fact-at-a-time) with sealed facts just as with plaintext facts. `end` reuses the verified ciphertext for the shortened interval; `replace` does the same for the past and seals its new plaintext value from the split onward. `correct` seals its plaintext replacement over the original interval. Submit the generated `TripleLive` preconditions with the operations so a concurrent retraction fails with `TransactionConflictError`. ### Reads `get`, `entity`, `entities`, `entityPage`, `match`, `history`, `transaction`, `transactions`, `transactionByCommand` and `transactionsForEntity` return stored sealed values unchanged. The journal's `:_tx/change` payload embeds the same sealed value, so history and "what changed" reads show that a sensitive fact changed, by whom and when, without its plaintext. Entity snapshots commit to the sealed envelope. `match` with a value on a sensitive attribute is rewritten to a blind-token lookup, so "which person has this SSN?" is an indexed equality read. A value of a type that can never equal the declared type matches nothing, as it would in plaintext. A value-only `match` (no attribute) fails with `ReadError` whose cause is `SensitiveQueryError` (`value-only-match`) when the policy is non-empty, because it could silently miss sealed facts. Opening is explicit: ```ts const reveal = (personId: EntityId) => Effect.gen(function* () { const triples = yield* Triples; const facts = yield* triples.entity(personId); return yield* Sensitive.open(facts); // fails with SensitiveValueError; requires Sensitivity }); ``` `Sensitive.open` accepts a triple, an array of triples or a `TransactionRecord`, and leaves non-sealed values untouched. It is the single place where Triplex produces plaintext, so hosts authorize and audit this call. ### Datalog The decorator validates and analyses every `query`, `queryAll`, `queryPage`, `explain` and `explainPage` call against the policy before the inner engine runs. A variable is _sensitive_ when it occurs in the value position of a pattern on a sensitive attribute. | Use | Result | | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Presence (`[?e ":person/ssn" ?_any]`) and its negation | Allowed. | | Constant in the value position | Allowed. Rewritten to a blind-token constant (`{ type: "blind", value }`) and answered from the blind index. A constant whose scalar family cannot equal the declared type matches nothing. | | Join between two sensitive value positions | Allowed with a blind index, within one attribute or across attributes when the codec's `blindScope` is `database`. | | Join with a plaintext value or an identity position | `SensitiveQueryError` (`join`). | | Projection in `find`, `optionalProjection` or wrapper filters | `SensitiveQueryError` (`projection`). | | `orderBy` (query or wrapper) | `SensitiveQueryError` (`order`). | | Comparison or equality predicate | `SensitiveQueryError` (`predicate`). Use a pattern constant for equality. | | Aggregate source or `having` | `SensitiveQueryError` (`aggregate`, `having`). | | Rule bodies and rule arguments | `SensitiveQueryError` (`rule`). | | Variable attribute whose value is used or constrained | `SensitiveQueryError` (`dynamic-attribute`), because the analysis cannot prove the attribute is not sensitive. | | Blind-dependent use when the codec's `blindScope` is `none` | `SensitiveQueryError` (`blind-index-unavailable`). | `SensitiveQueryError` is a member of `DatalogQueryError`. It carries `attribute` and `operation`, and its message names the attribute and never a value. Full-text search is not part of Triplex Datalog today. When the roadmap's `fts` predicate lands, it must reject sensitive variables through the same analysis. As defense in depth, the engines enforce the same boundary without the decorator. A raw Datalog read projects a sealed binding as `null`. It never matches a sealed value against a plaintext constant or binding, joins sealed values only on equal blind tokens, and excludes them from rule closure. A sealed value without a blind token equals nothing. KV, SQLite, PostgreSQL and the Durable Object backend agree on this in the shared `sealedStorageCases`. ### Constraints and validation - **Required** and **cardinality** count facts and work unchanged. - **Unique** compares the blind token (`sealed:`) instead of the canonical value. Without a blind index, a transaction that enforces a unique rule on a sensitive attribute fails before mutation; the rule is never skipped. - **Reference-target** cannot apply, because ref attributes cannot be sensitive. - Violation messages omit values, and `valueIdentity` for a sealed value is the blind token. - `EntityValidation` checks the presence and cardinality of sealed fields. Their plaintext type was enforced when they were sealed. The committed state CID covers the sealed envelope. ### Derivations and overlays Derivations plan their query through `Sensitivity` when it is in context. A derivation that projects, orders or compares a sensitive value fails with `SensitiveQueryError` when it is evaluated. Derivations may use sensitive attributes for presence and blind equality, for example "employees without an SSN on file" or "applicants whose SSN matches an employee". Sensitive value variables are withheld from the internal provenance projection. Candidate provenance still records every sealed source fact by triple ID, never a plaintext value. `evaluateOverlay` seals hypothetical assertions on sensitive attributes before seeding its in-memory store, so they compare exactly like committed facts and never reach its indexes in plaintext. A hypothetical fact's content identity commits to its blind token, not to plaintext or randomized ciphertext. `ConfigRuntime` evaluation reads scalar facts into a `World`. A rule that reads a sealed fact fails with `UnsupportedDecisionFactError`. A rule such as "older than 18" from a sealed birth date needs an explicit host decision to open the value, which is future work. ### Surfaces | Surface | Default | | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | HTTP | Entity reads return sensitive fields as `{ "$sealed": { "valueType", "keyId" } }`, without ciphertext or blind token. Writes accept plaintext and seal through the decorated `Triples`. A write to a sensitive attribute fails closed when `Sensitivity` is absent. There is no plaintext read route; hosts call `Sensitive.open` in their own handler after authorization. | | CLI | Entity, history, journal and query output prints sealed values as `{ "type": "sealed", "valueType", "keyId" }`. The CLI has no key access and no open command. | | Dashboard | Sealed values render as `sealed · ` and cannot be edited in form mode. | "Never plaintext by accident" holds structurally: each surface sees only what `Triples` returns, and `Triples` returns sealed values. ## Storage All backends store the same logical sealed value. The ciphertext and blind token are kept in dedicated places, and the plaintext value columns stay empty. | Backend | Ciphertext | Blind token | Index | | -------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | SQLite, PostgreSQL | `value_sealed TEXT` (JSON envelope: value type, key ID, ciphertext) | `value_blind TEXT` | `idx_attr_blind ON triples(attribute, value_blind) WHERE value_type = 'sealed'` (SQL migration v3) | | Cloudflare Durable Object | Same columns | Same column | Same index (Cloudflare migration v4) | | In-memory KV, FoundationDB | `META` record value tag 7 (envelope and optional blind) | Tuple tag `0x08` over `blind:`, or `opaque:` without a blind index, in EAVT, AEVT and AVET | AVET on `(attribute, sealed token)` | SQL migration v3 admits `sealed` in the `value_type` check, adds both columns and the index, and requires an envelope on sealed rows. PostgreSQL replaces the check constraints in place. SQLite cannot alter a check constraint, so v3 rebuilds `triples` with SQLite's create-copy-drop-rename procedure and recreates every index. The rebuild is proportional to the table size. Existing rows are copied unchanged. `runMigrations` now applies each migration and its applied record in one transaction. The exported `Migration` gained optional per-dialect statements (`dialects.sqlite`, `dialects.pg`) that run before the portable `up` statements. Hosts that execute migrations themselves must run their dialect's statements first. The columns are `TEXT` (base64url) rather than `BLOB`/`bytea` so one DDL serves SQLite, PostgreSQL and Durable Object SQL, and the KV and journal JSON encodings stay identical. The cost is about a third more space per sealed value. Every backend runs a planted-plaintext test. It writes a high-entropy marker into sensitive attributes through `ValueCodec.aesGcm`, runs the decorator corpus, then scans raw storage for the marker in every encoding Triplex uses (UTF-8, JSON-escaped and base64url). The corpus includes the journal, command receipts, a uniqueness violation and planning errors. Each scan includes a positive control that finds a non-sensitive plaintext value. - SQLite: every row of every table, plus the raw bytes of the database, WAL and shared-memory files after close. - PostgreSQL: every row of every table in the database schema, plus index definitions. - In-memory KV: every key and value. - FoundationDB: every application key and value in an isolated native test subspace; this does not scan server storage files or unrelated subspaces. - Cloudflare Durable Object SQLite: every row of every table. ## Erasure (crypto-shredding) Design, partially built: - `KeyProvider` is keyed by **subject**. `ValueCodec.aesGcmWith({ subjectOf })` maps a value to its subject, which defaults to the entity ID. A host may map related entities, such as a person's documents, to the person. - Erasing a subject destroys its data keys in the key provider. Facts, positions, journal entries and sealed envelopes remain, so history stays complete and audit queries keep working. The ciphertext can no longer be opened. - Reads after erasure return the same sealed values. `Sensitive.open` fails with `SensitiveValueError` (`key-destroyed`). `Sensitive.open(facts, { erased: "keep-sealed" })` leaves erased values sealed instead, so a UI can render "erased" without failing the read. - **Blind tokens must be scrubbed too.** A blind token keyed per database survives a destroyed data key, so someone holding a candidate value could still confirm it. Erasure therefore also clears the subject's blind tokens (`value_blind`, and the KV blind index entries) through an unjournaled maintenance operation, like checkpoint maintenance. It also records one journaled erasure fact that names the subject and the time but no attribute or value. After scrubbing, Datalog presence still matches erased facts, while equality, joins and uniqueness no longer do, so a unique value whose holder was erased becomes reusable. - Derivations over erased subjects keep their committed provenance. Re-evaluation sees presence but no equality matches, which is the correct post-erasure answer. Built: per-subject keys, key destruction in the test provider, typed `key-destroyed` opening and the `keep-sealed` mode. Not built: the storage-level blind scrub and the erasure fact. Both need a new optional `StorageAdapter` maintenance method on the in-memory KV, SQLite and PostgreSQL backends; Cloudflare and FoundationDB are frozen and need not implement it. ## Key rotation Design, partially built: - **Data keys.** Each envelope records its key ID. `KeyProvider.sealingKey` returns the current key for new seals and `KeyProvider.openingKey(context, keyId)` resolves historical keys, so rotation needs no data change. The test provider's `rotate()` demonstrates this and is covered by tests. Re-encryption under a new key (_rewrap_) rewrites `value_sealed` for an existing fact ID in place. The fact's meaning, valid time, transaction and position do not change, so transaction history is untouched. Rewrap is an unjournaled maintenance operation with a durable checkpoint, so it resumes after interruption. Journal `:_tx/change` payloads hold their own copy of the sealed value. Rewrap either rewrites them too or keeps old key IDs available for opening; the reference provider does the latter. - **Blind key.** Rotating the blind key changes every token. Tokens already carry the blind key ID as a prefix. During rotation the decorator would expand each equality constant to both key versions and compare joins within one version. A background rewrap recomputes tokens, which requires opening every value. Uniqueness enforcement compares both versions until the rewrap completes. - Not built: the rewrap maintenance operation (the same adapter extension as the blind scrub) and dual-version blind lookups. ## Backends | Backend | Storage | Query rules | Conformance | | ------------ | ------------------------ | ----------- | --------------------------------------------------------------------------------------------- | | In-memory KV | Yes | Yes | Sealed-storage and decorator corpora, full corpus through the decorator, raw scan | | SQLite | Yes (migration v3) | Yes | Same, plus file scan and the v1 to v3 upgrade | | PostgreSQL | Yes (migration v3) | Yes | Same in the opt-in integration suite, plus the v1 to v3 upgrade | | Cloudflare | Yes (migration v4) | Yes | Same in the native-SQL harness; both corpora in workerd | | FoundationDB | Yes (shared KV encoding) | Yes | Native sealed/decorator corpora, full Triples corpus through the decorator, raw subspace scan | The shared corpora are exported from `@triplex-build/triplex/testkit` as `sealedStorageCases`, `sensitiveConformanceCases`, `sensitiveConformanceLayer` and `findPlaintext`. ## Proposed host-sealing integration The [host-sealed values RFC](rfcs/tx3-host-sealed-values.md) proposes an explicit trusted-host import contract and compares a custom KeyProvider alternative. It requires an owner decision and changes no current behavior. Raw storage already round-trips sealed envelopes; the Sensitive decorator continues to reject them. ## Out of scope - Range, ordering, prefix and full-text queries over sealed values. Order-preserving or searchable encryption leaks far more than a keyed equality token and is not a general-purpose default. - Field-level authorization. Whether a caller may call `Sensitive.open` is a host decision; Triplex provides the single choke point. - Client-side sealing. Callers cannot submit pre-sealed values. - Encrypting attribute names, entity IDs, valid times or transaction metadata. Only values are sealed, so entity IDs must not embed sensitive data. - Length hiding. AES-GCM ciphertext length follows plaintext length; a host codec may pad. - Opening sensitive values inside `ConfigRuntime` rule evaluation. - Checking that a write's policy matches its pinned `configSnapshot`. Hosts derive the policy from the release with `Sensitive.attributesFromNodes`. ## Related work - Shared catalogs across tenant databases: sensitivity is part of the attribute node, so a shared catalog carries it to every tenant. Key material stays per database (the codec's `database`), and blind tokens never join across databases. - Commit validators: a validator running inside the inner store sees only sealed values. A validator that needs plaintext must run above the decorator or call `Sensitive.open`, and must not echo values into its error messages. ## Implementation status Built: marking, the sealed value type, the identity and AES-GCM codecs, the test key provider with rotation and destruction, the `Triples` decorator for writes, reads, the journal and Datalog planning, blind equality in the KV and SQL engines, storage migrations for SQLite, PostgreSQL and Cloudflare, constraint and validation support, derivation and overlay support, surface defaults, shared conformance corpora, and raw-storage scan tests. Designed but not built: blind-token scrubbing and the erasure fact, rewrap, blind-key rotation, and opening sealed values in `ConfigRuntime`. --- # Commit validators Status: implemented and covered by shared conformance on KV, SQLite, PostgreSQL, and the experimental Cloudflare backend. FoundationDB uses the same KV code path, but its native suite remains opt-in. Pre-1.0: the contract may still change before a stable release. A commit validator is host code that runs **inside** a Triplex transaction. It runs after the transaction's operations and journal envelope are applied and before the commit. It reads the database exactly as it will look if the transaction commits, and it can reject the whole transaction with typed, structured findings. ## Problem `Triples.transact` already protects two kinds of invariant atomically: - **Concurrency.** Compare-and-retract and entity-state preconditions check that the facts a command read are still the live facts. - **Structure.** `enforce` rejects new or worsened required, cardinality, uniqueness, and reference-target violations. Everything else was "host-owned", but hosts had no in-transaction seam. Examples: - "Only the employee may assert their own Section 1 facts." - "A signature may not cover a document that expired before the signing instant." - "A payroll import may not run before Section 2 is complete." Without a seam, a host checks these before `transact` and races concurrent writers. It cannot see the facts that its own command is about to write, so some rules can't be checked at all. ## API ```ts import { Effect } from "effect"; import { CommitValidation } from "@triplex-build/triplex"; const sectionOneOwner = CommitValidation.make("i9.section1-owner", (commit) => Effect.gen(function* () { // commit.transaction: attempted txId, instant, actor, commandId, correlationId, ... // commit.changes: resolved assert/retract changes, exactly as they would be journaled // commit.before: read view at the last committed position // commit.after: read view including this transaction's writes return [{ code: "not_asserter", message: "...", entityId, attribute }]; }), ); ``` ### The validator contract `CommitValidation.make(name, validate)` returns a `CommitValidation.Validator`. The `validate` function receives a `CommitValidation.CommitContext`: | Field | Contents | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `transaction` | The attempted `txId`, `instant`, `basisPosition`, `actor`, `commandId`, `correlationId`, `causationId`, `configSnapshot` | | `changes` | The resolved `TransactionChange` list (asserts and retracts with triple ID, entity, attribute, typed value, entity type, valid interval). This is the same list the journal records. | | `before` | A read view at `basisPosition`, the last committed position | | `after` | A read view at the attempted position, which includes this transaction's facts and its `_Transaction` envelope | Each view has `entity`, `entities`, `match`, and complete Datalog `query`. These fact reads accept `{ validAt }`, which defaults to the transaction instant. The recorded axis is fixed by the view. `view.query` returns the same `QueryResponse` as `Triples.queryAll`: read the bindings from `response.results`. It returns every result without a continuation cursor. Views also expose read-only `operationalEntry(id)` and `pendingOperationalEntries(kind, { scope?, limit?, cursor? })`. These reads use the view's exact commit position and have no valid-time axis. `after` includes inbox and outbox entries appended by `append-entry`, and acknowledgements made by `acknowledge-entry`. `before` hides new entries and shows acknowledgement targets as they were before this attempt. Pending reads are paged; a cursor belongs to its view position and filter. Follow `nextCursor` to inspect further entries. Validators cannot append or acknowledge entries. Rejecting an outgoing intent rolls back the entry and every acknowledgement along with the facts and receipt. `validate` returns rejections, or nothing to accept the transaction. It may fail with `ReadError` or a Datalog query error. A rejection has this shape: ```ts interface Rejection { readonly code: string; // stable machine code, e.g. "not_asserter" readonly message: string; // human-readable explanation readonly entityId?: EntityId; // subject of the finding readonly attribute?: string; readonly path?: readonly (string | number)[]; // e.g. a form field path readonly details?: { readonly [key: string]: unknown }; // JSON evidence readonly suggestion?: string; // what the caller could do instead } ``` If any validator returns a rejection, `transact` fails with `CommitRejectedError`. The error carries: - `rejections`: every finding, each stamped with its `validator` name; - `validators`: the names of the validators that ran; - `attempt`: the attempted envelope; and - `changes`: the attempted changes. The facts, operational entries and acknowledgements, journal envelope, command receipt, and commit position all roll back. ### Two ways to install validators 1. **Per call:** `transact(ops, { validate: [v1, v2] })`. This is the in-transaction mechanism that every backend implements, alongside `enforce` and `preconditions`. It suits command-specific guards such as "this import requires Section 2 to be complete". 2. **Runtime-wide:** `CommitValidation.layer({ validators }).pipe(Layer.provide(backendLayer))`. This decorates any `Layer` so that every application transaction runs the validators. That includes `assert`, `assertBatch`, `retract`, `retractByPattern`, and writes made by the HTTP package or other `Triples` consumers. The decorator also owns rejection auditing. Why both? An authorization rule that you opt into per call is easy to forget on one write path, so authorization belongs in the runtime-wide layer. A command-specific guard is part of that command, so it belongs on the call. Both use the same contract and run in the same place. The layer captures its validators' requirements `R` once, at layer construction. A validator can depend on host services (a policy table, for example) without changing the `R = never` signature of `transact`. Services in the calling fiber take precedence over captured ones, so transaction-local state such as Effect SQL's transaction connection is never replaced. Per-call validators must already be closed, with `R = never`. Compose the layer with `Layer.provide(backend)`. Use `Layer.provideMerge(backend)` when the host also needs the backend's `SqlClient`; the decorated `Triples` is the one exposed either way. Why not a backend constructor option? A decorator works with every shipped and custom `Layer`, including the ambient PostgreSQL client, `Runtime.define`, and Cloudflare. It needs no change to each backend's layer signatures. The in-transaction part is plain `TransactionMeta` that every implementation honors. ## Semantics ### Ordering Inside the backend's serialized transaction boundary: 1. Enter the serialized transaction boundary and check reserved namespaces and input shapes. 2. Claim the command ID and allocate the commit position, the serialization point. 3. Check `TripleLive`, cheap attribute-value/absence, and `EntityState` preconditions. 4. Apply operations: assert, retract, `append-entry`, and `acknowledge-entry`. 5. Evaluate `enforce` graph constraints against the projected post-state, retaining the pre-state baseline so unchanged legacy violations still permit repairs. 6. Write the `_Transaction` journal envelope. 7. **Commit validators**: runtime-wide validators first, then per-call validators, each in declaration order. 8. Commit. Preconditions run first, so a stale command fails as a retryable `TransactionConflictError`, not as a business rejection made against data that has since changed. Structural constraints run next because they are cheap and declarative, so validators can assume a structurally valid post-state. Validators run last so that `after` is exactly the state that would commit, journal envelope included. ### Composition and determinism - Validators run sequentially in a fixed order. Every validator runs even after an earlier one rejects, so the error and the audit record contain all the evidence. Rejections keep validator order, then the order each validator returned them. - If a validator **fails** (a `ReadError`, a Datalog error, or a malformed rejection), the remaining validators are skipped. The transaction fails with a `WriteError` that names the validator. A validator that can't decide never accepts the transaction (fail closed). - Validators should be deterministic functions of their `CommitContext`. Use `transaction.instant` for "today", not a wall clock. A host retry after a conflict runs them again against the new state. - Validators run under the backend's commit serialization, the same contention point that makes `enforce` safe. A validator's reads therefore can't race another Triplex writer. ### What validators may not do Validators are **read-only**. Each view exposes only reads, and there is no "also assert" channel, for these reasons: - The journal's `changes` must be the complete, final description of the transaction. Writes from a validator would need their own journal entries, `enforce` pass, and validation, which makes a fixpoint. - If one validator could write, the next validator's input would depend on validator order. - Anything a validator would derive can be computed by the command before `transact`, where it becomes part of the ops, the journal, and the validated state. On SQL backends a validator could still reach the ambient `SqlClient` and write host rows. That is outside the contract. Put host writes around `transact` (see [atomic host writes](#atomic-host-writes)). ### System writes The runtime-wide layer doesn't run validators on Triplex's own reserved-namespace writes: `ConfigStore`, `EntityValidation`, derivation materialization, consumer checkpoints, and rejection records. Triplex owns those invariants, and they never write application facts. To govern who may publish a release, check that at the host call site. ### Authorization `transaction.actor` is a string the caller supplies. A validator's authorization decision is only as trustworthy as the host's authentication of that actor. Set it from an authenticated principal. Triplex **doesn't** ship a declarative asserter rule in configuration. Real asserter rules are relational. "The actor is the entity's employee" or "the actor holds an HR role in this employer" need reads against the database, not a static grant list. The roadmap also keeps authorization above `Triples` until there is a complete threat model and a portable write-policy contract. The [authorization example](#example-per-attribute-asserter-rules) shows that an asserter policy is a short validator. It evaluates relationships in `before`, so a transaction can't grant itself permission in the same commit. ## Rejection audit A rejection rolls back the transaction's facts, but auditors still need "we refused this, and here's why". There are two ways to keep a record, and both run **after the rollback**: - **Built-in record:** `CommitValidation.layer({ validators, recordRejections: true })`. This writes one reserved `triplex.commit-rejection` entity under `_triplex/commit-rejection/…`. The entity holds the attempted envelope, validator names, findings, and attempted changes as JSON values. The error returned to the caller carries `recorded: { entityId, txId }`. Read a record back with `CommitValidation.readRejection` or with Datalog over the reserved attributes. - **Host callback:** `onRejected: (error) => Effect` writes to a host table or metrics. Both cover every rejection on a transaction that goes through the layer, from runtime-wide and per-call validators alike. Without the layer, or when the host rolls back an enclosing transaction (see below), call `CommitValidation.recordRejection(triples, error)` yourself after the rollback. Recording is idempotent per attempted transaction. A record can't be mistaken for committed facts: - It lives in the reserved namespace, which ordinary writes can't target. - The attempted changes are stored only as JSON payloads on the record entity, never as facts on the target entities. - The attempted `txId` never becomes a `_Transaction`, so `Triples.transaction(attemptTxId)` returns `null`. - The record's own journal envelope names only the record entity as changed. The record is written by its own system transaction. It appears in the ordered feed as a system change to `_triplex/commit-rejection/…` and does not reuse the rejected command ID, so the command can be retried. **Ambient SQL caveat.** If a host calls `transact` inside its own `sql.withTransaction`, the rejected Triplex transaction is a savepoint. A built-in record written after the savepoint rollback still belongs to the host's outer transaction, and the host's rollback discards it. Such hosts should leave `recordRejections` off. Instead, they catch `CommitRejectedError` after their outer rollback and call `recordRejection`, or write their own table. ## Atomic host writes PostgreSQL (`PgTriples.layerFromSqlClient`) and SQLite (`SqliteTriples.layer*`) share Effect SQL's fiber-local transaction with the host. Host rows written inside `sql.withTransaction` around `Triples.transact` (outbox, timers, process events) commit or roll back with the facts. That includes a validator rejection: the rejection fails the inner effect, and the outer transaction rolls back the host rows. Tests for both backends cover this. KV backends have no host tables. ## Backends | Backend | Validator semantics | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | In-memory KV | Same as all backends. Views read the transaction's buffered writes through the hexastore indexes. | | SQLite | Same. Views and Datalog run on the transaction connection with an exact commit-position cut. | | PostgreSQL | Same, including the ambient-client and database-scoped layers. | | Cloudflare | Same (experimental). Validators **must be synchronous**: the Durable Object `transactionSync` boundary runs the transaction with `Effect.runSyncExit`. | | FoundationDB | Same code path as KV (experimental). Validator reads add read-conflict ranges, and their time counts toward FoundationDB's 5-second transaction limit. | The shared `@triplex-build/triplex/testkit` conformance cases cover validators. They run on KV, SQLite, PostgreSQL, and both Cloudflare harnesses. ### Performance Views never materialize a snapshot. Reads use the same indexed paths as ordinary reads, at an exact commit cut: entity lookups, the KV type index, and SQL `recorded_position` predicates. Datalog compiles to SQL or runs on the KV executor in the transaction, at that same cut. Validators run while the transaction holds the commit serialization point, so their latency adds directly to write latency and limits write throughput. Keep them to indexed point reads and narrow Datalog. ## Interaction with other features - **Datalog.** `view.query` is complete (not paged) Datalog at one commit cut. `after` includes the attempted `_Transaction` envelope, so rules can join `:_tx/actor` for this transaction. - **Derivations.** Not run automatically. A validator can run a derivation's query through `view.query`. - **History and the journal.** Rejected transactions leave no facts, envelope, position, or command receipt. Committed history is unchanged. - **Subscriptions.** Rejected transactions emit nothing. - **`enforce`.** Runs after operations and before validators. - **Operational entries.** Entry operations join the atomic transaction but are separate from the triple-only `changes` list. Inspect their payloads and acknowledgement state through the operational view reads. ## Out of scope - Validator writes, or an "also assert" channel (see above). - Records of the checks that passed on committed transactions. A host that needs them can add check facts to its ops. - A declarative asserter grammar in configuration. - Async validators on Cloudflare. - Crash-injection hooks such as `beforeCommit`. Hosts can inject faults by failing a validator in tests. ## Example: per-attribute asserter rules This policy says who may assert each governed attribute. The employee named on an I-9 may sign Section 1, and an actor with the `hr` role may complete Section 2. Relationships are read from `before`, so a transaction that sets `:i9/employee` to its own actor cannot also sign in the same commit. The layer installs the policy for every write path and records each refusal durably. ```ts import { Effect, Layer } from "effect"; import { CommitRejectedError, CommitValidation, EntityId, KvTriples, Triples, ref, string, } from "@triplex-build/triplex"; type Grant = | { readonly kind: "subject"; readonly via: string } | { readonly kind: "role"; readonly role: string }; const grants: Readonly> = { ":i9/section1-signature": { kind: "subject", via: ":i9/employee" }, ":i9/section2-signature": { kind: "role", role: "hr" }, }; const asserters = CommitValidation.make("i9.asserters", (commit) => Effect.gen(function* () { const actor = commit.transaction.actor; const rejections: CommitValidation.Rejection[] = []; for (const change of commit.changes) { const grant = grants[change.attribute]; if (change.op !== "assert" || grant === undefined) continue; let allowed = false; if (actor === undefined) { allowed = false; } else if (grant.kind === "subject") { const subjects = yield* commit.before.match({ entityId: change.entityId, attribute: grant.via, }); allowed = subjects.some((fact) => fact.value.type === "ref" && fact.value.value === actor); } else { const roles = yield* commit.before.match({ entityId: EntityId.make(actor), attribute: ":person/role", }); allowed = roles.some( (fact) => fact.value.type === "string" && fact.value.value === grant.role, ); } if (!allowed) { rejections.push({ code: "not_asserter", message: `${actor ?? "An anonymous actor"} may not assert ${change.attribute}`, entityId: change.entityId, attribute: change.attribute, details: { actor: actor ?? null, grant }, }); } } return rejections; }), ); const Database = CommitValidation.layer({ validators: [asserters], recordRejections: true, }).pipe(Layer.provide(KvTriples.layer)); const i9 = EntityId.make("i9:alice"); const sign = (attribute: string, actor: string) => Effect.gen(function* () { const triples = yield* Triples; return yield* triples.transact( [{ op: "assert", entityId: i9, entityType: "I9", attribute, value: string(actor) }], { actor, commandId: `${attribute}:${actor}` }, ); }); const program = Effect.gen(function* () { const triples = yield* Triples; yield* triples.transact([ { op: "assert", entityId: EntityId.make("person:hank"), attribute: ":person/role", value: string("hr"), }, { op: "assert", entityId: i9, entityType: "I9", attribute: ":i9/employee", value: ref(EntityId.make("person:alice")), }, ]); yield* sign(":i9/section1-signature", "person:alice"); yield* sign(":i9/section2-signature", "person:hank"); const refused = yield* sign(":i9/section1-signature", "person:hank").pipe(Effect.flip); if (refused instanceof CommitRejectedError && refused.recorded !== undefined) { const audit = yield* CommitValidation.readRejection(triples, refused.recorded.entityId); yield* Effect.log(audit?.rejections[0]?.code); // "not_asserter" } }); Effect.runPromise(program.pipe(Effect.provide(Database))); ``` ## Example: a command-specific guard Per-call validators suit rules that belong to one command. This import refuses to run until Section 2 is complete, checked against the state that would commit. A later write cannot slip in between the check and the commit. ```ts import { Effect } from "effect"; import { CommitValidation, EntityId, Triples, number } from "@triplex-build/triplex"; const sectionTwoComplete = (i9: EntityId) => CommitValidation.make("payroll.requires-section2", (commit) => Effect.gen(function* () { const response = yield* commit.after.query({ find: ["?signature"], where: [[i9, ":i9/section2-signature", "?signature"]], }); return response.results.length > 0 ? [] : [ { code: "section2_incomplete", message: "Payroll import requires a completed Section 2", entityId: i9, suggestion: "Complete Section 2, then retry the import", }, ]; }), ); export const importPayroll = (employee: EntityId, i9: EntityId, rate: number) => Effect.gen(function* () { const triples = yield* Triples; return yield* triples.transact( [{ op: "assert", entityId: employee, attribute: ":payroll/rate", value: number(rate) }], { actor: "service:payroll-import", validate: [sectionTwoComplete(i9)] }, ); }); ``` ## Example: host rows in the same SQL transaction On PostgreSQL (`PgTriples.layerFromSqlClient`) and SQLite, host rows and Triplex facts share one Effect SQL transaction. If a validator rejects, the outbox row rolls back with the facts. Because this host rolls back its own enclosing transaction, it records the rejection afterwards rather than enabling `recordRejections`. ```ts const command = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const triples = yield* Triples; return yield* sql .withTransaction( Effect.gen(function* () { const tx = yield* triples.transact(ops, { actor, commandId }); yield* sql`INSERT INTO host_outbox (id, body) VALUES (${tx.txId}, 'notify')`; return tx; }), ) .pipe( Effect.tapError((error) => error instanceof CommitRejectedError ? CommitValidation.recordRejection(triples, error) : Effect.void, ), ); }); ``` Rejection records are ordinary reserved facts, so Datalog can list them: ```ts const refusals = Effect.gen(function* () { const triples = yield* Triples; return yield* triples.queryAll({ find: ["?record", "?actor", "?finding"], where: [ ["?record", ":triplex/commit-rejection-actor", "?actor"], ["?record", ":triplex/commit-rejection-finding", "?finding"], ], }); }); ``` --- # 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. - `recordedPosition` selects an exact commit cut for entity and collection reads. It must be a non-negative safe integer; zero is the empty cut. Do not combine it with `recordedAt`. Temporal query parameters are rejected on writes. Use the returned collection cursor to continue at the same cut after later commits. 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 envelopes `{ "error": Diagnostic }`, for example: ```json { "error": { "code": "http.invalid-request", "message": "limit must be an integer between 1 and 200", "path": "$.limit", "fix": "Correct the named request field using the OpenAPI schema.", "retryable": false } } ``` The OpenAPI error schema includes `code`, `message`, `retryable`, and optional `path`, `fix`, and `details`. Core failures retain their core codes; HTTP-owned errors use `http.*`. See [Error diagnostics](/errors) for every code. Retryable transaction conflicts include `Retry-After: 0`; reload current facts before retrying the whole command. 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`. A write refused by a runtime-wide [commit validator](/commit-validators) is `409` with code `transaction.commit-rejected`. 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 and ship in the single `@triplex-build/triplex` package. The dashboard includes prebuilt static assets. The experimental [MCP server](/mcp) exposes the CLI's implementation as stdio tools for agent clients. It serves one database per process, advertises input schemas, requires an actor and command ID on writes, and returns the same data and runtime diagnostics as the CLI. ## 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 triplex() { node --disable-warning=ExperimentalWarning packages/triplex/dist/bin.js "$@"; } ``` The examples below use the repository command. Install `@triplex-build/triplex` from npm to use the same `triplex` binary in an application: ```sh npm install @triplex-build/triplex effect @effect/sql-sqlite-node \ @effect/platform-node @effect/platform-node-shared triplex --sqlite ./app.db describe triplex --sqlite ./app.db status ``` `describe` returns the machine-readable command manifest in `data.commands` and the schema from `live` in `data.schema`. Use `describe --ref preview` to inspect another release ref. The schema includes release identity (`id`, `label`, `rootContentId`, `ref`), entity types with their attribute value types (including enum members), requiredness, cardinality, uniqueness, reference targets and sensitivity, every declared attribute including unused ones, constraint keys and kinds, and configuration/derivation kinds and keys. `schema.examples` contains one `{ entityType, input }` per type. Save its `input` as JSON for `transaction apply --input `. Each example asserts required attributes and sets `entityType` on every fact, `meta.actor`, `meta.commandId`, and the release's `meta.configSnapshot` and `meta.enforce`. Replace synthetic placeholders such as `""`, `""`, `""`, and `""` before submitting. Reference targets must already exist and be valid at commit time; use fresh values for unique attributes. Numbers, booleans, dates, instants and enums start with `0`, `false`, `"2000-01-01"`, epoch `0`, and the first enum member. Optional-only types get one attribute assertion so they exist as facts. `schema.rules` repeats the essential write rules: one command per transaction, deterministic command IDs, pinning and enforcing the same release, one interval per entity, and retracting/re-asserting to end a value. When no release exists at the requested ref, `data.schema` is `null` and `data.hint` explains how to publish one with application-owned `config.commit` or move an existing release with `config set-ref`. Successful CLI commands emit one `{ "ok": true, "command": "…", "data": … }` JSON value to stdout. Decode/runtime failures emit an `{ "ok": false, "command": "query.run", "error": { "code": "datalog.unbound-variable", "message": "…", "path": "find", "fix": "Bind the variable in a positive pattern or rule application before using it.", "retryable": false } }` envelope to stderr and exit nonzero. Branch on `error.code`, correct `error.path` using `error.fix`, and reload facts before retrying a diagnostic with `retryable: true`. See [Error diagnostics](/errors). Add `--debug` to include the original `cause` string (which may contain input details). 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. ## Explain and preview ```sh triplex --sqlite ./app.db why fact 01ARZ3NDEKTSV4RRFFQ69G5FAV triplex --sqlite ./app.db why attribute worker:maria :worker/name triplex --sqlite ./app.db why entity worker:maria --recorded-position 12 --valid-at 1788998400000 triplex --sqlite ./app.db why entity worker:maria --causation-depth 3 triplex --sqlite ./app.db why candidate --input candidate.json triplex --sqlite ./app.db transaction preview --input command.json triplex --sqlite ./app.db transaction apply --input command.json ``` `why` runs the core [explanation service](/why). Stored subjects accept `--valid-at`, `--recorded-at` or `--recorded-position`, and `--causation-depth` (0–32). Recorded timestamp and position are mutually exclusive. An exact fact explanation includes its retracting transaction; an entity or attribute explanation returns visible facts at the selected cut. Candidate input is `{ "subject": { "kind": "candidate", "definition": …, "candidateId": …, "revision": … }, "basis": { "validAt": …, "recordedPosition": … }, "causationDepth": 0 }`. Supply the full immutable definition returned by `Derivation.make`, plus the candidate's ID and revision, and an explicit valid and recorded basis. Candidate flags can override input options. The MCP `why` tool uses this same input schema for all four subject kinds. `transaction preview` accepts exactly the same input as apply, including required `meta.actor` and `meta.commandId`. It returns `release`, `operations` (`ops` and `preconditions`), `changes`, `violations`, and an empty `derivations` list. Preserve `meta.configSnapshot` and `meta.enforce` from describe: both preview and apply use the supplied enforcement, and a release pin alone does not enforce constraints. If the command ID is already committed, preview returns `commandAlreadyCommitted` with the receipt and empty operations and changes. Preview does not write facts, receipts or journal positions. It is advisory: it does not check preconditions, run commit validators or reserve state against other writers. Commit checks the then-current state. Assertion IDs and commit metadata are assigned only on commit. Omitted `validFrom` uses the preview clock and is assigned again on commit; specify it to compare exact intervals. Sealed envelopes are redacted in both explanations and previews. ## 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 npx triplex dashboard --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). --- # MCP server `triplex mcp` exposes one database per process through MCP over stdio, using Effect's MCP server. The server is experimental and ships in `@triplex-build/triplex`. The `/mcp` library entry exports `tools`, `registrations` and `TransactionInput`. ```sh npm install @triplex-build/triplex effect @effect/sql-sqlite-node \ @effect/platform-node @effect/platform-node-shared npx triplex mcp --sqlite /absolute/path/to/app.db ``` Register it with an MCP client using the client's server configuration. Replace the database path and use the installed binary's absolute path if it is not on PATH: ```json { "mcpServers": { "triplex": { "command": "triplex", "args": ["mcp", "--sqlite", "/absolute/path/to/app.db"] } } } ``` The client must support MCP protocol version `2025-11-25`. Stdout carries only MCP messages; startup diagnostics go to stderr. Backend flags follow the [CLI](/tools): `--sqlite ` defaults to `./triplex.sqlite`; `--postgres-url ` selects PostgreSQL (or use the `TRIPLEX_POSTGRES_URL` environment variable). `--database-id ` selects a managed PostgreSQL database or `./data/.db` for SQLite; `--data-dir ` changes that SQLite directory. Relative paths resolve from the server process's working directory. Opening SQLite can apply pending migrations. ## Tools Each tool advertises an Effect-derived input schema through `tools/list`. Inputs use the same field names and wire values as the CLI's internal `execute` operations, without the `_tag` field. | Tool | Input | | --------------------- | --------------------------------------------------------------------------------------- | | `describe` | Optional `ref` (defaults to `live`) | | `status` | No parameters | | `entity_types` | Optional `includeSystem` (defaults to `false`) | | `entity_get` | `entityId`, optional `basis` | | `entity_facts` | `entityId` | | `entity_history` | `entityId`, optional `limit`, `snapshotPosition`, `beforePosition` | | `fact_match` | `pattern`, optional `basis` | | `query_run` | `query`, optional `cursor`, `pageSize`, `basis`, `debug` | | `query_page` | Wrapped `query`, optional `basis`, `debug` | | `query_explain` | `query` | | `journal_list` | Optional `after` (defaults to `0`), `limit` | | `journal_receipt` | `commandId` | | `config_release` | Optional `snapshotId` or `ref` | | `why` | `subject` (fact, attribute, entity or candidate), optional `basis`, `causationDepth` | | `transaction_preview` | Same input as `transaction_apply`; no commit | | `transaction_apply` | `operations`, required `meta.actor` and `meta.commandId`; optional transaction metadata | History and journal limits default to 100 and accept 1–1,000. Query page sizes follow the same bounds. A `basis` may specify `validAt` and either `recordedAt` or `recordedPosition`. Start with `describe`. It reads the live immutable release, including schema, constraints, rules and transaction examples. Substitute real values into an example's `input`, then pass that object directly as `transaction_apply` arguments. Preserve `meta.configSnapshot` and `meta.enforce` to apply the described constraints. Every write requires an actor and a unique command ID. If the response is lost, call `journal_receipt` with the original ID before retrying. Successful calls return the same JSON `data` as the CLI, in text content and `structuredContent`. Runtime failures return `isError: true` with a JSON [Diagnostic](/errors) in text content: branch on `code`, use `path` and `fix` when present, and honor `retryable`. Invalid parameters are rejected by MCP schema validation before execution. Sealed values are redacted using the CLI's output shaping; plaintext, ciphertext and blind tokens from sealed envelopes are never returned. Hosts remain responsible for sealing sensitive writes with [Sensitive.layer](/sensitive-values) and for database access permissions; the standalone server does not configure codec keys or authorization. `why` returns the core [explanation DTO](/why). Candidate subjects include the full immutable `Derivation.make` definition, `candidateId` and `revision`, with explicit recorded and valid basis. Stored subjects identify a `tripleId`, an `entityId` and attribute, or an `entityId` alone. `causationDepth` defaults to zero and accepts integers through 32. Use `transaction_preview` before `transaction_apply` with the same command input and ID. Preview returns projected operations, changes and new constraint violations under the supplied `meta.enforce`, or `commandAlreadyCommitted` for an existing ID. It does not write, check preconditions, run commit validators or reserve state. Preserve the release pin and enforcement from describe; a pin alone does not enforce constraints. See [CLI preview](/tools#explain-and-preview) for result fields and clock behavior. HTTP transport is planned; this first cut serves stdio only. --- --- # 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)` | The KV, SQLite, and standalone PostgreSQL convenience layers expose only `Triples`. `PgTriples.layerFromSqlClient` and `layerFromSqlClientMigrated` also expose only `Triples` and require the host's `SqlClient`. To use that client alongside Triplex, retain it explicitly with `Layer.provideMerge(HostSql)`, as below. `layerForDatabase` and `layerForDatabaseMigrated` expose exactly `Triples | SqlClient` for host transactions. Adapters, query executors, dialects, clocks, and ID generators stay inside these layers. 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* PgTriples.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. `PgTriples.withTransaction` also normalizes serialization and deadlock failures from host statements and the outer commit into `TransactionConflictError`. The error has `isRetryable: true`, optional `reason: "serialization" | "deadlock"`, and its original `cause`. Ordinary host SQL and domain failures are preserved. Triplex writes normalize these SQL failures on their own, but a nested call cannot catch an error raised later by the host's commit. Retry the entire host transaction after reloading state; retry policy and backoff remain host-owned. No automatic retry is performed. Direct `sql.withTransaction` remains available when the host wants the original SQL error channel. `layerFromSqlClient` creates no pool and runs no DDL. Acquisition checks the current schema version and fails with the Postgres package's exported `SchemaVersionError` when it differs from the release's expected version. The error includes `expectedVersion` and `actualVersion` (zero for absent migration history) and names `runMigrations`. Run migrations before building this layer, rather than inside the application effect that receives it. Newer schemas require a compatible release; connection and permission failures remain SQL errors. 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", { 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. [Commit validators](/commit-validators) let it enforce them inside the same transaction, reading the state that would commit. ## 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` (`GraphConstraint.pinned(snapshot)` returns both); and - a `TripleLive` precondition for each optimistic fact being replaced; and - command-specific [commit validators](/commit-validators) in `validate`. Install authorization and other always-on rules once with `CommitValidation.layer`. 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-defined. Run them inside the transaction with [commit validators](/commit-validators). - 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 For machine-readable error codes and recovery hints, see [Error diagnostics](/errors). ## Why does npm return 404 for a Triplex package? Install `@triplex-build/triplex`; backend imports are subpaths such as `/sqlite` and `/postgres`, not separate npm packages. Cloudflare and FoundationDB ship as experimental, frozen subpaths. Check the full package name and clear a stale local npm cache if a newly published version returns 404. Do not switch new examples back to the superseded `@bjacobso` scope. Use a compatible Effect peer version for the installed Triplex release. The current source tree targets Node.js 22+ and `effect@4.1.1`; 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))); ``` For client startup, publish your release before acquiring `Database.layer`; see the complete [getting-started composition](/getting-started). `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. 6. **Attribute spelling:** the [Database client](/database#typed-reads-and-bounded-queries) checks constant attributes against its release automatically. For raw `Triples`, install `SchemaBound.layer({ ref: "live" })` from `@triplex-build/triplex/config` to check constant attribute keys against a committed release. A misspelled key such as `":vendor/staus"` then fails before execution with `UnknownAttributeError`, naming its path and suggesting `":vendor/status"`. For `match`, the error is in `ReadError.cause`. Without the layer, unknown keys still return `[]`. Build queries from your `Attribute` definitions' `.key` (or entity type handles' `attributes.x.key`) to avoid spelling mistakes. See [Datalog](/datalog#query-shape) for wiring. 7. **Transaction provenance under a past `validAt`:** join the assertion's fourth-term transaction to `:_tx/actor` in the same query. These reserved metadata rows follow only recorded time. If the join is empty, check that the recorded cut includes the assertion's commit and that the assertion is valid at the requested point. See [Datalog](/datalog#query-shape). 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. Recorded time is the commit clock and cannot be supplied or backdated; an import of historical data is recorded at the import's commit time. Put domain dates in valid time, and model "when we learned this" as an ordinary attribute if you need it. 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? With the [Database client](/database#releases-retries-and-receipts), retry the same command ID to receive the original receipt; the callback does not run again. Command IDs are atomically unique within one Triplex database. With raw `Triples`, 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? The [Database client](/database) pins and enforces the release on every command. If you use raw `Triples`, 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. For raw writes, 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 does an enforced write fail with `ConstraintViolationError`? Enforcement checks required, cardinality, uniqueness, and reference-target rules at every valid-time edge of the affected entities, not only at the current time. The most common causes are time gaps rather than missing data: - **Facts of one entity start at different times.** An assertion without `validFrom` starts at the commit time. If one attribute is backdated and the others are not, the entity exists at the earlier instant without its required attributes. Give all of an entity's facts the same `validFrom`. - **A reference points at an entity that is not valid yet.** The target must have a fact of the target entity type valid at the referencing fact's `validFrom`. Make referenced entities valid from an earlier (or the same) time. - **A retraction erased an interval.** Retracting a value removes it from all of valid time, so a required attribute can disappear from the entity's past. To end a value from a date on, retract it and re-assert it with `validTo` in the same transaction. The error message names the first two violations with their constraint key, subject, and `validAt`. Inspect `error.violations` for the complete list; each entry's `validAt` is the epoch-millisecond instant at which the rule fails. See [Constraints hold at every valid instant](/configuration#constraints-hold-at-every-valid-instant). ## 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). --- # Error diagnostics Import `diagnose` and the `Diagnostic` type from `@triplex-build/triplex`. Pass the actual error from an Effect failure to `diagnose(error)`; it returns a JSON-serializable object containing `code`, `message`, `retryable`, and optional `path`, `fix`, and `details`. Branch on the stable code rather than parsing the message. Messages and fixes can gain detail. `path` identifies the input location when the error provides one, such as `where[2] alternative 1`, `find`, or `match.attribute`. `$` means the whole query. Errors without a known location omit it; sensitive errors do not infer a location by inspecting values or causes. Only transaction conflicts are retryable: reload current facts and retry the **whole command**. Repeating a conflicting command unchanged without reloading its preconditions will still fail. A duplicate command ID is a receipt lookup, not a retry. Diagnostics preserve the error message and allowlist structured details. They never copy raw queries, predicate operands, SQL, causes, sealed envelopes, or constraint value identities into `details`. Constraint details contain `violations`, an array of `{ constraintKey, subject, validAt }`. `validAt` is an epoch-millisecond instant. The diagnostic path names the first violation; the array includes them all. See the [valid-time modeling rule](/agents#_3-write-each-command-as-one-transaction): all of an entity's facts should share one interval so required facts remain present at every boundary. ## Code reference The table covers every class exported by the core error module and the more specific Datalog validation and transaction conflict codes. Fixes below describe the default recovery action; individual diagnostics can supply a more specific hint or suggested attribute spelling. | Code | Meaning | Suggested fix | Retryable | | --------------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | --------- | | `write.failed` | A storage write failed. | Check the write input and storage availability. | No | | `operational.entry-conflict` | An inbox or outbox idempotency key is already claimed. | Read the existing inbox or outbox entry for this idempotency key. | No | | `triple.duplicate` | The fact already exists. | Read the existing fact before asserting it again. | No | | `triple.invalid` | A fact field is invalid. | Correct the named field and submit the fact again. | No | | `transaction.failed` | A transaction failed outside a recognized concurrency conflict. | Inspect the transaction input and storage availability before resubmitting. | No | | `transaction.conflict.precondition` | Observed facts no longer match the transaction preconditions. | Reload current facts and retry the whole command. | Yes | | `transaction.command-already-committed` | This command ID already has a committed receipt. | Read the original receipt with `transactionByCommand`. | No | | `transaction.commit-rejected` | A commit validator rejected the transaction; nothing was written. `details` carries the validator findings. | Correct the validator findings before resubmitting the command. | No | | `constraint.violation` | The proposed post-state introduces or worsens graph constraints. | Correct the violated constraints and ensure all of an entity's facts share one valid-time interval. | No | | `watch.invalid-options` | A live-query option or moving input is invalid. | Correct the watch options; remove pinned bases and continuation cursors. | No | | `watch.invalid-resume` | A resume token is malformed or belongs to another query or generation. | Resume with the original query, options and database. | No | | `watch.result-too-large` | Complete raw results exceeded maxRows. | Use a wrapped window or increase maxRows. | No | | `why.invalid-request` | The explanation basis, subject or depth is invalid. | Supply an exact candidate basis and a depth from 0 to 32. | No | | `why.subject-not-found` | The fact or candidate does not exist at this cut. | Check the subject and recorded basis. | No | | `why.candidate-revision-mismatch` | This candidate has a different revision at the supplied basis. | Replay its exact definition, revision and basis. | No | | `why.incomplete-history` | Missing or inconsistent history prevents an exact explanation. | Inspect journal attribution; do not infer actors or positions. | No | | `read.failed` | A storage read failed. | Check the read input and storage availability. | No | | `entity.not-found` | The requested entity does not exist at this basis. | Check the entity ID and temporal basis. | No | | `triple.not-found` | The requested fact does not exist. | Reload the entity and use a current fact ID. | No | | `query.failed` | Query execution failed. | Check the query and storage availability. | No | | `query.invalid-pattern` | A triple match pattern is invalid. | Supply a valid triple match pattern. | No | | `datalog.parse` | The query text could not be parsed. | Correct the query syntax at the reported line and column. | No | | `datalog.unexpected-token` | The parser encountered an unexpected token. | Replace the unexpected token with the expected token. | No | | `datalog.undefined-variable` | The parser encountered an undefined variable. | Define the variable before referencing it. | No | | `validation.failed` | An input field failed validation. | Correct the named input field. | No | | `schema.unknown-attribute` | An attribute is absent from the bound release. | Use an attribute defined in the bound release. | No | | `datalog.failed` | A Datalog operation failed. | Correct the Datalog query before submitting it again. | No | | `datalog.unbound-variable` | A variable lacks a positive relation binding. | Bind the variable in a positive pattern or rule application before using it. | No | | `datalog.invalid-predicate` | Predicate operands or their types are invalid. | Use a supported predicate with bound operands and numeric ordered comparisons. | No | | `datalog.invalid-temporal-basis` | An all-time Datalog query supplies an explicit valid point. | Remove `basis.validAt` or use `validTime: "point"`. | No | | `datalog.invalid-shape` | The query does not match the Datalog or wrapper schema. | Supply a query matching the Datalog query schema. | No | | `sensitive.query` | The operation requires unsupported access to a sealed value. | Use blind-index equality for sensitive values and open them explicitly with Sensitive.open. | No | | `sensitive.value` | A sensitive value or codec operation failed. | Check the sensitive attribute policy and codec key availability. | No | | `pagination.invalid-cursor` | The cursor is invalid or belongs to a different query or basis. | Restart pagination without a cursor using the intended query and database. | No | | `migration.failed` | A database migration failed. | Inspect the failed migration and repair its prerequisites before rerunning it. | No | | `database.not-found` | The requested database does not exist. | Use an existing database name or create the database first. | No | | `database.already-exists` | The requested database name is already in use. | Use the existing database or choose a different name. | No | | `database.internal` | A database-manager internal operation failed. | Inspect the database logs and resolve the underlying failure. | No | | `transaction.conflict.serialization` | The database lost a serialization race. | Reload current facts and retry the whole command. | Yes | | `transaction.conflict.deadlock` | The database aborted a deadlock participant. | Reload current facts and retry the whole command. | Yes | | `datalog.and-clause` | An unsupported `and` clause groups conditions. | Put conjunctive clauses directly in `where` or in `not`. | No | | `datalog.not-with-and` | A negation contains an `and` wrapper. | Write `["not", clause1, clause2, ...]`. | No | | `datalog.or-branch-not-single-clause` | An `or` alternative contains multiple conjunctive clauses. | Use a conjunctive `not` alternative or run one query per alternative. | No | | `datalog.invalid-query` | A query uses an unsupported relation, rule or wrapper form. | Use a positive relation and supported rule and wrapper forms. | No | | `datalog.invalid-aggregate` | Aggregate targets or `having` are invalid. | Use unique aggregate targets in `find` and valid aggregate predicates. | No | | `datalog.invalid-projection` | Projection variables or optional targets conflict. | Project each bound variable once and use unique optional targets in `find`. | No | | `internal` | The input is not a recognized Triplex error. | Inspect the original failure in the calling application. | No | ## CLI and HTTP envelopes The CLI writes `{ "ok": false, "command": "query.run", "error": Diagnostic }` to stderr and exits nonzero. Input paths use `$` for the JSON root and `$args` for arguments. Add `--debug` to include a top-level `cause` string for local debugging; it can contain input and storage details. HTTP failures use `{ "error": Diagnostic }`. Core failures retain their core codes. Retryable responses include `Retry-After: 0`: reload facts before retrying the whole command. A version/cursor conflict requires restarting pagination and is not retryable. The OpenAPI document describes the envelope and all diagnostic fields. | Code | Meaning and fix | Retryable | | --------------------------------- | --------------------------------------------------------------------------- | --------- | | `cli.arguments` | Invalid or missing arguments; use command help and correct the named field. | No | | `cli.input-read` | Input cannot be read; supply a readable file or pipe JSON to stdin. | No | | `cli.input-json` | Invalid JSON; correct its syntax. | No | | `cli.input-schema` | Input fails schema decoding; correct the field at `path`. | No | | `http.unsupported-config` | Use configuration supported by the HTTP compiler. | No | | `http.config-conflict` | Resolve conflicting deployed definitions. | No | | `http.version-not-found` | Use latest or an existing snapshot or alias. | No | | `http.entity-not-found` | Check entity ID, collection and temporal basis. | No | | `http.invalid-request` | Correct the named request field using OpenAPI. | No | | `http.data-shape-conflict` | Repair entity facts to match the configuration. | No | | `http.read-only-version` | Write through latest or the active write snapshot. | No | | `http.scheduled-facts` | Resolve scheduled facts before replacing or deleting. | No | | `http.cursor-conflict` | Restart pagination under the current configuration. | No | | `http.sensitive-storage-required` | Configure the host with Sensitive.layer. | No | | `http.unauthorized` | Supply authentication accepted by the host. | No | | `http.forbidden` | Request access to the operation from the host. | No | | `http.collection-not-found` | Use a collection listed in OpenAPI. | No | | `http.not-found` | Use a route listed in OpenAPI. | No | | `http.method-not-allowed` | Use a method listed in Allow. | No | ## Database client mappings The [Database client](/database) uses existing stable codes; it adds no new error class or code. Its exported `DatabaseError` union contains its normalized read and validation errors and the underlying transaction, Datalog and sensitive errors. `command` preserves callback domain errors unchanged; hosts diagnose those themselves. Defects and interruption remain outside the typed failure channel. | Failure | Code and recovery | | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | Missing ref or incompatible declaration | `validation.failed`, path `schema.ref` or `schema.types`; provide the matching release/model. | | Invalid metadata, empty put, unknown alias/type, invalid interval or temporal source, reused/escaped/concurrent builder | `validation.failed`, a `meta.*`, `schema.types` or `command.*` path; correct that input. | | Invalid view basis | `validation.failed`, path `basis.recordedAt`, `basis.recordedPosition` or `basis.validAt`. | | Entity decoder failure or Schema projection failure | `validation.failed`, path `entity.values` or `results[index]`; inspect data or the projection. Malformed state is never silently converted to `null`. | | Config storage/corruption, entity/history/journal lookup, missing receipt after success/duplicate | `read.failed`; original cause is retained locally. Retry the same command ID to recover a committed receipt. | | Unknown raw/typed query attribute | `schema.unknown-attribute`; preserves SchemaBound's path and nearest-key hint. | | Datalog, constraints, preconditions, operational keys, sensitive values/planning | Existing `datalog.*`, `constraint.violation`, `transaction.conflict.*`, `operational.entry-conflict`, `sensitive.value` and `sensitive.query` codes. | | Missing source fact / stale source fact | `triple.not-found` / `transaction.conflict.precondition`. | | Invalid client/backend continuation | `pagination.invalid-cursor`; restart the read. | | Duplicate command claim | Recovered automatically as the original receipt. Other errors are not automatically retried. | Use `diagnose` instead of duplicating code strings in transports. Normalized client validation messages identify the field without copying values, payloads, ciphertext, query constants or arbitrary causes into diagnostics. ### `time.invalid-bound` Assertion `validFrom`/`validTo`, Change split points, and numeric interval-pattern bounds must use integer epoch milliseconds. Fractional and non-finite bounds fail before mutation or query execution on KV, SQLite, and PostgreSQL. Use integer millisecond timestamps. Assertion builders throw `ValidationError`; write failures retain that error as their `WriteError.cause`. Interval-pattern failures use `DatalogValidationError`. `diagnose` returns this code for each form. --- # Releasing Triplex Triplex publishes one npm package, `@triplex-build/triplex`, through Changesets and GitHub Actions. All implementation workspaces are private and ignored by Changesets. ## Release set and packing from main After cloning `main`, install and pack from the repository root: ```sh corepack enable pnpm install --frozen-lockfile pnpm pack ``` The prepack hook builds the private module graph and dashboard, then emits one `triplex-build-triplex-.tgz`. It contains the existing core exports plus `/sql`, `/sqlite`, `/postgres`, `/cloudflare`, `/foundationdb`, `/host`, `/http`, `/testkit`, `/testkit/postgres` and `/mcp`, and the `triplex` executable. Dashboard JavaScript, CSS and fonts are prebuilt assets; consumers install no UI runtime. Cloudflare and FoundationDB remain **experimental and frozen** even though their subpaths now ship. `/runner` is reserved for later work and is not exported yet. Only `effect` is a required peer. Install the backend driver separately: ```sh npm install @triplex-build/triplex effect @effect/sql-sqlite-node # PostgreSQL instead: npm install @triplex-build/triplex effect @effect/sql-pg ``` The bin's Node services also need `@effect/platform-node` and `@effect/platform-node-shared`, both optional peers. Library-only and browser consumers do not need them. `pnpm pack:check` verifies the effect-only consumer, then installs the SQLite driver and Node adapters to exercise the bin and server. ## Deprecate the old npm names after the consolidated release Ben should publish and verify the unified release before deprecating all versions of these six names. Do not unpublish any artifacts. Use these exact messages: | Old npm package | Deprecation message | | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `@triplex-build/triplex-sql` | Use @triplex-build/triplex/sql from the single @triplex-build/triplex package. | | `@triplex-build/triplex-sqlite` | Use @triplex-build/triplex/sqlite from the single @triplex-build/triplex package; install @effect/sql-sqlite-node separately. | | `@triplex-build/triplex-postgres` | Use @triplex-build/triplex/postgres from the single @triplex-build/triplex package; install @effect/sql-pg separately. | | `@triplex-build/triplex-http` | Use @triplex-build/triplex/http from the single @triplex-build/triplex package. | | `@triplex-build/triplex-testkit` | Use @triplex-build/triplex/testkit (or /testkit/postgres) from the single @triplex-build/triplex package. | | `@triplex-build/triplex-cli` | Install @triplex-build/triplex for the triplex bin; use triplex mcp and triplex dashboard for the bundled tools. | For each row, the owner can run `npm deprecate '@*' ''` after verifying the new paths. Cloudflare, FoundationDB, host, MCP and dashboard were never published under the old workspace names and need no npm deprecation. ## 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. The single 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`. Inspect the production infrastructure diff with `pnpm docs:plan`. Deployments run through GitHub Actions. The `prod` stage preserves and . 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 `prod` attaches the custom domain. `.github/workflows/deploy.yml` deploys the exact `main` commit from a successful push run of CI, after verification, package-consumer checks, documentation browser tests, PostgreSQL integration, and stress smoke tests pass. Production deployments are serialized and are not canceled mid-deploy. `.github/workflows/preview.yml` deploys same-repository pull requests on opening, updates, and reopening to `pr-`. Each stage has its own `triplex-docs-pr-` Worker, serves a `workers.dev` URL, and updates one sticky PR comment. Closing a PR destroys that stage and marks the comment as removed. Deploy and cleanup share a per-PR concurrency group and finish before another operation starts. Fork pull requests never receive deployments or credentials. Configure these repository Actions secrets for both production and previews: - `CLOUDFLARE_ACCOUNT_ID`: the account containing the production Worker and state store. - `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. Its scope must allow creating and deleting preview Workers as well as updating the production Worker. The preview workflow requests `pull-requests: write` for its sticky comment; repository or organization Actions policies must allow that permission. Both workflows skip with an Actions notice when credentials are absent, including cleanup; restore credentials and rerun the closed-PR workflow if cleanup was skipped. Both workflows use the existing remote `Cloudflare.state()` store. The `triplex` stack keeps its `prod` state and isolates preview state by stage. 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/). Alchemy beta.84 discovers the provider through `@alchemy.run/cloudflare`. State-store maintenance now uses `pnpm exec alchemy state bootstrap --backend cloudflare`; the old `alchemy provider cloudflare bootstrap` command exits with a migration hint. Deployment commands pass `--yes` so CI can provision or upgrade the existing state store when required. ## Verify a canary Install from the registry in a clean directory outside this monorepo: ```sh npm install @triplex-build/triplex@next effect @effect/sql-sqlite-node \ @effect/platform-node @effect/platform-node-shared npx triplex --help npx triplex --sqlite ./canary.sqlite describe npx triplex mcp --sqlite ./canary.sqlite # In another terminal, verify the local dashboard: npx triplex dashboard --sqlite ./canary.sqlite --port 4174 ``` 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 the single 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 package: 1. Review the generated versions, changelogs, lockfile changes, and single publishable package 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-tag, 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 effect @effect/sql-sqlite-node ``` 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). --- # A fact is a triple Part 01 of [The Database That Remembers Why](./) · Act I, Facts · November 8, 2026 ```ts export const firstFact = Effect.gen(function* () { const store = makeStore(); yield* store.put({ entity: "worker:maria", attribute: ":worker/trained", value: true, }); return yield* store.match({ entity: "worker:maria" }); }); // Effect.runSync(firstFact) returns: // [ // { // entity: "worker:maria", // attribute: ":worker/trained", // value: true, // }, // ] ``` Maria is trained. The program records that statement, asks for Maria's facts, and returns it. By the end of this post, that program runs on a tiny store we wrote ourselves. Then we correct Maria's record and discover what the store can no longer tell us. This is **teaching code**, not Triplex's API. It needs TypeScript and the repository's Effect 4.1.1. The source files and tests are linked below; they already run in this repository's docs check. ## Three slots for a statement A fact has an **entity** (what we're talking about), an **attribute** (what we're saying about it), and a **value** (the answer). ```ts import { Effect } from "effect"; export type Value = string | number | boolean; export type Fact = { readonly entity: string; readonly attribute: string; readonly value: Value; }; export type Pattern = Partial; ``` The entity is a stable name, not a person's display name. Maria could change her name without becoming a different worker. The attribute is a string with a namespace so training and payroll can name different properties without negotiating every word. Neither convention is validated yet. For now values are strings, numbers, or booleans. Relationships can use strings too, but nothing distinguishes a reference from ordinary text. There are no dates or objects hiding behind this type. `Partial` means a question may specify any subset of the three slots. Omitted slots are wildcards; supplied slots must all match exactly. ## A Map is enough—for now Here is the entire store: ```ts export const makeStore = () => { const facts = new Map(); const put = (fact: Fact) => Effect.sync(() => { const key = JSON.stringify([fact.entity, fact.attribute]); facts.set(key, { ...fact }); }); const match = (pattern: Pattern) => Effect.sync(() => [...facts.values()] .filter((fact) => Object.entries(pattern).every(([key, value]) => { return fact[key as keyof Fact] === value; }), ) .map((fact) => ({ ...fact })), ); return { put, match }; }; ``` We key the Map by the entity and attribute together. JSON encoding the pair keeps a separator inside either string from confusing two different pairs. The value isn't in the key: writing the same entity and attribute again **replaces** its value. This store allows one value per pair. That is a choice for this version, not an inherent property of triples. `match` scans every fact and requires every supplied field to agree. An empty pattern returns everything. A boolean `false`, numeric zero, or empty string is still a value to match, not a missing filter. Strict equality keeps numeric zero different from the string containing zero. We copy facts on the way in and out so a caller cannot change the stored statement by mutating an object it already holds. Copies aren't history; they only separate the caller's objects from the Map's objects. ### Why Effect already? The Map is mutable, so reading or writing it is an operation, not a pure value. `Effect.sync` delays each operation until the Effect runs. Merely calling `put` doesn't write anything; yielding it in `Effect.gen` does. `Effect.runSync` runs these synchronous programs at the outer boundary. There is no typed failure channel yet. We accept the TypeScript types and don't decode untrusted input. Later, Schema will check values and the transaction boundary will check constraints. Effect doesn't invent either guarantee just because we imported it. ## Ask a few questions Give Maria a name and a training record, then add Lee, who is not trained: ```ts export const questions = Effect.gen(function* () { const store = makeStore(); yield* store.put({ entity: "worker:maria", attribute: ":worker/name", value: "Maria", }); yield* store.put({ entity: "worker:maria", attribute: ":worker/trained", value: true, }); yield* store.put({ entity: "worker:lee", attribute: ":worker/trained", value: false, }); return { trained: yield* store.match({ attribute: ":worker/trained", value: true, }), untrained: yield* store.match({ value: false }), absent: yield* store.match({ entity: "worker:jo" }), all: yield* store.match({}), }; }); ``` Running `questions` produces four answers: - `trained` contains only Maria's training fact, not her name. - `untrained` contains only Lee's training fact, whose value is `false`. - `absent` is an empty array: no facts mention Jo. - `all` contains all three facts, in their insertion order. Notice what an empty answer means. Jo isn't recorded as untrained; Jo isn't recorded at all. Absence of evidence and evidence of absence are already different, even before we have a query language. We can ask for one entity or find every entity with a particular property without defining a new table for each kind of thing. But we cannot yet join workers to sites or ask for workers missing a required training record. Part 7 will turn those questions into data too. ## Make a correction An auditor discovers that Maria's certificate was entered in error. Change her training value to `false`: ```ts export const correction = Effect.gen(function* () { const store = makeStore(); const identity = { entity: "worker:maria", attribute: ":worker/trained", }; yield* store.put({ ...identity, value: true }); const before = yield* store.match(identity); yield* store.put({ ...identity, value: false }); return { before, after: yield* store.match(identity), evidence: yield* store.match({ ...identity, value: true, }), }; }); // Effect.runSync(correction): // before[0].value === true // after[0].value === false // evidence is [] ``` The current answer is right. The store now says Maria is not trained. The earlier array in `before` still contains `true`, because we read a copy before the overwrite. But ask the **store** for the earlier statement and `evidence` is empty. Keeping a local variable alive isn't an audit trail. This isn't a missing query helper. Consider a different history: ```ts export const noEarlierTraining = Effect.gen(function* () { const store = makeStore(); yield* store.put({ entity: "worker:maria", attribute: ":worker/trained", value: false, }); return yield* store.match({}); }); // The store cannot distinguish these two histories: // true, then false -> [{ ...identity, value: false }] // false only -> [{ ...identity, value: false }] ``` That program records only `false`. Its complete stored state is identical to the corrected store's state. One history had a mistaken `true`; the other never did. No query over the remaining Map can distinguish them. Now someone asks why Maria was assigned to a site. The assignment may have been reasonable given the training record at the time. We cannot establish that from today's value. The update didn't just change an answer. It destroyed the evidence needed to explain an earlier answer. ## What this version leaves out We have a synchronous, in-memory, single-value store with linear scans. No persistence, indexes, atomic multi-fact transactions, validation, authorization, retractions, timestamps, actors, or rule versions. Map insertion order is not a transaction log. There are no modeled errors to quote yet; malformed external input isn't supported. Most importantly, this is **not bitemporal yet**. We cannot ask when a fact held in the world or when we learned it. Adding a date attribute wouldn't recover an overwritten statement either. ### This is what Triplex does The real Triplex retains assertions and retractions and reads facts using independent valid and recorded times. Its `Triples` service is not this `makeStore`, and its write contract isn't `put`. See [Core concepts](/concepts) for facts, corrections, and the two clocks, and [Getting started](/getting-started) for runnable library code. Those guarantees don't make every assertion true. They let us preserve and inspect what was asserted. The rest of the series builds toward that distinction; [Current state](/current-state) describes what the real library supports today. ## Next: preserve the statement [Part 2, “Nothing is overwritten,”](./02-nothing-is-overwritten) stops replacing facts and records assertions and retractions in a transaction log. A correction must change the current answer without erasing the statement that made an earlier answer possible. Only then can Part 3 give that history two clocks. The [source and tests](https://github.com/bjacobso/triplex/tree/main/docs/snippets/blog/remembers-why/01) live together. Every code block above is a named-region import from those files; CI checks the opening result, every question, and the two indistinguishable histories. [Back to the series](./). --- # Nothing is overwritten Part 02 of [The Database That Remembers Why](./) · Act I, Facts · November 8, 2026 ```ts import { Effect } from "effect"; import { correction } from "./story.ts"; export const answer = Effect.runSync(correction); // answer.current[0].value is false. // answer.journal[0].changes[0].fact.value is still true. ``` The current answer is `false`. The earlier assertion still says `true`. Unlike [Part 1](./01-a-fact-is-a-triple), this version can distinguish a correction from a history in which nobody ever said Maria was trained. We haven't made the earlier statement true. We've stopped destroying the evidence that someone made it. This is still **teaching code**, not Triplex's API, using the repository's Effect 4.1.1. Each block comes from the tested files linked below. ## Give statements identities Keep Part 1's triples and pattern queries. Add an identity to each assertion, and replace `put` with two operations: ```ts import { Effect, Schema, Semaphore } from "effect"; import type { Fact, Pattern } from "../01/facts.ts"; export type Assertion = Fact & { readonly id: string }; export type Change = | { readonly op: "assert"; readonly fact: A } | { readonly op: "retract"; readonly id: string }; export type Transaction = { readonly position: number; readonly changes: ReadonlyArray>; }; export class StoreError extends Schema.TaggedError()( "StoreError", { message: Schema.String }, ) {} ``` An assertion introduces a statement. A retraction withdraws that particular assertion, not every fact about its entity and attribute. The ID names evidence; it is not the entity's ID. Maria keeps her entity identity when her training record changes. A transaction groups changes under one increasing position. The generic type lets later parts attach time to assertions without rewriting this log. `StoreError` gives invalid transitions a typed failure channel. ## Correct, don't replace Here is the program behind the opening result: ```ts export const original = { id: "training:1", entity: "worker:maria", attribute: ":worker/trained", value: true, }; export const correction = Effect.gen(function* () { const store = makeStore(); yield* store.transact([{ op: "assert", fact: original }]); yield* store.transact([ { op: "retract", id: original.id }, { op: "assert", fact: { ...original, id: "training:2", value: false, }, }, ]); return { current: yield* store.match({ entity: "worker:maria" }), journal: yield* store.journal, }; }); // current has training:2, value false. // journal positions are [1, 2]. // Position 1 still asserts training:1, value true. ``` Position 1 asserts `training:1`. Position 2 retracts it and asserts `training:2`, whose value is `false`. Those last two operations belong together. A reader must not see the withdrawal without the replacement. The journal keeps both transactions. The current answer is a projection of that journal, not a second history we edit independently: ```ts export const replay = ( log: ReadonlyArray>, ) => { const live = new Map(); for (const tx of log) { for (const change of tx.changes) { if (change.op === "assert") { live.set(change.fact.id, change.fact); } else { live.delete(change.id); } } } return [...live.values()]; }; export const matches = (fact: Fact, pattern: Pattern) => Object.entries(pattern).every(([key, value]) => { return fact[key as keyof Fact] === value; }); ``` Replay walks transactions in order. Its temporary Map is keyed by assertion ID, rather than by entity and attribute. Deleting an entry from that Map doesn't delete the earlier journal entry. We can throw the projection away and compute it again. Pattern matching retains Part 1's exact comparisons and wildcard omissions. We're changing how facts survive a correction, not inventing a query language yet. ## Check the whole batch before publishing it A retraction of an unknown assertion is probably a mistake. Reusing an assertion ID would make the evidence ambiguous. Check both before appending: ```ts export const stage = Effect.fnUntraced(function* < A extends Assertion, >( log: ReadonlyArray>, input: ReadonlyArray>, ) { const changes = structuredClone(input); const live = new Set(replay(log).map((fact) => fact.id)); const used = new Set( log.flatMap((tx) => tx.changes.flatMap((change) => change.op === "assert" ? [change.fact.id] : [], ), ), ); for (const change of changes) { if (change.op === "assert") { const id = change.fact.id; if (used.has(id)) { return yield* new StoreError({ message: `assertion ID already used: ${id}`, }); } used.add(id); live.add(id); } else { if (!live.delete(change.id)) { return yield* new StoreError({ message: `assertion is not live: ${change.id}`, }); } } } return { position: log.length + 1, changes }; }); ``` `used` includes assertions that were later retracted. An ID cannot become available again just because its statement is no longer live. The checks also see earlier operations in the same batch: asserting and then retracting a new ID is allowed; asserting it twice is not. Reuse fails with `assertion ID already used: training:1` in the tested example. Staging changes local sets, not the stored log. Only a successful stage reaches the append: ```ts export const makeStore = () => { let log: ReadonlyArray = []; const lock = Semaphore.makeUnsafe(1); const transact = (changes: ReadonlyArray) => lock.withPermit( Effect.gen(function* () { const tx = yield* stage(log, changes); log = [...log, tx]; return structuredClone(tx); }), ); const match = (pattern: Pattern) => Effect.sync(() => structuredClone( replay(log).filter((fact) => matches(fact, pattern)), ), ); const journal = Effect.sync(() => structuredClone(log)); return { transact, match, journal }; }; ``` One semaphore permit protects the check and append together. Two callers cannot both check the same old state and successfully retract the same assertion. Input and output copies keep callers from mutating the journal through references they hold. This is atomicity inside one in-memory store. It is **not** crash safety or a lock shared by multiple processes. ## Failure must leave no half-correction Try a batch whose first retraction is valid and whose second isn't: ```ts export const rollback = Effect.gen(function* () { const store = makeStore(); yield* store.transact([{ op: "assert", fact: original }]); const changes: ReadonlyArray = [ { op: "retract", id: original.id }, { op: "retract", id: "missing" }, ]; const error = yield* store.transact(changes).pipe(Effect.flip); return { message: error.message, current: yield* store.match({}), positions: (yield* store.journal).map((tx) => tx.position), }; }); // message: "assertion is not live: missing" // current still has training:1, value true. // positions: [1] ``` The error is `assertion is not live: missing`. Maria's original assertion is still current. The journal still has only position 1. A rejected batch doesn't consume a position or leave the first retraction behind. There is another difference from Part 1's single-value Map: ```ts export const ambiguity = Effect.gen(function* () { const store = makeStore(); yield* store.transact([{ op: "assert", fact: original }]); yield* store.transact([ { op: "assert", fact: { ...original, id: "training:2", value: false, }, }, ]); return (yield* store.match({})).map((fact) => fact.value); }); // [true, false]: asserting a replacement is not a retraction. ``` Without a retraction, both `true` and `false` are current. We deliberately allow multiple assertions for the same entity and attribute. Whether an attribute must have exactly one value is a rule, not a property of triples. Part 5 will give that rule somewhere to run. ## What this version leaves out We scan and replay the whole journal. There is no persistence, indexing, external input decoding, actor, command identity, or rule enforcement. Assertion IDs come from the caller. Transactions have an order, but no timestamps and no public as-of query yet. ### This is what Triplex does Real Triplex also distinguishes assertions from retractions and commits transaction changes atomically. Its assertion representation and service API are not these teaching types. See [Core concepts](/concepts) for its correction model and [Getting started](/getting-started) for library calls. The backend guarantees remain those in [Current state](/current-state), not those of this closure over an array. ## Next: when was it true, and when did we know? The journal can now prove that Maria's record changed. It still cannot say whether training ended before her March 2 assignment or whether we learned about that ending later. A transaction position answers neither question. [Part 3](./03-two-clocks-two-questions) gives the history two clocks. The [source and tests](https://github.com/bjacobso/triplex/tree/main/docs/snippets/blog/remembers-why/02) check the correction, rollback, ID reuse, competing retractions, and copy isolation. [Back to the series](./). --- # Two clocks, two questions Part 03 of [The Database That Remembers Why](./) · Act I, Facts · November 8, 2026 ```ts import { Effect } from "effect"; import { twoClocks } from "./story.ts"; export const answer = Effect.runSync(twoClocks); // answer.asKnownThen.map((f) => f.id) -> ["training:1"] // answer.asKnownNow.map((f) => f.id) -> [] // answer.january.map((f) => f.id) -> ["training:2"] ``` Three answers, no contradiction. On March 2, our evidence said Maria was trained. On March 10, we learned her training had ended on March 1. Our corrected view no longer covers March 2, but it still covers January. [Part 2](./02-nothing-is-overwritten) preserved the statements. Now we need to distinguish **when a statement holds** from **when we recorded it**. This is teaching code using Effect 4.1.1, not Triplex's API. ## Two coordinates for one question An assertion gets a valid interval. A transaction gets a recorded time. A read must supply both coordinates explicitly: ```ts import { DateTime, Effect, Semaphore } from "effect"; import type { Pattern } from "../01/facts.ts"; import { StoreError, matches, replay, stage, type Assertion, type Change, type Transaction, } from "../02/log.ts"; export type TimedFact = Assertion & { readonly validFrom: number; readonly validTo?: number; }; export type TimedTransaction = Transaction & { readonly recordedAt: number; }; export type Basis = { readonly validAt: number; readonly recordedAt: number; }; export const wallClock = DateTime.now.pipe( Effect.map(DateTime.toEpochMillis), ); ``` `validFrom` is inclusive; `validTo` is exclusive. An omitted end means the interval is open-ended, not that the certificate can never expire. `recordedAt` belongs to the transaction: everything in one commit becomes known together. Both coordinates use epoch milliseconds. Valid time is supplied with the statement. Recorded time comes from the store's clock, not a per-write timestamp chosen by the client. `DateTime.now` supplies the default; the store accepts a clock Effect so the story and tests can control time without waiting or backdating a live request. Here are the story's UTC instants; JavaScript numbers months from zero: ```ts export const JAN_1 = Date.UTC(2026, 0, 1); export const JAN_5 = Date.UTC(2026, 0, 5); export const MAR_1 = Date.UTC(2026, 2, 1); export const MAR_2 = Date.UTC(2026, 2, 2); export const MAR_10 = Date.UTC(2026, 2, 10); ``` All five dates are in 2026. These are precise boundaries at midnight UTC, not a model of local working days or time zones. ## Record a correction when it arrives On January 5, we record training effective from January 1. On March 10, we replace that open interval with one ending on March 1: ```ts export const twoClocks = Effect.gen(function* () { let now = JAN_5; const store = makeStore(Effect.sync(() => now)); yield* store.transact([ { op: "assert", fact: { ...original, validFrom: JAN_1, }, }, ]); now = MAR_10; yield* store.transact([ { op: "retract", id: original.id }, { op: "assert", fact: { ...original, id: "training:2", validFrom: JAN_1, validTo: MAR_1, }, }, ]); return { asKnownThen: yield* store.match( {}, { validAt: MAR_2, recordedAt: MAR_2, }, ), asKnownNow: yield* store.match( {}, { validAt: MAR_2, recordedAt: MAR_10, }, ), january: yield* store.match( {}, { validAt: JAN_5, recordedAt: MAR_10, }, ), journal: yield* store.journal, }; }); // asKnownThen: [training:1] — we believed Maria was trained. // asKnownNow: [] — the corrected interval ended on March 1. // january: [training:2] — January is still covered. ``` The correction does not assert that Maria was untrained forever. It withdraws the old assertion and supplies a narrower interval. An empty answer on March 2 means **no matching training assertion**, not a stored boolean `false`. Notice that the retraction has no valid interval of its own. At its recorded time it withdraws the entire old assertion from our newer view, including the old assertion's claims about January. The replacement explicitly preserves January. Earlier recorded-time views still see the old assertion. ## Recorded time first, valid time second The order of the read matters: ```ts export const readAt = ( log: ReadonlyArray, pattern: Pattern, basis: Basis, ) => { const known = log.filter( (tx) => tx.recordedAt <= basis.recordedAt, ); return structuredClone( replay(known).filter( (fact) => fact.validFrom <= basis.validAt && (fact.validTo === undefined || basis.validAt < fact.validTo) && matches(fact, pattern), ), ); }; ``` First select transactions known at the requested recorded time. Then replay their assertions and retractions using Part 2. Finally select assertions whose valid intervals cover the requested instant. If we replayed the entire journal first, the March 10 retraction would leak into the March 2 view. We'd have preserved the old statement in the journal but still lost the ability to reconstruct the earlier belief. The opening results follow from those steps: | Question | Valid time | Recorded time | Answer | | ---------------------------------- | ---------- | ------------- | --------------------- | | What did we believe then? | March 2 | March 2 | `training:1` | | What do we believe now about then? | March 2 | March 10 | No matching assertion | | Did the correction erase January? | January 5 | March 10 | `training:2` | Two time coordinates are not an extra filter on today's rows. They select which history is allowed to participate in the answer. ## Keep the clock honest Reuse Part 2's staging checks and add interval and clock checks: ```ts export const record = Effect.fnUntraced(function* ( log: ReadonlyArray, changes: ReadonlyArray>, recordedAt: number, ) { const last = log.at(-1)?.recordedAt ?? 0; if (!Number.isFinite(recordedAt) || recordedAt < last) { return yield* new StoreError({ message: "recorded clock must not move backwards", }); } for (const change of changes) { if (change.op !== "assert") continue; const { id, validFrom, validTo } = change.fact; if ( !Number.isFinite(validFrom) || validFrom < 0 || (validTo !== undefined && (!Number.isFinite(validTo) || validTo <= validFrom)) ) { return yield* new StoreError({ message: `invalid valid interval: ${id}`, }); } } const tx = yield* stage(log, changes); return { ...tx, recordedAt }; }); ``` This teaching model permits finite, nonnegative valid starts and finite ends strictly after the start. A zero-length or reversed interval for the original assertion fails with `invalid valid interval: training:1`. The recorded clock cannot go backwards; that failure says `recorded clock must not move backwards`. Neither failure appends anything. Equal recorded times are allowed. A read at that millisecond includes all commits at that time. Positions still order them, but this version has no read coordinate for selecting between same-millisecond commits. The store is the same small ownership boundary as before: ```ts export const makeStore = (clock = wallClock) => { let log: ReadonlyArray = []; const lock = Semaphore.makeUnsafe(1); const transact = (changes: ReadonlyArray>) => lock.withPermit( Effect.gen(function* () { const now = yield* clock; const tx = yield* record(log, changes, now); log = [...log, tx]; return structuredClone(tx); }), ); const match = (pattern: Pattern, basis: Basis) => Effect.sync(() => readAt(log, pattern, basis)); const journal = Effect.sync(() => structuredClone(log)); return { transact, match, journal }; }; ``` The semaphore protects clock sampling, staging, and publication together. `readAt` is pure over the journal supplied to it; the store supplies a consistent array and returns copied answers. ## What this version leaves out No persistence or temporal indexes. Every query replays history. No automatic expiry notifications: asking after an interval ends changes the answer, but nothing wakes a host up. Part 9 will address that. The attributes still accept any primitive value. There are no authenticated actors, command IDs, receipts, or versioned rules. Our clocks describe what was recorded, not an independent proof that the real-world event happened. ### This is what Triplex does Real Triplex reads facts with independent valid and recorded coordinates and keeps correction history. Read [Core concepts](/concepts) for its interval semantics and actual query contract. This miniature's numeric `Basis` and `makeStore` are not library exports; backend maturity is still documented in [Current state](/current-state). ## Next: a timestamp isn't an author We can reconstruct what the store knew before the correction. We cannot say who submitted it, or tell a retried request from a new business event. “It was recorded on March 10” isn't an answer to “who did this?” [Part 4](./04-someone-did-this) puts a command and its receipt in the same commit as the facts. The [source and tests](https://github.com/bjacobso/triplex/tree/main/docs/snippets/blog/remembers-why/03) pin the dates, all three answers, both interval boundaries, retraction visibility, and failed clock checks. [Back to the series](./). --- # Someone did this Part 04 of [The Database That Remembers Why](./) · Act I, Facts · November 8, 2026 ```ts import { Effect } from "effect"; import { retry } from "./story.ts"; export const answer = Effect.runSync(retry); // answer.first.position -> 1 // answer.receipt?.position -> 1 // answer.journal.length -> 1 ``` The first request committed. The retry did not. Looking up the command's receipt finds the original position, not a second event that happens to look like the first. [Part 3](./03-two-clocks-two-questions) recorded when we learned something. It didn't record who asked us to change it or identify the request that caused the commit. Let's put those beside the facts, not in a separate log we hope stays in sync. This remains **teaching code**, using Effect 4.1.1, not Triplex's API. ## Name the attempt that may succeed once Keep the temporal transaction, and attach the command that produced it: ```ts import { Effect, Semaphore } from "effect"; import type { Pattern } from "../01/facts.ts"; import { StoreError, type Change } from "../02/log.ts"; import { readAt, record, wallClock, type Basis, type TimedFact, type TimedTransaction, } from "../03/temporal.ts"; export type Command = { readonly id: string; readonly actor: string; readonly rules: string; }; export type Receipt = TimedTransaction & { readonly command: Command; }; ``` The command ID identifies one intended submission. The actor identifies the principal claimed by the host. The rule label records the context the caller supplied. It does **not** yet identify executable, immutable rules; we'll expose that hole shortly. A receipt is the committed temporal transaction plus that command. Keeping one object for both avoids an independent receipt write that could succeed when the facts fail, or fail after the facts succeed. Our journal is now the sequence of these receipts. Neither the type nor the database authenticates `actor`. A real host must derive it from trusted request context, not let a caller impersonate Pat by typing a string. Attribution without authentication is just a recorded claim. ## Lose the response, then retry Imagine the server commits Maria's training but the response never reaches the caller. The caller resubmits the **same command ID**: ```ts export const recordTraining: Command = { id: "record-training:maria:request-17", actor: "user:pat", rules: "hr-2026.1", }; export const retry = Effect.gen(function* () { const store = makeStore(Effect.succeed(MAR_10)); const changes = [ { op: "assert", fact: { ...original, validFrom: JAN_1, }, }, ] as const; const first = yield* store.transact(recordTraining, changes); const duplicate = yield* store .transact(recordTraining, changes) .pipe(Effect.flip); const receipt = yield* store.receipt(recordTraining.id); return { first, receipt, error: duplicate.message, journal: yield* store.journal, }; }); // error: // "command already committed: record-training:maria:request-17" // first and receipt have position 1; journal has one entry. ``` The duplicate fails with `command already committed: record-training:maria:request-17`. The caller can then fetch the original receipt. Both `first` and `receipt` have position 1; the journal has one entry. The receipt records `user:pat` and `hr-2026.1` along with the assertion and its time. This contract is deliberately explicit: duplicates are rejected, not silently returned as successes. A missing lookup returns `undefined`. We don't compare the retried body with the original body and pretend that matching IDs prove matching intent. The receipt shows what actually won. The caller must preserve the ID across retries. Generating a new ID for each network attempt defeats this protection. Conversely, reusing an ID for a different business event is an error even if the facts differ. ## Claim the ID where we commit the facts Reuse Part 3's record operation. Reject an already committed command before staging a new transaction: ```ts export const accept = Effect.fnUntraced(function* ( log: ReadonlyArray, command: Command, changes: ReadonlyArray>, now: number, ) { const existing = log.find((tx) => tx.command.id === command.id); if (existing !== undefined) { return yield* new StoreError({ message: `command already committed: ${command.id}`, }); } const tx = yield* record(log, changes, now); return { ...tx, command: structuredClone(command) }; }); ``` The ID claim is the receipt's presence in the journal. There is no separate set to update before or after it. A failed assertion or retraction leaves no receipt, so that command ID is still available for a corrected attempt. But a duplicate check alone isn't enough. It must share the commit boundary: ```ts export const makeStore = (clock = wallClock) => { let log: ReadonlyArray = []; const lock = Semaphore.makeUnsafe(1); const transact = ( command: Command, changes: ReadonlyArray>, ) => lock.withPermit( Effect.gen(function* () { const now = yield* clock; const receipt = yield* accept(log, command, changes, now); log = [...log, receipt]; return structuredClone(receipt); }), ); const receipt = (id: string) => Effect.sync(() => structuredClone(log.find((tx) => tx.command.id === id)), ); const match = (pattern: Pattern, basis: Basis) => Effect.sync(() => readAt(log, pattern, basis)); const journal = Effect.sync(() => structuredClone(log)); return { transact, receipt, match, journal }; }; ``` One permit covers the clock, ID lookup, staging checks, and append. If two callers race with the same command ID but different assertion IDs, only one can commit. This isn't accidental protection from assertion-ID collisions; command identity itself prevents the second event. Reads and returned receipts are copies. Changing a returned actor doesn't rewrite who the journal says acted. As before, this protects one store instance in memory, not multiple processes or a durable database. ## A rule label is not a rule Try a training value that the intended domain should reject: ```ts export const rulesGap = Effect.gen(function* () { const store = makeStore(Effect.succeed(MAR_10)); yield* store.transact(recordTraining, [ { op: "assert", fact: { ...original, value: "yes", validFrom: JAN_1, }, }, ]); return { facts: yield* store.match( {}, { validAt: MAR_2, recordedAt: MAR_10, }, ), receipt: yield* store.receipt(recordTraining.id), }; }); // The receipt says hr-2026.1. The value "yes" still commits. // Recording a rule label doesn't enforce any rule. ``` The string `"yes"` commits. The receipt dutifully records `hr-2026.1`. Nothing in the store knows what that label means, and nothing checks that training must be a boolean. The journal is more useful now, but it must not overclaim. It says Pat submitted this statement in this named context. It does not establish that the context was applied, much less preserve the definition of that context. Those are two separate missing pieces, not something a metadata field can supply on its own. ## What this version leaves out IDs are local to this store. There is no persistence, authenticated host, authorization policy, command-body fingerprint, or external delivery. Rejecting a duplicate commit doesn't make an email or payment exactly-once. Such effects need their own host-side delivery and retry contract. Rules are still labels. Attribute values remain unchecked beyond their primitive TypeScript types. We have preserved the submitted evidence and its claimed author, not validated the business claim. ### This is what Triplex does Triplex's real command envelope and journal commit command identity, attribution, changes, and receipts atomically. Duplicate command IDs fail with the library's own typed error; they are not silent successes. See [Operational primitives](/operational-primitives) for the actual contract and [Host integration](/host-integration) for the authentication and delivery responsibilities that remain outside the database. Our three-field command and `StoreError` are not those public APIs. ## Next: check the world we're about to publish We can say who submitted the string `"yes"` and prove that a retry did not submit it twice. We still accepted the wrong kind of value. And checking one value won't prevent two workers from claiming the same employee code. [Part 5](./05-a-shape-is-not-an-invariant) puts schemas and cross-fact constraints inside the transaction. The [source and tests](https://github.com/bjacobso/triplex/tree/main/docs/snippets/blog/remembers-why/04) check the receipt, exact duplicate error, competing commands, failed-ID reuse, copy isolation, and the unenforced rule label. [Back to the series](./). --- # A shape is not an invariant Part 05 of [The Database That Remembers Why](./) · Act II, Rules · November 8, 2026 ```ts import { Effect } from "effect"; import { correction } from "./story.ts"; export const answer = Effect.runSync(correction); // answer.message: // Expected boolean // at [":worker/trained"] // answer.journal.length -> 1 // answer.receipt -> undefined ``` Maria's correction fails. The journal still has the onboarding transaction, and no receipt claims the correction committed. We finally reject the string `"yes"` instead of storing it as a training value. [Part 4](./04-someone-did-this) made the author and command visible without enforcing their named rules. This time we'll decode incoming changes and check the world they would create, using Effect Schema. It is **teaching code** on Effect 4.1.1, not Triplex's configuration or transaction API. ## A value can have the right shape in the wrong world Start with a worker's shape: ```ts export const goodShape = { ":entity/type": "Worker", ":worker/code": "M-17", ":worker/trained": true, }; export const shapeOnly = Schema.decodeUnknownEffect(Worker)(goodShape); // This shape is valid for Maria, and for a second worker. // Shape alone cannot make the code unique across entities. ``` That object has a worker marker, an employee code, and a boolean training value. Decoding it succeeds. Giving the same object to a second worker would succeed too. A Schema decoder seeing one object cannot know that another entity already claims `M-17`. We'll enforce two different contracts: **shape**, including required attributes and their types, and **uniqueness** of employee codes among workers valid at the same instant. Codes are case-sensitive in this model. Display names aren't unique keys; Maria and another Maria may share a name. ## Decode changes, then decode entities Here are the schemas and the earlier pieces they reuse: ```ts import { Effect, Schema, Semaphore } from "effect"; import type { Pattern, Value } from "../01/facts.ts"; import { StoreError, replay } from "../02/log.ts"; import { readAt, wallClock, type Basis } from "../03/temporal.ts"; import { accept, type Command, type Receipt, } from "../04/commands.ts"; const Fact = Schema.Struct({ id: Schema.String, entity: Schema.String, attribute: Schema.String, value: Schema.Union([ Schema.String, Schema.Number, Schema.Boolean, ]), validFrom: Schema.Number, validTo: Schema.optionalKey(Schema.Number), }); export const Changes = Schema.Array( Schema.Union([ Schema.Struct({ op: Schema.Literal("assert"), fact: Fact }), Schema.Struct({ op: Schema.Literal("retract"), id: Schema.String, }), ]), ); export const Worker = Schema.Struct({ ":entity/type": Schema.Literal("Worker"), ":worker/code": Schema.String, ":worker/trained": Schema.Boolean, }); ``` `Changes` decodes the operation envelope and primitive fact values from `unknown`. `Worker` describes the assembled entity, not one triple. A string is a legitimate primitive fact value, but not a legitimate training value. Those checks occur at different levels. For this version every entity is a Worker, and every attribute has exactly one value. The `:entity/type` sentinel is our teaching convention, **not** Triplex's entity-type registration API. We are not yet designing a general configuration language. Assemble facts by entity and check both contracts: ```ts export const checkEntities = Effect.fnUntraced(function* ( facts: ReturnType, ) { const entities = new Map>(); for (const { entity, attribute, value } of facts) { const body = entities.get(entity) ?? (Object.create(null) as Record); if (Object.hasOwn(body, attribute)) { return yield* new StoreError({ message: `multiple values: ${entity} ${attribute}`, }); } body[attribute] = value; entities.set(entity, body); } const codes = new Set(); for (const body of entities.values()) { const worker = yield* Schema.decodeUnknownEffect(Worker)(body, { onExcessProperty: "error", }); const code = worker[":worker/code"]; if (codes.has(code)) { return yield* new StoreError({ message: `worker code already used: ${code}`, }); } codes.add(code); } }); ``` Two live assertions for the same attribute are an error even if their values agree. Required fields and value types come from `Worker`. Unknown attributes are rejected rather than silently stripped. The null-prototype object lets even an incoming attribute named `__proto__` reach that check as data, instead of interacting with an inherited property. The set of codes is shared across the entities being checked. That is the piece a shape decoder alone couldn't supply. ## “Now” is not the whole timeline Checking the state at commit time is tempting and wrong. Suppose training is required, but its assertion expires tomorrow while the worker's other attributes continue. Today's shape is valid. Tomorrow's is missing a field. Our interval model changes only at assertion starts and ends. Check each boundary in the proposed recorded-time view: ```ts export const checkTimeline = Effect.fnUntraced(function* ( log: ReadonlyArray, ) { const boundaries = new Set( replay(log).flatMap((fact) => fact.validTo === undefined ? [fact.validFrom] : [fact.validFrom, fact.validTo], ), ); const recordedAt = log.at(-1)?.recordedAt ?? 0; for (const validAt of [...boundaries].sort((a, b) => a - b)) { const facts = readAt(log, {}, { validAt, recordedAt }); yield* checkEntities(facts); } }); ``` At an end boundary the ending assertion is already absent; an assertion starting there is already present. This checks every distinct state on the timeline without polling every millisecond. Before the first boundary there are no entities to validate. The scan uses the latest recorded view, so withdrawn evidence doesn't create an extra current entity. Older recorded views are left intact. All this is deliberately brute-force: replaying every boundary is a clear teaching algorithm, not a performance claim about Triplex. ## Publish only after the checks succeed Keep Part 4's ID check and receipt creation. Validate the proposed journal before making it the store's journal: ```ts export const makeStore = (clock = wallClock) => { let log: ReadonlyArray = []; const lock = Semaphore.makeUnsafe(1); const transact = (command: Command, input: unknown) => lock.withPermit( Effect.gen(function* () { const changes = yield* Schema.decodeUnknownEffect(Changes)( input, { onExcessProperty: "error" }, ); const now = yield* clock; const receipt = yield* accept(log, command, changes, now); const proposed = [...log, receipt]; yield* checkTimeline(proposed); log = proposed; return structuredClone(receipt); }), ); const receipt = (id: string) => Effect.sync(() => structuredClone(log.find((tx) => tx.command.id === id)), ); const match = (pattern: Pattern, basis: Basis) => Effect.sync(() => readAt(log, pattern, basis)); const journal = Effect.sync(() => structuredClone(log)); return { transact, receipt, match, journal }; }; ``` Decoding, ID claiming, temporal validation, uniqueness, and append share one permit. Otherwise two callers could both observe an unused code and publish individually valid but jointly conflicting workers. A rejection leaves facts, receipt, and transaction position untouched. There is no “compensating” transaction to erase a half-accepted command. The failed command ID remains available for a corrected attempt. ## Try the correction again Onboard Maria with code `M-17`, then replace her boolean training value with a string: ```ts export const onboard = (entity: string, code: string) => ( [ [":entity/type", "Worker"], [":worker/code", code], [":worker/trained", true], ] as const ).map(([attribute, value], index) => ({ op: "assert" as const, fact: { id: `${entity}:${index}`, entity, attribute, value, validFrom: JAN_1, }, })); export const correction = Effect.gen(function* () { const store = makeStore(Effect.succeed(MAR_10)); yield* store.transact( recordTraining, onboard("worker:maria", "M-17"), ); const command = { ...recordTraining, id: "correct:maria" }; const error = yield* store .transact(command, [ { op: "retract", id: "worker:maria:2" }, { op: "assert", fact: { id: "training:corrected", entity: "worker:maria", attribute: ":worker/trained", value: "yes", validFrom: JAN_1, }, }, ]) .pipe(Effect.flip); return { message: error.message, current: yield* store.match( {}, { validAt: MAR_2, recordedAt: MAR_10, }, ), receipt: yield* store.receipt(command.id), journal: yield* store.journal, }; }); // The failed correction leaves Maria trained, one journal // entry, and no receipt for correct:maria. ``` The opening error comes from Schema: `Expected boolean`, followed by ` at [":worker/trained"]`. `Effect.flip` makes the expected failure a value so the story can inspect it. The stored training remains `true`; the journal has one entry, and `correct:maria` has no receipt. Now give Lee the same employee code: ```ts export const duplicateCode = Effect.gen(function* () { const store = makeStore(Effect.succeed(MAR_10)); yield* store.transact( recordTraining, onboard("worker:maria", "M-17"), ); const error = yield* store .transact( { ...recordTraining, id: "onboard:lee" }, onboard("worker:lee", "M-17"), ) .pipe(Effect.flip); return { message: error.message, journal: yield* store.journal, }; }); // message: "worker code already used: M-17" // The journal still has one entry. ``` Both worker shapes would decode, but the proposed world fails with `worker code already used: M-17`. Again the journal has one entry. The tests also race two code claims, then retry the loser with a distinct code: one initial commit wins, and the corrected attempt gets position 2. ## What this version leaves out One entity type, three required attributes, scalar values, and one unique key. No references, deletions with cascading policies, schema registry, or incremental constraint indexes. Entirely retracting an entity removes it from validation; there is no separate existence record. Most importantly, this validator checks the **whole proposed timeline** against today's hard-coded rules. It doesn't distinguish newly introduced violations from existing ones and offers no migration or grandfathering policy. Changing the decoder can make the next write fail because of an unrelated old entity. That is a limitation, not automatic correctness. ### This is what Triplex does Real Triplex describes attributes and entity types with Effect Schema and checks opted-in constraints at the transaction boundary. Its enforcement policy rejects new or worsened violations rather than blindly using this full-state teaching check. See [Configuration](/configuration) and the [configuration walkthrough](/configuration-versioning) for actual schemas, constraints, releases, and enforcement modes. [Current state](/current-state) remains the maturity contract. ## Next: which rules accepted this fact? The receipt still says `hr-2026.1`, but the executing rules live in a mutable source file. Change `Worker` tomorrow and that label explains nothing about what accepted yesterday's transaction. We need to identify and retain the rule definitions, not just remember a name. **Part 6, “Rules are releases,” is coming.** It will give immutable rule content an identity and separate it from the movable reference that chooses what accepts the next write. This installment stops here for review. The [source and tests](https://github.com/bjacobso/triplex/tree/main/docs/snippets/blog/remembers-why/05) check quoted results, invalid shapes, excess attributes, cardinality, future expiry boundaries, concurrent uniqueness, and atomic rejection. [Back to the series](./). --- # 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`. Custom `StorageAdapterService` implementations must provide `recordedPositionAt(instant, head)` and `dependencyStateAll(resolvedBasis)`. The former returns the greatest committed journal position at or before the timestamp, bounded by the already captured head (zero when empty). Use indexed journal lookups and covering temporal indexes, without scanning journal envelopes or fact payloads. `TriplesLive` captures and validates the public recorded basis and resolves future timestamps to its captured head. The all-attribute dependency read includes historical assertions and retractions and future valid-time starts and ends; an empty fixed dependency array still means no dependencies. Host-owned SQL schemas must apply migration v9 before using these reads. Decorators that spread the inner service retain both reads automatically. 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 standalone PostgreSQL convenience layers expose only `Triples`, matching KV. PostgreSQL's ambient-client constructors require `SqlClient` and expose only `Triples`; its database-bound constructors expose exactly `Triples | SqlClient` for host transactions. Code that needs implementation services must compose their layers explicitly rather than retrieve them from a convenience layer. See [Host integration](/host-integration) for sharing a host-owned SQL client. 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. ## Backend and API policy New surface targets in-memory KV, SQLite and PostgreSQL. KV and SQLite are supported; PostgreSQL remains a pre-1.0 production candidate. Cloudflare and FoundationDB are frozen, experimental backends: they must keep compiling and their existing tests must keep passing, but new features do not have to cover them. Triplex is greenfield before 1.0. Breaking public API changes are allowed when they make the API right. Update callers and record each break in a Changeset rather than retaining compatibility shims, deprecations or opt-in flags for old shapes. ## 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 one tarball 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 ship as experimental, frozen subpaths; their implementation workspaces remain private. - 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 the single public package with a tokenless `next` release and test the registry package 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 of a target backend, validate its 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 experimental and frozen, preserving compilation and existing tests without requiring new surface coverage. ## 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. - Delivered: [commit validators](/commit-validators), the host's in-transaction seam for domain rules over db-before and db-after views, including Datalog. They return typed rejections and can write durable rejection records. - Next: explicitly modelled general Datalog invariants. Observation mode remains available for migrations and audit. Commit validators are the escape hatch until then. ## 1a. Sensitive values **Goal:** Hold regulated personal data without plaintext at rest or accidental disclosure. - Delivered: content-addressed `Attribute.sensitive` marking, the `sealed` value type, pluggable `ValueCodec` and `KeyProvider` services with identity and AES-256-GCM codecs, the sealing `Triples` decorator, blind-index equality and joins in every engine, planning-time `SensitiveQueryError`, uniqueness over blind tokens, sensitivity-aware derivations and overlays, SQL v3 and Cloudflare v4 migrations, and shared conformance with raw-storage plaintext scans. See [Sensitive values](/sensitive-values). - Next: erasure that scrubs an erased subject's blind tokens through a backend maintenance operation and records an erasure fact; in-place rewrap under rotated data keys; dual-version blind lookups for blind-key rotation; and an explicit host opt-in for opening sealed values in `ConfigRuntime` rules. ## 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. - Delivered foundation: runtime-wide commit validators let a host enforce write authorization (for example, per-attribute asserter rules) inside the transaction. Triplex deliberately ships no declarative policy grammar. - 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. The [durable runner](/runner) delivers diffs, retries handlers and replays expiry-driven transitions 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. --- # Blog Working code, the questions it cannot answer yet, and the decisions that shape Triplex. ## The Database That Remembers Why **A twelve-part build-in-public series · Effect TypeScript** Facts change. Rules change. The record of what happened shouldn't. Build a tiny bitemporal fact store from first principles, discovering why each piece exists by watching the previous version fail. [Explore the series](/blog/remembers-why/) or start with [Part 1: A fact is a triple](/blog/remembers-why/01-a-fact-is-a-triple). ### Latest · November 8, 2026 **[05 · A shape is not an invariant](/blog/remembers-why/05-a-shape-is-not-an-invariant)** Decode values with Effect Schema, then check the proposed timeline inside the transaction. A valid worker shape doesn't make an employee code unique. **[04 · Someone did this](/blog/remembers-why/04-someone-did-this)** Commit the actor, command ID, rule context, and receipt with the facts. A lost response must not turn a retry into another business event. **[03 · Two clocks, two questions](/blog/remembers-why/03-two-clocks-two-questions)** Was Maria trained on March 2, as known then and as known now? A late correction changes our view of the past, not the record of our earlier belief. **[02 · Nothing is overwritten](/blog/remembers-why/02-nothing-is-overwritten)** Assertions, retractions, and one atomic append. Correct the answer without erasing the statement that made the earlier answer possible. **[01 · A fact is a triple](/blog/remembers-why/01-a-fact-is-a-triple)** An entity, an attribute, a value. Put them in a Map, ask a few questions, then make a correction. The current answer is right. The evidence is gone. --- # The Database That Remembers Why **Building a bitemporal fact store from first principles with Effect TypeScript.** Facts change. Rules change. The record of what happened shouldn't. This twelve-part build-in-public series starts with a Map and ends with a small compliance host. Each installment adds one missing piece, then asks a question that version cannot answer. Parts 1–5 are published; Parts 6–12 are coming. [Start with a fact](./01-a-fact-is-a-triple), or continue with [Part 5](./05-a-shape-is-not-an-invariant). ## The tension: an update destroys evidence Maria's training record says she was qualified. Someone corrects it. A month later, an auditor asks why she was assigned to a site on March 2. Today's row is not an answer. We need what was true on March 2, what we believed when we assigned her, who recorded it, and which rules accepted it. Updating in place destroys exactly the evidence needed to answer those questions. That makes **“why?” the most expensive question a back-office system gets asked**: someone has to reconstruct a decision from whatever survived. The bet here is that history, time, and rule identity belong in the data model—not in a retrospective logging project. We will earn that design one failure at a time, rather than begin with a finished database's vocabulary. ## Act I · Facts Give a statement an identity, a history, two clocks, and an author. ### 01 · [A fact is a triple](./01-a-fact-is-a-triple) **Published.** Entity, attribute, value. Store facts in a Map, query by pattern, and watch a correction erase the evidence it replaced. ### 02 · [Nothing is overwritten](./02-nothing-is-overwritten) **Published.** Assertions, retractions, and an atomic transaction log. Preserve the earlier statement without pretending it is still the current answer. ### 03 · [Two clocks, two questions](./03-two-clocks-two-questions) **Published.** Valid time and recorded time. Ask “was Maria trained on March 2?” as known then and as known now. A late correction changes one answer, not the record of the earlier belief. ### 04 · [Someone did this](./04-someone-did-this) **Published.** Commands, actors, idempotent command IDs, receipts, and the journal. Record who acted and the rule context supplied with the command; Part 6 will give those rules immutable release identities. A retry must not become a second business event. Attribution records a claim about an actor; authentication stays with the host. ## Act II · Rules Give facts a checked shape, give rules an identity, and make work queryable. ### 05 · [A shape is not an invariant](./05-a-shape-is-not-an-invariant) **Published.** Attributes and entity types described with Effect Schema. Decode values at the boundary; enforce cross-fact constraints inside the transaction, where a concurrent writer cannot invalidate the check. ### 06 · Rules are releases **Coming.** Immutable, content-addressed rule versions and a movable `live` ref. Pin the accepting release so old decisions remain explainable under their rules, without claiming old facts satisfy today's rules. ### 07 · A query is data **Coming.** A small Datalog interpreter with shared-variable joins and negation. Ask for related facts and missing evidence without hard-coding every question as another method. ### 08 · Obligations are queries, not queues **Coming.** Derive candidates from facts and reconcile added, removed, and changed obligations. The host owns tickets, delivery, and workflow state. ## Act III · Operations Make the model survive time, persistence, and questions from other programs. ### 09 · Time moves without writes **Coming.** An expiring certificate changes an answer even when nobody writes. Find the next temporal boundary and let the host schedule a wake-up instead of polling. ### 10 · Storage is an Effect layer **Coming.** Run the same teaching contract in memory, on SQLite, and on PostgreSQL. Separate storage substitution from claims about durability, isolation, and operational readiness. ### 11 · Show your work **Coming.** Carry source provenance on every supported candidate, expose as-of HTTP reads, and provide a JSON CLI for agents. Say where explanations are incomplete instead of manufacturing certainty. ### 12 · A host, not just a database **Coming.** Bring the pieces together around the repository's [compliance host](https://github.com/bjacobso/triplex/tree/main/examples/compliance-host). Show the real Triplex implementation alongside the teaching model, and where [WorldVM](https://worldvm.com/) and [Forma](https://github.com/bjacobso/forma) pick up execution and policy-language concerns. They are related projects, not hidden prerequisites. ## How this relates to Triplex This is teaching code, written from scratch, not an alternate public API. Later parts reuse earlier parts and replace pieces only when their limits become visible. The code lives in [docs/snippets/blog/remembers-why](https://github.com/bjacobso/triplex/tree/main/docs/snippets/blog/remembers-why). Published code blocks are imported from named regions of tested files; CI type-checks them and tests the values, outputs, and errors quoted in the posts. When a post says **“this is what Triplex does,”** it links to the real library's guides. Use [Getting started](/getting-started) to build with Triplex today, and [Current state](/current-state) for its maturity contract. Triplex is pre-1.0: in-memory KV and SQLite are supported, PostgreSQL is a production candidate, and Cloudflare and FoundationDB are experimental. Preserving records is not the same as proving they were true. Triplex does not supply authentication, authorization, workflow delivery, or an independent witness to an actor's claims. Those boundaries stay visible as the series grows. --- # 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 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. --- # RFC: the schema-bound `Database` service Status: proposed, P2.5; implementation waits for owner approval in D3. This document adds no exports or runtime behavior. All code below is proposed API or illustrative composition, not a checked example of an available client. The underlying operations are available unless explicitly identified as a future integration. The client makes the safe path short: one attributed command, one immutable release, one enforced transaction, guarded read-before-write operations, and decoded entity reads. It does not replace `Triples`, add a write path, infer authorization, or run a workflow. WorldVM selects one database per world and owns release promotion. ## 1. Location, schema, and layer construction Export `Database` (the Effect service tag), `layer`, and the client types from a new core subpath, `@triplex-build/triplex/database`. Keep the root export unchanged for this implementation. Core remains browser-safe, ESM and Effect-only: no Promise facade, backend imports, Node APIs, connection pools, or host selection inside the client. Target KV, SQLite and PostgreSQL; frozen experimental backends retain their existing compilation and tests without a new-feature promise. ```ts import * as Database from "@triplex-build/triplex/database"; const Model = { types: [Site, Worker, Placement], objects: [], // additional ConfigNode values: constraints, policies, forms, etc. } as const; const Store = ConfigStore.layer.pipe( Layer.provideMerge(SqliteTriples.layer({ filename: "world.db" })), ); const World = Database.layer({ schema: Model, ref: "live" }).pipe(Layer.provide(Store)); ``` `layer({ schema: { types, objects? }, ref? })` requires the existing `Triples` and `ConfigStore.ConfigStore` services and produces only `Database`. The host must provide a ConfigStore over the same database as Triples, once per world lifetime. Substitute `KvTriples.layer` or `PgTriples.layerFromUrl(...)` for SQLite above; layer construction never decides backend support or database identity. Sensitive models require the host's existing `Sensitive.layer` and codec composition below the client; the client does not invent a codec or open values. Build the declared graph with `EntityType.nodesOf(...types)` and the supplied objects. Deduplicate identical `(kind, key, cid)` nodes; reject conflicting logical identities. Include reference-target closures through `nodesOf`, not just the types written directly. Local aliases are TypeScript handles, not persisted identities. Additional constraints must be ordinary versioned graph-constraint config nodes, not a parallel unversioned array of runtime rules. **Recommendation: check compatibility and refuse mismatch; never publish at startup.** Resolve `ref` (default `live`) during layer acquisition. Walk the resolved snapshot and require every declared logical node to exist with the same content ID, including its dependency closure. Additional release objects are allowed. This is deliberately conservative compatibility: no structural subtyping, automatic migration, or claim that two different constraint graphs are equivalent. A changed supplied type, requiredness, sensitivity, attribute definition, policy or constraint fails the check. Alias-only changes do not change content identity. Enforce _all_ constraints in the resolved release, including objects beyond the local declarations. A missing ref or mismatching declaration fails acquisition before serving requests. The host publishes with `config.commit({ label, objects, ref })` or promotes with `config.setRef`; neither is a client method. Automatically publishing on startup would let an old WorldVM worker move a world's release backwards. Comparing the entire supplied graph with the entire release would unnecessarily reject unrelated host-owned policy additions. Exact node compatibility avoids both outcomes. Resolve and check again at each new command or ordinary read. Cache compatibility and `GraphConstraint.pinned` results by immutable root identity, never by ref name. Compatible ref changes take effect on the next call; incompatible changes fail loudly until the host provides the matching model/layer. A command admitted under release R finishes under R even if the host moves `live` during its callback. This is an admission-time pin, not a commit-time ref fence. WorldVM must drain in-flight commands if promotion requires that stronger boundary. No hidden config-ref writes or private config-entity reads are introduced to implement fencing. ## 2. Commands and their builder ```ts const program = Effect.gen(function* () { const db = yield* Database.Database; return yield* db.command( { actor: "user:dana", commandId: "create:placement:maria-harbor", correlationId: "request:7f3a", }, (tx) => Effect.gen(function* () { yield* tx.put( Placement, placementId, { worker: maria, site: harbor }, { validFrom: MAR_2 }, ); }), ); }); ``` `command(meta, build)` returns `Effect` when the callback returns `Effect`. Keep `commandId`, `correlationId` and `causationId` spellings identical to TransactionMeta, rather than introducing `id` aliases. `actor` and `commandId` are required nonempty strings. Correlation and causation are optional strings copied verbatim. Actor is attribution, not proof of authentication; the host authorizes before calling. The callback's value is not returned or persisted, so a retry can return exactly the same receipt shape. Every builder operation is an Effect returning `void`, except `get` and `fact`. Execute sequentially in the callback's fiber; concurrent use, use after callback completion, or detached use fails validation. A failed callback submits nothing. The builder collects operations and preconditions; it never calls `transact` early. | Builder call | Meaning | | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `put(Type, entityId, values, interval?)` | Append `Type.assert(...)` operations, including entityType and one shared interval. This is assertion, not implicit upsert or full replacement. A second single-valued assertion can fail constraints. | | `create(Type, entityId, values, interval?)` | The same assertions plus `{ _tag: "EntityAbsent", entityId }`; strict creation across all valid time. No prior read is needed. | | `get(Type, entityId, { validAt? }?)` | Decode persisted state and retain an EntityState guard as specified below. Missing state returns `null` but still guards the observed fact set. | | `fact(Type, tripleId)` | `Triples.get`, verify type and declared attribute, require unretracted state, return the raw Triple for a temporal verb. No automatic decryption. | | `end(Type, fact, at)` | Collect `Type.end(fact, at)`. | | `replace(Type, fact, oneAliasValue, at)` | Collect `Type.replace(fact, oneAliasValue, at)`. | | `correct(Type, fact, oneAliasValue)` | Collect `Type.correct(fact, oneAliasValue)`. | | `retract(Type, fact)` | Collect `{ op: "retract", id: fact.id }` and a matching TripleLive guard after type/attribute validation. Explicit whole-interval removal, not an end date. | | `inbox.append(input)`, `outbox.append(input)` | Collect `{ op: "append-entry", kind, ...input }`; input is existing `{ scope, idempotencyKey, payload }`. | | `inbox.acknowledge(id)`, `outbox.acknowledge(id)` | Collect `{ op: "acknowledge-entry", kind, id }`. Delivery and retries stay host-owned. | Use the existing `EntityType.assert` value encoding and interval semantics. Omitted validFrom stays omitted so the engine assigns the same commit instant; explicit validFrom/validTo apply to every generated fact. Reject empty values, undeclared aliases/types, invalid intervals, and repeated edits to the same source fact before submission. Required-field/graph validity is decided by enforcement, not a second client validator. `put` accepts optional fields as today's assertion builder does. There are no implicit entity shells: empty entities are not representable. Temporal verbs delegate to the existing pure builders without altering their operations. `end` preserves the past, `replace` preserves the past and original end, and `correct` replaces over the source interval. Combine independent changes with `Change.combine` in caller order and forward _all_ generated preconditions. A caller-supplied source Triple is allowed; its TripleLive guard still tests that exact assertion at commit. Sensitive past-value reuse follows the existing same-transaction retract rule in `Sensitive.layer`. ### Reads inside a command There is **no read-your-writes overlay in P2.6**. Queued writes are invisible to `tx.get` and `tx.fact`; another read after `put` still reads persisted state. Keep new values in local variables. P3.3 preview may later evaluate the compiled command against an overlay, without changing this command contract implicitly. `tx.get` calls `Triples.history(entityId)` once on first use of that ID. From that one result select all unretracted facts, across _all valid intervals_, and retain their IDs as `{ _tag: "EntityState", entityId, tripleIds }`. Filter this same fact set by the requested validAt (default captured command-start Effect Clock time), then decode the selected type. Cache this observation per entity for the callback; different validAt reads filter the same observed set. This avoids incorrectly using only today's visible IDs as a complete EntityState guard. Future-valid assertions and past intervals matter to that guard. Empty history gives an empty-set guard. The observation is latest recorded state when fetched, not a historical basis. Different entities can be fetched at different instants; their complete guards must all hold inside the serialized transaction. There is no database-wide read snapshot promise. `tx.fact` alone does not guard an unused read; it registers a TripleLive guard when consumed by a retracting verb, because the primitive requires a matching retraction. For a decision depending on other fields, use `tx.get`. There are no builder `list`, Datalog or historical recorded-time reads: automatic entity guards cannot protect arbitrary query absence or predicate phantoms. Use commit validators for such business checks when P2.2 is available. ### Submission, retries and explicit escape Validate meta, then call `transactionByCommand(commandId)` **before** resolving live or running the callback. If found, return that TransactionRecord unchanged, including its original actor/release, even if live has since changed. Command IDs are database-wide identities, not actor-scoped keys; reusing an ID with a different payload does not execute that payload. This matches the existing receipt guarantee; there is no persisted request hash or invented mismatch detection. Otherwise resolve/check the release, run the builder once, and submit exactly one `transact(ops, { ...meta, ...pinned, preconditions })`. Here `pinned` is precisely `yield* GraphConstraint.pinned(release)`; clients cannot supply configSnapshot or enforce overrides. On success read `transaction(result.txId)` and return that stored record. If the atomic command claim loses with CommandAlreadyCommittedError, read `transactionByCommand` again and return the winner. A missing receipt after a reported success/duplicate is a read failure, never an empty success or a second write. If that read fails, retrying the command ID recovers the committed receipt. Only duplicate-claim handling is automatic. Conflicts fail; the host retries the whole command with the same ID and fresh observations. User callbacks must contain no irreversible external effects: two competing callers may both build although only one commits. Express outgoing intents through the outbox. Interruption or a lost commit response can leave an uncertain outcome; recover by command ID. There are no `enforce: false`, `unguarded: true`, or unpinned client modes. Opting out means explicitly acquiring the existing lower-level `Triples` service in host or migration code and writing the desired operations/meta there. This is a deliberate escape hatch, not a compatibility flag. Raw client Datalog escapes model syntax, but never schema checking. Authorization is not guaranteed by hiding Triples. ## 3. Reads, typed queries, pagination and `asOf` `db.get(Type, entityId)` returns `Effect | null, DatabaseError>`. Resolve/check one release, call `Triples.entity(entityId, basis)`, select facts whose entityType equals Type.entityType, then call P2.3's planned `Type.decode`. Return `{ entityId, values: decoded }` so IDs cannot collide with a field alias. No selected facts means `null`; malformed facts are failures, not absence. The decoder owns alias mapping, one/many cardinality and sealed-value representation. P2.6 must use its approved exported result type rather than independently copying HTTP's encoder or asserting that encrypted values are plaintext. Until P2.3 lands, this typed return is a dependency, not a shipped capability. Do not pick a latest value to hide corrupt single-valued state. `db.list(Type, { limit?, cursor? }?)` returns `{ entities, snapshot, nextCursor? }`. Call `Triples.entityPage({ entityType: Type.entityType, limit, cursor, basis })` and decode each body identically to get. Preserve entity-ID order, the backend snapshot, and page boundaries. Default 100, maximum 1,000; never fetch every entity and slice in memory. No relationship expansion or implicit unbounded list. Typed queries use existing model clauses and an explicit Effect Schema projection: ```ts const OpenTraining = { find: ["?worker", "?site"], where: [ ...Placement.where("?placement", { worker: "?worker", site: "?site" }), Worker.not("?worker", { trainedFor: "?site" }), ], } as const; const Row = Schema.Struct({ "?worker": Schema.String, "?site": Schema.String }); const read = Effect.gen(function* () { return yield* db.query(OpenTraining, { decode: Row, pageSize: 100 }); }); // page.results: readonly { readonly "?worker": string; readonly "?site": string }[] ``` `Type.where` encodes typed constants through handles and expands aliases to the same clauses a caller writes by hand; `Type.not` builds the existing negation. These arrays do not carry enough information to infer projection types, optional bindings, aggregates, or a complete entity object. Require an explicit projection schema and decode each flattened backend result binding with it. References in Datalog remain strings unless the projection explicitly decodes a brand. Entity objects come from get/list, not a hidden N+1 query behind every projected ID. `db.raw(query, { pageSize?, cursor?, debug? }?)` forwards ordinary Datalog without projection decoding. `db.rawPage(wrappedQuery, { debug? }?)` handles existing wrapped queries, including their pagination fields. Both stay SchemaBound. No client queryAll method: batch callers can explicitly use checked Triples.queryAll. `query` uses the same options as raw plus required `decode`; it changes only the result bindings, retaining backend count/debug/continuation fields. Each logical read must check against its **captured** release. Reuse `SchemaBound.layer({ ref })` over a request-local ConfigStore facade: its `resolveRef(ref)` returns that already-resolved immutable snapshot; all other methods delegate to the original service. Provide the original Triples beneath that decorator. This is ordinary public Effect layer composition and prevents a second live-ref resolution from checking against another release. ConfigStore's own reads continue using its underlying store, avoiding a decorator cycle. Get and list check the declared type before their direct entity reads; raw/typed queries use the existing SchemaBound validation, including reserved attributes and shared Datalog preflight. Do not copy the attribute checker or bypass Sensitive planning. For continuation, use a versioned client cursor envelope containing operation kind, release ID, local model fingerprint, and the backend's opaque cursor. Validate it, load that release with `snapshotById`, and check compatibility again. Decode and query with that captured release throughout pagination, even if live moves. Forward the inner cursor unchanged: it binds database scope, query/type, temporal basis and exact commit cut. A malformed envelope, changed model, missing release, wrong call kind, wrong query/basis or cross-database cursor fails with PaginationCursorError. The envelope is not an authorization token. First pages resolve live; continuation pages do not substitute live for the captured release. The row decoder is supplied again on continuation; its validation still runs on every row. A failed decode fails the page, without returning partial success. ### A pinned read view ```ts const read = Effect.gen(function* () { const then = yield* db.asOf({ validAt: lastTuesday }); const placement = yield* then.get(Placement, placementId); const knownThen = yield* db.asOf({ recordedPosition: receipt.position, validAt: MAR_2 }); }); ``` `asOf(basis)` is an **Effect**, unlike the review's synchronous sketch: acquiring a stable recorded cut and schema can perform reads and fail. It returns a read-only view with get/list/query/raw/rawPage and a `basis` plus `releaseId`; no command or nested asOf. Resolve/check live once at view creation and retain that snapshot. Capture omitted validAt once with Effect Clock and omitted recorded axis once with `Triples.currentPosition`. Explicit recordedPosition is a safe integer from zero through the captured head. Reject a future position, non-finite/negative time, and simultaneous recordedAt/recordedPosition. Normal db reads retain the engine's current-time defaults; the view supplies its fixed basis on every call. For recordedAt, normalize to an exact position using public operations today: capture head H, page `Triples.transactions({ after, limit: 1000 })` up to H, and select the greatest position at or below H whose instant is at or before recordedAt, or zero. Return `{ recordedPosition, validAt }`, never both recorded fields. This is an explicitly O(journal) fallback, not an efficient timestamp index or a new engine capability. The proposed `resolveRecordedBasis` in the [why RFC](./why.md) can replace this only after approval/implementation with equivalence tests. Existing position/omitted-axis view creation does not scan the journal. The pinned schema is the one selected at view acquisition, not automatically the release that happened to be live at the requested historical instant. Facts may have been written under several releases; there is no single inferred historical schema. For an old incompatible model the host supplies a matching layer/ref pointing to that immutable release. Cursor continuations must match a view's release and basis. This keeps historical data and configuration time distinct. ## 4. Compilation contract and proof obligations No client operation reaches adapters, KV keys, SQL tables, private config entities, or an internal transaction API. The following is the complete initial method set; helpers described here compile to ordinary values or the public calls listed. Clock reads, pure declaration checks, decoding and cursor encoding add no writes. | Client call | ConfigStore operations | Triples operations and pure compilation | | ---------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | layer acquisition | resolveRef(ref) | None; nodesOf plus logical-node/CID comparison. | | command, existing ID | None | transactionByCommand(commandId); return stored record. | | command, new ID | resolveRef(ref) | transactionByCommand; builder reads below; exactly one transact with GraphConstraint.pinned(release) and combined guards; transaction(txId). On duplicate claim only, transactionByCommand again. | | tx.put / tx.create | None beyond command pin | Type.assert; create adds EntityAbsent; no immediate write. | | tx.get | None beyond command pin | One history(entityId) per observed entity; filter unretracted IDs for EntityState, visible intervals/type for Type.decode. | | tx.fact | None beyond command pin | get(tripleId); validate source; return Triple. | | tx.end / replace / correct | None | Corresponding Type verb, hence Change.end/replace/correct, combined without rewriting operations or guards. | | tx.retract | None | One retract op and TripleLive; no retract-pattern expansion. | | tx inbox/outbox append/acknowledge | None | Existing append-entry/acknowledge-entry TransactOp values; all submitted with the same facts/meta. | | db.get / view.get | resolveRef/check once, or retained view snapshot | entity(id, basis), type selection and Type.decode. | | db.list / view.list | resolveRef first page, snapshotById on continuation, or retained view snapshot | entityPage with backend cursor; Type.decode for each body. | | db.query / view.query | Same release selection as paged reads | Captured-release SchemaBound.query, hence Triples.query with unchanged Datalog/options; Schema.decodeUnknownEffect per row. | | db.raw / view.raw | Same | Captured-release SchemaBound.query, no result decoder. | | db.rawPage / view.rawPage | Same | Captured-release SchemaBound.queryPage with wrapped query unchanged except unwrapping/rewrapping its cursor. | | db.asOf | resolveRef/check once | currentPosition; for timestamp only, bounded transactions pages through captured H; fixed Clock validAt; no entity reads/writes until view use. | Before P2.6 implementation, write failing tests using spy public services. Compare generated `TransactOp[]`, complete TransactionMeta, guards, Datalog objects and options with hand-written fixtures using deep equality, not merely equivalent query results. Include explicit and omitted intervals, all three temporal verbs against the sequences in [concepts](../concepts.md), one/many aliases, reference encoding, sealed past reuse, create, retract, both entry kinds and acknowledgement. Assert call order and zero transact on failed builds; duplicate fast path does no ref lookup/callback and the concurrent loser returns the exact winner receipt. Add shared KV/SQLite/PostgreSQL behavior cases for constraint rejection, atomic entry/fact rollback, complete EntityState guards including future facts and an insertion-only race, stale TripleLive, compatible/incompatible ref moves, same-ID retries and uncertain-response recovery. Read tests cover decoder failures, null, sealed values, model-constant queries versus hand clauses, unknown attributes, page boundaries/cross-world cursors, ref movement between pages, and view stability across same-millisecond commits and corrections. Prove persisted-only command reads after queued writes. A raw query must fail on a misspelled attribute before backend execution. No tests are added in this design-only item; those are implementation acceptance requirements, not claimed passing checks. ## 5. Error channel and diagnostic codes Use an explicit `DatabaseError` union of the actual read, transaction, config, canonical/schema and sensitive errors reached by the calls above, plus the approved P2.3 decode error type. Do not erase them into `unknown` or catch all failures as not-found. `command` additionally preserves callback error E unchanged; hosts own diagnostics for their own domain errors. Defects and Effect interruption stay outside the typed failure channel. Never catch a programmer defect and retry it. Normalize client input errors and known pure-builder exceptions to the existing ValidationError with a path and fix. Preserve the original cause locally. For config/decoding errors currently outside the public diagnostic module, wrap in ReadError for storage corruption or ValidationError for invalid input/data shape; do not send an unrecognized config error to diagnose and pretend `internal` is a useful recovery code. P2.6 must document these mappings in docs/errors.md and add diagnostic tests. This RFC requires no new stable code or error class. | Failure | Existing docs/errors.md code | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | Missing live ref, declared node mismatch, invalid meta/basis, unknown type/alias, empty put, builder misuse, invalid temporal split or typed source, projection/entity decode shape error | validation.failed; paths such as schema.types, meta.commandId, basis.recordedAt, command.replace.at, results[3]. | | Config persistence/corruption, history/entity/journal lookup failure, impossible missing receipt | read.failed, with cause retained locally and no silent null conversion. | | Unknown raw-query attribute | schema.unknown-attribute, retaining nearest-key fix/path. Unwrap the known SchemaBound match cause only if match is later exposed. | | Invalid Datalog, parsing, binding, projection, grouping, aggregates or predicates | Existing datalog.\* codes from diagnose, including grouping-specific subcodes; unchanged. | | Preconditions, serialization or deadlock | transaction.conflict.precondition / serialization / deadlock; only these are automatically eligible for a host retry. | | Post-state constraints | constraint.violation; retain violations and their validAt diagnostics. | | Operational key collision | operational.entry-conflict. | | Duplicate command claim | Handled as receipt recovery; low-level escape retains transaction.command-already-committed. | | Cursor or release continuation mismatch | pagination.invalid-cursor. | | Missing required source fact / already retracted source | triple.not-found / transaction.conflict.precondition respectively. Missing entity get is successful null. | | Other backend write/transaction/fact validation failures | Existing write.failed, transaction.failed, triple.invalid or triple.duplicate according to the underlying typed error. | | Unsupported sensitive planning / codec failure | sensitive.query / sensitive.value; never expose plaintext through errors. | Always use `diagnose` for recognized errors instead of duplicating code strings in transport implementations. Projection Schema errors are validation failures even when the Datalog itself was valid. Do not log query constants, values, payloads, sealed envelopes or arbitrary causes in diagnostics. ## 6. Future pieces slot into the same boundary - **P2.3 decode:** one decoder used by get/list/tx.get. Its eventual treatment of required missing fields and sealed values is authoritative; approval of this RFC cannot substitute for that implementation's result/error contract. - **P2.2 validators:** compose validators on the supplied Triples before providing Database. They run in its one transact boundary; no pre-commit Datalog check in the client substitutes for them. Follow settled D7: validators can see inbox and outbox entries read-only in the after-view. Preserve the approved CommitRejected error and add its stable diagnostic mapping when that error lands; no code for this planned error is claimed to exist in docs/errors.md today. - **why:** after the separate RFC is approved and implemented, a client convenience can delegate to Why.why with the view basis. Stop at transaction/release by default and follow causation only to the requested depth. Initial P2.6 exposes no placeholder why method and invents no provenance from decoded objects. - **P4.3 live queries:** a future watch takes the same query/projection definition and delegates feed/dependency/wakeup behavior to the approved live-query service. It must define release-change and decoder-failure events there. Static asOf views do not watch. No polling loop or subscription design is settled here. - **P3.3 preview:** reuse compiled ops/meta/guards and the existing overlay, then commit with the same command ID. No new write semantics, external delivery or authorization capabilities are implied by preview. ## 7. Documentation and adoption after approval Lead with the client in docs/getting-started.md, docs/agents.md, the homepage site-safety snippets, and examples/compliance-host's command/read handlers. Update configuration, concepts, operational-primitives, errors and host-integration pages to distinguish client defaults from explicitly managed Triples. Keep Datalog and custom-runtime documentation as the primitives reference. Update checked snippets, generated story outputs and HTTP examples where they demonstrate application composition; do not redesign HTTP response contracts merely to adopt a client. README.md waits for the owner's standalone-audience decision. The vendor-risk report stays historical; run a fresh P2.7 trial rather than editing its findings. In docs/agents.md, command rules 1, 2, 4, 7, 8 and 9 become client behavior (one transaction, required identity, pin+enforce, temporal guards and guarded tx.get). The guide should teach these defaults once and explain the lower-level escape. Rule 3's correlation/causation remains optional but recommended. Rules 5 and 6 remain domain modeling duties: shared intervals do not infer business dates or make referenced entities exist earlier. Release publishing, authorization, domain invariants, obligation reconciliation, checkpointing, delivery, temporal wakeups, retention and retries remain host duties. The vendor-risk expiry trap still needs detection and a timer; an enforced command cannot stop time passing. The client also does not change `_tx` valid-time joins: keep the documented two-stage audit workaround until D2's separate semantics are approved and built. ## 8. Site-safety example: before and after The model, synthetic IDs and MAR_2 are the existing declarations from [app.ts](../snippets/home/site-safety/app.ts). Both versions assume Site/Worker already exist at MAR_2. Both preserve a single command receipt and enforce required/reference constraints across valid-time boundaries. Publishing releases, derivation evaluation and HTTP serving remain the existing host code. The command region today (16 nonblank, non-comment lines, excluding region markers): ```ts export const placeWorker = ( release: InMemoryConfigStore.ConfigSnapshot, placement: EntityId, worker: EntityId, site: EntityId, ) => Effect.gen(function* () { const triples = yield* Triples; const facts = Placement.assert(placement, { worker, site }, { validFrom: MAR_2 }); return yield* triples.transact(facts, { actor: "user:dana", commandId: `create:${placement}`, correlationId: "request:7f3a", ...(yield* GraphConstraint.pinned(release)), }); }); ``` Against the client (12 such lines): ```ts export const placeWorker = (placement: EntityId, worker: EntityId, site: EntityId) => Effect.gen(function* () { const db = yield* Database.Database; return yield* db.command( { actor: "user:dana", commandId: `create:${placement}`, correlationId: "request:7f3a", }, (tx) => tx.put(Placement, placement, { worker, site }, { validFrom: MAR_2 }), ); }); ``` The reduction also removes the caller's release parameter. There are intentional behavior improvements: live is resolved and checked per command, and duplicate IDs return the original journal record rather than failing at transact. `put` preserves today's assertion semantics; use create if WorldVM needs strict creation. The existing audit region can stay unchanged beside this client, using the host's Triples and ConfigStore services. Its fact-level `trained` helper and client form are shown together below. They share the already-looked-up receipt: ```ts // Today: eight nonblank, non-comment lines, including the generator wrapper. const read = Effect.gen(function* () { const trained = (basis: TemporalBasis) => triples .match({ entityId: maria, attribute: Worker.trainedFor.key }, basis) .pipe(Effect.map((facts) => facts.length > 0)); const trainedAsKnownThen = yield* trained({ validAt: MAR_2, recordedAt: receipt.instant }); const trainedAsKnownNow = yield* trained({ validAt: MAR_2 }); }); ``` ```ts // Client: six nonblank, non-comment lines, including the generator wrapper. const read = Effect.gen(function* () { const then = yield* db.asOf({ validAt: MAR_2, recordedPosition: receipt.position }); const now = yield* db.asOf({ validAt: MAR_2 }); const trainedAsKnownThen = ((yield* then.get(Worker, maria))?.values.trainedFor.length ?? 0) > 0; const trainedAsKnownNow = ((yield* now.get(Worker, maria))?.values.trainedFor.length ?? 0) > 0; }); ``` Here the example assumes P2.3 returns an empty array for an absent many-valued alias, matching the intended entity decoder; adjust the expression to its approved shape if that differs. This replaces only the training-state calculation: actor, governedBy and liveToday still come from the existing journal/config audit code. No future why implementation is silently counted as a current read reduction. The open-work clauses remain exactly OpenTraining in section 3. For an ordinary bounded UI read, `yield* db.query(OpenTraining, { decode: Row })` replaces a raw Triples query and host projection casting. For task identity, provenance and recheckAt, keep the existing Derivation.make/evaluate path in app.ts; a decoded query page is not a derivation candidate or a scheduler. For runtime composition the existing Store is three nonblank lines for SQLite; the client adds one `Database.layer(...).pipe(Layer.provide(Store))` declaration (one line in this presentation). The model and release publication stay the same. Counts compare only the displayed region pairs, excluding imports, comments and blank lines; they do not claim that the whole app shrinks by these percentages or hide the once-per-world layer cost. HTTP/server lines are unchanged. ## 9. Owner questions before P2.6 Recommend approving the following together under D3; these are proposed details, not requests to revisit the settled Effect-only, WorldVM-first or backend decisions: 1. Approve conservative node/CID compatibility with extra release objects allowed, host-only publishing, and admission-time live pinning. Does WorldVM require a commit-time release fence instead of host draining? That would need a separate public config-ref precondition design before claiming such a guarantee. 2. Approve append-style put plus strict create, required actor/commandId, receipt-only command results, and persisted-only guarded entity reads inside callbacks. Automatic upsert or read-your-writes would require additional temporal/overlay semantics; neither should be inferred from the verb put. 3. Approve explicit Effect Schema query projections and no implicit entity hydration. This avoids pretending today's Type.where arrays encode a result type. 4. Approve effectful asOf, current-schema historical views and the public journal-scan timestamp fallback. If WorldVM needs frequent recordedAt reads, prefer approving the shared resolveRecordedBasis prerequisite from the why RFC before P2.6. 5. Approve the core database subpath and explicit Triples escape with no client safety-disable flags. Confirm adoption scope above; README continues to wait. This RFC alone has no breaking package change and needs no Changeset. P2.6 must record any public error-union/export changes in a Changeset, settle P2.3's concrete decode types, and integrate approved validator diagnostics when available. No owner approval, implementation completion or trial success is implied here. --- # RFC: valid intervals and transaction provenance in Datalog Status: proposed, awaiting owner approval of P4.1 and D2 (P1.3). This document specifies future behavior; none of its new query syntax is implemented. It changes query planning and execution, not the fact model or the transaction write boundary. > **Decision update — 2026-11-11 (D13):** Valid-time assertion bounds and numeric > interval-pattern bounds must be integer epoch milliseconds on all backends. This > replaces the original “no new integer-only time constraint” proposal below. > PostgreSQL keeps `valid_from` and `valid_to` as `BIGINT`: promoting them to double > precision regressed the cheap-precondition plan to an attribute scan with an > entity filter and would rewrite and lock existing tables during upgrade. > Migration 8 retains its interval history indexes without changing column types. ## Problem and recommendation WorldVM needs assertion interval bounds without a `history` or `match` read per entity. [The trial](https://github.com/bjacobso/triplex/blob/main/trials/vendor-risk/REPORT.md) finding 5 and issue #73 describe that gap. Silent trap 1 is related: a backdated review joins its assertion transaction at a past `validAt`, but the transaction's metadata starts at its later commit instant, so the join silently disappears. Current workarounds are in [Datalog](../datalog.md) and [Core concepts](../concepts.md#read-an-assertions-interval). Recommend an explicit object pattern for interval bindings, a query-wide `validTime: "all"` mode, and recorded-only evaluation of `:_tx/*` metadata. Keep one shared preflight and one recorded cut across the complete query, as required by [Architecture](https://github.com/bjacobso/triplex/blob/main/ARCHITECTURE.md). KV and SQLite remain supported; PostgreSQL remains a pre-1.0 production candidate. ## 1. Syntax and scope Keep `[e, a, v]` and `[e, a, v, tx]`. Add this alternative wherever a fact pattern is legal: ```ts // Proposed grammar fragment, not a currently executable public API example. { pattern: ["?exception", ":exception/status", "approved", "?tx"], valid: { from: "?from", to: "?to" }, } ``` `pattern` is exactly an existing three- or four-term tuple. `valid` must contain at least one of `from` or `to`. Each field accepts a valid variable identifier or a finite, non-negative epoch-millisecond number; `to` also accepts `null` to match an open end. An omitted field imposes no binding or constraint. Unknown keys, empty `valid`, invalid variables, six-term tuples, nested object patterns and invalid bound literals fail shared schema decoding. Numeric times follow the existing timestamp domain, not commit-position units; do not reinterpret seconds, ISO strings or `Date` objects. Do not introduce a new integer-only time constraint in this feature. Validate concrete paired bounds with the same interval checks as assertions. Add `validTime?: "point" | "all"` to `DatalogQuery`, defaulting to `"point"`. A wrapped query takes its mode from `inner`; there is no second wrapper mode. Read options continue to carry `basis`. This is a Datalog scope, not a new mode for `entity`, `match` or history. Alternatives considered: | Alternative | Assessment | | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `[e, a, v, tx, from, to]` | Compact but positional, requires a transaction placeholder, and makes omissions obscure. Reject. | | Fourth-term metadata object | Overloads the established transaction position and complicates existing tuple recognition. Reject. | | Separate interval lookup clause | Needs a fact identity binding and a second relation to avoid ambiguous same-value assertions. Reject. | | Binding bounds implicitly disables point filtering | A projection would change relation membership; an unrelated three-term join could still hide history. Reject. | | Object pattern plus explicit query-wide mode | Named, extensible fields; preserves old tuples and makes all-time scope visible. Recommend. | Allow object patterns in positive conjunctions, conjunctive `not` and the existing single-clause `or` alternatives. Existing variable-safety rules still apply: negation locals do not escape, and alternatives only filter outer bindings. Reject object interval patterns in recursive rule bodies in this first implementation. Existing binary rules still execute in the query's mode and can join ordinary identity endpoints; temporal endpoints are not new rule arguments. Reject `optionalProjection` with `validTime: "all"`: choosing one of several historical values would be ambiguous. Plain three-/four-term all-time queries remain legal without interval fields. ## 2. Temporal and value semantics ### Relation membership At one resolved recorded cut, a pattern returns assertions recorded by that cut and not yet retracted at that cut. Then: - In `"point"` mode, domain assertions must satisfy `validFrom <= validAt` and `(validTo is absent or validAt < validTo)`. Omitted `validAt` resolves to runtime now once. Binding an interval does **not** remove this filter. Bounds are the assertion's complete stored bounds, never clipped to the point or intersected with another assertion. - In `"all"` mode, skip the domain point filter for **every** pattern, including tuples, negation and rule scans. Do not resolve an implicit current valid time. An explicit `basis.validAt` is a preflight error with a fix to remove it or use `"point"`. - In either mode, transaction metadata follows section 3. All-time joins mean “these assertions coexist in the recorded view,” not “their valid intervals overlap.” A caller requiring overlap must bind bounds and express it explicitly, accounting for open ends. Do not synthesize intersection intervals, merge adjacent assertions, or collapse separate periods into one. Projection retains existing distinct-row semantics: project both bounds (and transaction where needed) to preserve distinguishable periods. Fact identity is not added by this RFC; assertions indistinguishable in the projected fields collapse. `recordedAt` and `recordedPosition` remain mutually exclusive public inputs. Position zero is empty; explicit positions do not gain a wall-clock constraint. Timestamp queries and default pages retain their existing captured position/timestamp intersection. All-time refers only to valid time: a retracted assertion is absent at the latest recorded cut even if it once held. Use an older recorded cut to recover the earlier belief, or history/journal APIs to enumerate recorded changes. An interval query is not an assertion/retraction event feed. ### Open ends, predicates and types Missing `validTo` means an interval unbounded above in the fact model. Project it as JSON `null`, not numeric infinity, a maximum date or Effect `Option`. Infer `from` as datetime/numeric and `to` as nullable datetime/numeric. SQL BIGINT decoding must return the same JavaScript numbers as KV and preserve null. No bound is sealed or a text identity. Extend predicate operands to accept literal `null`; do not make null a legal ordinary fact value constant or identity. Define portable two-valued equality: null equals null, null differs from a finite number, and all ordered comparisons involving null are false. Thus `["!=", "?to", null]` selects finite ends; `["<=", "?to", cutoff]` also excludes open ends. Open-endedness does not make `[">", "?to", cutoff]` true. This deliberately avoids infinity arithmetic and SQL UNKNOWN. Wrapper `is-null`/`is-not-null` work on projected ends; numeric wrapper filters use the same guard. Repeated variables impose equality within and across tuple and interval positions, irrespective of clause order. Two open ends unify; an open end cannot unify with a start. Numeric value and interval bindings unify by the existing number/datetime scalar family. A known string identity used as an interval variable is a preflight type error, including transaction or binary-rule endpoints; never cast it. Runtime mixed value families use existing guarded scalar equality. A repeated variable constrained to both start and end can match only equal finite endpoints, which valid nonempty assertions cannot have. Return no matches, rather than treating the terms as independent variables. Static incompatible ordered operands fail preflight; a nullable end is allowed and receives the runtime finite-number guard. Bounds can appear in `find`, predicates, `having`, grouping, ordering and wrapper filters. `count` counts distinct public bound values, including null as one distinct value. Implement that explicitly rather than relying on SQL `COUNT(DISTINCT nullable_column)`, which omits null. `sum`, `avg`, `min` and `max` use finite numeric inputs only, skip null, and retain input-row multiplicity. Groups with only open ends produce null for these numeric aggregates. Existing empty-input rules remain: ungrouped count zero and other aggregates null; grouped empty input no rows. No aggregate computes a union or duration of intervals. Ordering uses the existing typed total order, with finite bounds in the numeric family and open ends in the null family, not a special infinity order. Pagination deduplicates projected rows, completes the order with projected tie-breakers and captures one exact recorded cut. Include `validTime`, object fields, null operands and the resolved point (only in point mode) in query fingerprints. Reject cursor reuse across modes or changed bound filters; omitted continuation options inherit the captured basis. Version cursors when changing their canonical representation. ### Sealed values and service boundaries Intervals and assertion transactions are unsealed metadata. Permit binding, filtering and projecting them on sensitive attributes using existing permitted presence/blind-equality patterns. Do not allow a value variable to escape the sensitive layer by renaming it into an interval field. Shared variable/type analysis must retain sealed-value taint: projection, ordering, numeric comparisons, aggregation and reuse of a sealed value as a bound stay planning errors. Raw engines retain blind-token-only equality and null projection of sealed values. Schema-bound attribute validation must traverse object patterns in every permitted location, including wrappers; reserved transaction attributes keep their existing exemption. Dependency extraction must discover the wrapped tuple's attributes. Exact derivation provenance must reject new interval patterns and all-time mode until it can explain them faithfully, rather than silently ignoring new fields. This RFC does not widen its currently supported subset. ## 3. D2: transaction metadata is about recorded time Recommend that any matched row whose attribute starts with `:_tx/` ignore valid-time filtering and obey the query's recorded cut. This applies in positive patterns, negation, disjunction, rules, and dynamic-attribute scans. Classification is by the matched attribute, not by whether the entity variable happens to be named `?tx`, nor by a join-order inference. Non-reserved attributes on the same entity retain domain semantics. Metadata interval bindings, if requested, return stored commit-based bounds; they do not imply point membership. This makes a backdated assertion's actor available when the recorded cut includes its commit, without exposing future knowledge. The fourth tuple term always identifies the **asserting** transaction, including on a corrected, re-asserted bounded fact. It never changes to the retracting transaction. Retraction time, position and actor are not interval metadata: use the journal or history. `temporalView.ts` already clears later retraction metadata for explicit position reads; the new executor must similarly avoid exposing later retraction knowledge. | D2 option | Benefit | Cost / recommendation | | ---------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Planning-time error for past `validAt` plus metadata | Loud failure, preserves uniform point filtering | “Past” changes with the clock; without reading data the planner cannot know which metadata join is empty. Rejecting every explicit valid point is predictable but rejects legitimate audits. Safe fallback if the owner rejects the recommendation. | | Metadata at current valid time | Makes most provenance joins work | Introduces a second business clock and can hide metadata at future commit instants under clock skew. Reject. | | Metadata at recorded cut only | One-call historical provenance with no second clock | Explicit reserved-attribute exception to uniform validity. Recommend and document it. | | Keep current semantics / require two queries | No engine change | Preserves the silent trap and risks two queries observing different recorded cuts. Reject. | Breaking behavior: existing three-/four-term domain queries in default mode are unchanged. Existing queries involving `:_tx/*` can return additional rows at earlier `validAt`; negation can remove rows and aggregates/counts can change. Dynamic-attribute queries can now include those metadata rows too. This is an intentional semantic break, with no legacy flag. It changes Datalog only; stored metadata intervals and direct fact-read APIs retain their existing behavior. Update all provenance guidance and query consumers during implementation and record the break in a publishable-package Changeset. This documentation-only RFC needs no Changeset. ## 4. Native execution and indexes ### Shared planning Extend `packages/core/src/datalog/schema.ts`, clause discrimination, validation and type inference as one contract. Normalize tuple/object forms to a pattern IR carrying optional bound terms. One shared preflight must validate every query before backend execution, including `explain` and wrapped queries. It determines temporal mode, scope safety, sealed taint and static binding types. No backend-specific acceptance or per-entity history fallback is permitted. Core remains browser-safe. ### In-memory KV `packages/core/src/kv/datalog/executor.ts` orders positive patterns before predicates and feeds `pattern.ts`; that module scans `KvTripleStore` and unifies datoms. Datom META already stores `validFrom`, nullable `validTo`, assertion position and retraction position. Split its current `visibleAt` into recorded visibility plus row-sensitive point visibility. Keep `includeRetracted: true` when evaluating a historical cut; do not use a live-only scan in all-time mode and accidentally lose assertions visible before their later retraction. Use existing EAVT for fixed entity/attribute, AEVT for attribute, AVET for attribute/value and VAET for ref traversal. Bind bounds directly from hydrated datoms in the same scan, using batched META `getMany` on async KV paths and the synchronous cache/path in memory. Keep the existing hash join path for large contexts; adding bounds must not fall back to a `history(entity)` call. Index nested-loop joins can still do scans for join contexts; the guarantee is no additional per-entity history or per-fact metadata round trip to obtain intervals. Existing hexastore keys do not lead with interval bounds. AEVT plus filtering is a correct fallback, but not a selective expiry plan. The later implementation should add two history-preserving KV indexes keyed `(attribute, finiteValidTo, tripleId)` and `(attribute, validFrom, tripleId)`. Maintain them on assertion, retain entries on retraction, and rebuild them for existing stores before enabling their planner path. No entry is needed in the finite-end index for open ends. Recognize constant-attribute bound ranges and bound equalities in shared normalized predicates; use bounded key scans, then batch META visibility checks and residual constraints. Without a constant attribute, choose an existing prefix scan and report the residual filter honestly. This is a fixed rule-based access-path choice, not a cost-based optimizer project. ### SQLite and PostgreSQL The compiler lives in `packages/core/src/datalog/compiler.ts`; `packages/sql` owns migrations, `SqlQueryExecutor`, execution and decoding. Add `validFrom`/`validTo` binding positions mapped to `alias.valid_from`/`alias.valid_to`, including local negation bindings and hidden typed wrapper columns. Emit guarded numeric comparisons and null-safe equality explicitly on both dialects. Decode nullable bounds as number-or-null, including PostgreSQL BIGINT strings. Replace the current regex-based `applyTemporalBasis` rewrite of `alias.retracted_at IS NULL` with structured per-alias visibility generation. Every alias, including recursive CTE scans, gets the same recorded condition. In point mode append `(attribute is transaction metadata OR (valid_from <= point AND (valid_to IS NULL OR valid_to > point)))`. Use a constant decision for fixed attributes and a row condition for variable attributes; escape SQL LIKE underscores if implementing the reserved-prefix test with LIKE. All-time mode emits no point condition. Bound projections do not change these decisions. Parameterize times and constants. Existing `idx_attribute_history` supports attribute/recorded scans and `idx_entity_recorded_position` supports provenance entity lookups at a cut. `idx_attribute_temporal` leads with `valid_from` and is live-only; it cannot promise a selective `valid_to` range at a historical position. Add non-partial indexes `(attribute, valid_to, recorded_position)` and `(attribute, valid_from, recorded_position)` through shared SQL migrations. Retraction visibility remains a residual condition. For expiry, push the finite upper/lower bounds onto `valid_to` directly, avoiding COALESCE or casts on the indexed column. All-time vendor queries use entity/attribute history access and residual visibility. Joins and bounds run in one native SELECT/CTE query; wrapper count remains the existing optional second statement. There is no entity history loop or hydration query for interval projection. Require SQLite EXPLAIN QUERY PLAN and PostgreSQL EXPLAIN evidence on a large, selective fixture: attribute plus end range uses the new end index, start ranges can use the start index, provenance uses indexed entity lookup, and result parity holds at older recorded cuts. Small tables may legitimately use a full scan; test meaningful cardinalities and candidate counts, not an unconditional optimizer index-name promise. KV explain should show range keys and residual visibility filters. Cloudflare inherits the core compiler/SQL decoder semantics and needs any shared migration plumbing to keep compiling and passing existing tests; it is frozen and gets no new feature certification. FoundationDB inherits the shared KV executor and datom changes. Its interval-index initialization must either complete before use or use the correct existing prefix fallback, never an incomplete index. Native FoundationDB performance/integration coverage is excluded from this delivery. Neither backend requires a parallel implementation or a new support claim. ## 5. Worked queries These are proposed query fragments, intentionally ordinary `ts` fences: the current public types do not accept them. The implementation must turn them into checked, runnable examples. Fixture: all dates below are midnight UTC in 2026. Let `J = Date.UTC(2026, 0, 1)`, `F = Date.UTC(2026, 1, 1)`, `M = Date.UTC(2026, 2, 1)`, `E = Date.UTC(2026, 2, 20)`, `C = Date.UTC(2026, 2, 31)` (M plus exactly 30 days), `A = Date.UTC(2026, 3, 1)` and `Y = Date.UTC(2026, 4, 1)`. | Position / commit | Actor | Assertions (entity, attribute, value, interval) | | ----------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 1 / Jan 5 | `user:pat` | `vendor:acme`, `:vendor/status`, `active`, `[J, F)`; same vendor/status/value `[M, open)`; `exception:one`, `:exception/status`, `approved`, `[J, E)`; `exception:open`, same attribute/value, `[J, open)` | | 2 / Mar 10 | `user:lee` | `review:one`, `:review/status`, `approved`, `[F, A)` (backdated) | | 3 / Mar 12 | `user:kim` | Retract position 2's review; assert same review/status/value `[F, Y)` | The stored fourth-term transaction identities are `tx:1`, `tx:2`, `tx:3` respectively. Each commit also writes ordinary reserved metadata with its actor. These are descriptive fixture IDs; an implementation test captures actual generated IDs and receipt positions/instants. ### Exceptions expiring within 30 days ```ts const query = { find: ["?exception", "?end"], where: [ { pattern: ["?exception", ":exception/status", "approved"], valid: { to: "?end" } }, [">", "?end", M], ["<=", "?end", C], ], orderBy: [{ variable: "?end" }], }; const options = { basis: { recordedPosition: 3, validAt: M } }; ``` Expected: `[{ "?exception": "exception:one", "?end": E }]`. The window is `(M, C]`; point mode additionally requires the exception to be active at M. Open ends do not match. This is one query; it needs neither two-base subtraction nor direct reads per exception. ### Who asserted the review that held on March 1? ```ts const query = { find: ["?review", "?actor", "?tx"], where: [ ["?review", ":review/status", "approved", "?tx"], ["?tx", ":_tx/actor", "?actor"], ], }; const options = { basis: { recordedPosition: 2, validAt: M } }; ``` Expected: `[{ "?review": "review:one", "?actor": "user:lee", "?tx": "tx:2" }]`. March 1 precedes the March 10 commit, but the recorded cut includes it. At position 1 the result is empty. At position 3 the row instead names `user:kim` and `tx:3`: it explains the corrected assertion now believed to cover March 1, not the replaced assertion's author. ### Every interval a vendor was active ```ts const query = { validTime: "all", find: ["?start", "?end"], where: [ { pattern: ["vendor:acme", ":vendor/status", "active"], valid: { from: "?start", to: "?end" } }, ], orderBy: [{ variable: "?start" }], }; const options = { basis: { recordedPosition: 3 } }; ``` Expected: `[{ "?start": J, "?end": F }, { "?start": M, "?end": null }]`. No implicit current point removes the January interval. Adding `validAt: M` is an error; switching to point mode at M returns only the second row. “Every” means every assertion interval in this recorded view, not every superseded recorded belief. ## 6. Conformance and implementation gate Add shared cases in `packages/testkit` and run them unchanged on KV, SQLite and PostgreSQL: 1. All three fixtures above, with generated transaction IDs, pinned positions, timestamp cuts, and position zero. Ordinary domain tuple queries retain their current results. 2. Bind either/both bounds, project finite numbers/null, match literal endpoints and open ends; reject bad keys, units/types, variables, tuple arities and invalid concrete intervals before I/O. 3. Bound projection retains point membership; all-time includes past and future intervals without an implicit now. Explicit all-time plus validAt fails consistently, including wrappers/explain. 4. Half-open boundaries: start included, end excluded; exact expiry window edges. Preserve disjoint and overlapping assertions; show that all-time joins do not impose interval overlap. 5. Repeated-variable equality within/across patterns, nullable ends, numeric value joins, statically incompatible identities, predicate reordering, and null equality/inequality/ordered comparisons. 6. Negation-local interval variables and predicate ordering; disjunction filters; unsafe escaping locals fail. Existing rules obey mode and metadata semantics; interval rule bodies and all-time optional projection fail loudly. 7. Distinct projection, grouping, every aggregate (including duplicate inputs, all-null ends and empty input), having, wrapper null/numeric filters and ascending/descending typed ordering. 8. Pagination with equal bounds and null ends, concurrent assertions/retractions, equal commit timestamps, explicit position without wall time, terminal pages, counts and cursor mode/basis mismatch. No duplicate or missing projected rows across the captured cut. 9. Backdated `_tx` joins, reordered joins, metadata through variable attributes, negation and rules; recorded cuts still hide future metadata. Domain attributes on a transaction entity stay point filtered. Assertion tx stays distinct from retract tx after corrections. 10. Sensitive presence/blind equality with interval projection succeeds; sealed value projection, ordering, aggregation and variable alias escape fail. Raw execution never exposes plaintext. 11. SchemaBound traversal, dependency extraction and exact-derivation rejection recognize new forms. 12. Instrumented KV batched META reads, selective index scans and SQL statement counts prove no per-entity history reads. Test fresh indexes, rebuild/backfill and historical retractions; compare indexed execution to fallback scans. Exercise SQL numeric/null decoding. Write each implementation regression test first and observe failure before implementing it. Require selective query plans as described above, existing frozen-backend tests, checked public examples and updated provenance guidance. Run the root checks and package checks; the orchestrator runs PostgreSQL integration and browser tests. This RFC changes no package source, so it adds no executable regression test and does not claim the future syntax passes current typechecking. ## 7. Owner questions 1. **D2:** approve recorded-only semantics for every `:_tx/*` row, including variable attributes, and the intentional changes to negation/counts? Recommendation: yes; otherwise choose the predictable error on explicit point bases involving metadata, not a clock-dependent test. 2. **P4.1 / D6:** approve the object pattern and query-wide all-time scope, with explicit validAt rejected in that scope? Recommendation: yes; keep implicit now only for point queries. 3. Approve null as the public open-end representation and finite-only ordered predicates, with null-safe equality and distinct-count semantics stated above? Recommendation: yes. 4. Approve interval access-path indexes and existing-store rebuild/migrations as part of the later implementation, while leaving interval rule bodies and all-time optional projection rejected? Recommendation: yes; ship the three target backends together before enabling the feature. No question reopens the settled Effect-only, greenfield, browser-safe or backend policy. Owner approval gates implementation; this item delivers only the RFC. --- # RFC: journal-driven live queries Status: proposed for P4.3, awaiting owner approval before P4.4. This is a design, not an implemented API. It changes no package source or public exports. ## 1. Recommendation and API Add a browser-safe `LiveQuery` service on a proposed `@triplex-build/triplex/live-query` subpath. Its `watch(query, options)` returns an Effect `Stream` of complete result snapshots. Start with query re-execution, not incremental maintenance. WorldVM can replace its displayed result atomically, including after reconnecting, without keeping a durable diff base or inventing row identities for aggregates. A snapshot is an observation, not a durable task occurrence or an instruction to perform an external action. The following are proposed contract fragments, deliberately ordinary `ts` fences. Implementation must add checked examples against public exports. ```ts type WatchOptions = { readonly validTime?: { readonly mode: "now" } | { readonly mode: "fixed"; readonly at: number }; readonly resume?: string; readonly coalesceMs?: number; // default 0; finite integer 0..1000 readonly maxRows?: number; // raw complete results: default 10_000, integer 1..100_000 }; type WatchSnapshot = { readonly version: 1; readonly watchId: string; // canonical query/options identity, scoped to this database generation readonly eventId: string; readonly position: number; // exact recorded cut, including internal maintenance positions readonly validAt: number | null; // null only for a future all-valid-time query readonly reason: "initial" | "resume" | "commit" | "time" | "coalesced"; readonly coveredAfter: number | null; // exclusive journal coverage start; null for initial readonly result: QueryResponse | PagedQueryResponse; readonly nextTemporalBoundary: number | null; readonly resume: string; }; interface LiveQueryService { readonly watch: ( query: DatalogQuery | WrappedQuery, options?: WatchOptions, ) => Stream.Stream; } ``` The service requires `Triples`, Effect clock/scheduling, and a scoped shared feed coordinator. Errors include existing read/query/cursor failures plus typed `WatchOptionsError`, `WatchResumeError`, and `WatchResultTooLarge`. Invalid inputs fail before registration; cancellation releases registrations, timers and pending reads. A read failure ends the stream with its typed error; a caller may retry from its last successfully handled token. Never advance delivery state on failure. For a raw query, use `queryAll`, preserving the query's own ordering, aggregation and explicit result restrictions. Do not call the default 100-row `query` and present it as a complete relation. `maxRows` rejects oversize results, never silently truncates them; an initial implementation can enforce it after execution but must document that it is not a query-memory bound. Large relations should use a wrapped window. Row order without an explicit order is not a consumer identity. For a wrapped query, re-run `queryPage` for the same window, including filters, ordering, offset/limit and requested count. Preserve existing defaults and the 1,000-row page maximum. A window may gain or lose rows because a row outside it changes sort rank. Reject an input continuation cursor: it pins an older cut and cannot define a moving window. A returned continuation cursor remains usable for ordinary reads of that snapshot only. Following it does not create another live page; start a separate watch for another window. Rows, count and cursors in one emission must all use the envelope's exact basis, even if count is a second query. Recorded time always follows committed positions. Reject explicit `recordedAt` or `recordedPosition` embedded in a proposed watch input; use ordinary reads for a frozen recorded view. `validTime` defaults to `now`. Resolve it once per evaluation with Effect's clock, and pass that same `validAt` to every read. Fixed valid time follows corrections to what is believed about that instant, without expiry timers. Validate `at` against the existing timestamp domain. If the [Datalog time RFC](datalog-time.md) is implemented, normalize its interval patterns in dependency extraction. `validTime: "all"` queries need no valid-time timer and emit `validAt: null`; reject an explicit point option. This RFC does not make that syntax available ahead of owner approval. Literal predicate cutoffs remain literal: a query containing “today plus 30 days” as a number does not automatically move that number. Recreate the watch when those parameters change. ## 2. Journal correctness and restart The authoritative inputs are `Triples.transactions`, `currentPosition`, and position-pinned reads. `ChangeEmitter`, `SubscriptionManager` and network wakeups are hints to check those inputs sooner. They cannot establish coverage or supply the only copy of a change. Never label an unpinned latest query with a previously read position. Use this protocol: 1. Register with the shared coordinator, then capture `H = currentPosition()`. Execute the initial query and dependency schedule at `{ recordedPosition: H, validAt: T }`. Emit even an empty result. Writes racing registration or evaluation are either included at H or found after H. 2. Read the **unfiltered global** journal after the last scanned position in bounded pages. Process envelopes in increasing position order. A transaction is atomic: inspect all assertions and retractions, then evaluate at its position if possibly relevant. Never emit intermediate operations in a commit. 3. With the default `coalesceMs: 0`, emit once for each possibly relevant commit, even if the rows are identical. An irrelevant commit advances the internal scanned position without an emission. `coveredAfter` is the previous emitted position, so the next emission also covers intervening irrelevant commits. 4. At a captured head, ignore any later envelopes for this drain and retain them for the next drain. Exhausting a page alone does not prove the database head: compare with `currentPosition`. Positions may have gaps because checkpoint maintenance is intentionally unjournaled. Across any uncovered position gap, conservatively re-evaluate all watches at the gap's end. Do the same for a trailing gap through H, even when the feed is empty. An over-invalidation is preferable to hiding a queryable consumer-checkpoint change. 5. Re-arm the earliest temporal deadline and drain again after any wakeup. Also perform a configurable shared journal/head check (default at most one second while idle). Missing every emitter message must affect latency only. After every drain, check for newly arrived work before sleeping. Pending timer and journal work are serialized per watch. The journal stores application changes, not every reserved metadata assertion. Queries reading `:_tx/*` must invalidate on every journal envelope, including empty and entry-only commands. Unknown/older envelope formats conservatively invalidate rather than treating missing change metadata as proof of no effect. Direct adapter mutations outside Triples' position protocol are unsupported. Every result is exactly the query at `(position, validAt)`, not an assertion that the database cannot already have advanced. Positions are safe integers, not timestamps, and zero represents the empty recorded cut. Equal commit timestamps must remain distinguishable. A timer can emit at the same position with a later valid instant; position alone is therefore insufficient for deduplication. Build `eventId` with the browser-safe canonical content hash over version, database generation, canonical query/options, position and validAt. Exclude reason, delivery attempt, coverage and scheduling metadata. Replaying the same basis has the same event ID; later timer evaluations at a different instant have different IDs even if the rows coincide. No result-equality suppression is required in v1. The opaque, versioned resume token binds that identity, the emitted basis and event ID. Validate its structure, database generation and full query/options fingerprint; reject tampering, incompatible versions and changed parameters with `WatchResumeError`. It is not an authorization credential. The host must still authorize the query and resolve the same database. A restored/recreated database needs a new generation. Reject a token ahead of the committed head or referencing history that retention no longer supports; never silently reset to latest. On restart, first re-evaluate and emit the token's **exact old basis** with reason `resume` and the same event ID. Then drain after its position and evaluate new commit cuts at the current valid instant (or the configured fixed instant). Finally evaluate at the current head and current valid time to account for time elapsed while offline, even if no transaction arrived. Consumers persist a token only after processing its result; a crash before acknowledgement can redeliver it. Use event ID for idempotency. Exactly-once external effects are not promised. `ConsumerCheckpoint` is useful for a durable host's journal processing but stores only a position: it cannot replace the watch token, which also binds query and valid time. Persist the token in host storage together with application effects where possible. Core does not write checkpoints on stream delivery, avoiding a watch that recursively observes its own acknowledgements. “No update lost” means every journal envelope after the starting cut is examined or explicitly included in a coalesced coverage range, and every emitted snapshot has a coherent basis. It does not mean this observation API reproduces every historical wall-clock state after a process was stopped. Offline time edges are collapsed into a current observation. Use a durable runner and application occurrences when every business-time transition must be handled as work. ## 3. Conservative invalidation Reuse `extract-dependencies.ts` to walk positive patterns, all negation clauses, every disjunction alternative, and every definition of each reachable rule. Visit recursive rule names once while walking all their bodies to closure. Predicates, grouping, having and wrapper operations filter or transform those reads; they do not remove dependencies. Wrapper dependencies come from its inner query, including hidden sort/filter inputs. `invalidation.ts` currently narrows by attribute-derived namespace, attribute and bound entity IDs. Those sets are useful candidates, not a proof for every query shape. The watch planner must record whether a narrowing is sound: - Fixed attributes: a change to any read attribute may matter. Namespace names derived from attributes are not entity-type membership constraints. - Only narrow by a constant entity when every reachable pattern reading that attribute is proven to use those constants. Mixed bound/unbound patterns must retain the unbound branch. Do not infer a rule argument's type from its ID prefix and use that heuristic to discard changes in a recursive join. - Any dynamic attribute slot makes the watch database-wide. In particular, a mixed fixed/dynamic query must bypass **all** namespace and bound-ID filters; the current type-first filtering cannot safely certify that case. - Facts inside negation are dependencies even when the query currently returns zero rows. Both adding suppressing evidence and retracting it may change output. - Rule closure includes base relations reached indirectly, all same-named rule definitions, and negation/disjunction within them. Unknown clause forms, unresolved dependency analysis or incomplete journal data mean re-run. Invalid queries still fail shared query preflight; “unknown” is no permission to execute an invalid query. Do not filter the journal by entity prefix just because the projected IDs share a prefix. The [prefix feed](../operational-primitives.md#entity-prefix-transaction-feeds) is a subsequence, not an attribute/dependency feed. A training assertion on a worker can affect work projected for a placement. Only a future planner proof covering every positive, negative and rule input could justify such filtering. V1 uses the global feed for all watches. ## 4. Valid time without expiry polling After each evaluation call `dependencyState(attributes, basis)` at the **same** recorded cut and valid instant as the query. Its next temporal boundary includes future `validFrom` and `validTo` of all currently recorded dependency facts, including facts in negative clauses and facts supporting no current row. Never derive the deadline only from returned rows or a page's visible members. Sleep with Effect's clock until that boundary, wake, drain committed journal work, resolve now once and re-evaluate. Half-open intervals include their start and exclude their end. Recompute the next boundary after every commit-triggered run; a correction can bring an earlier deadline forward or remove it. A late timer evaluates at actual now and advances beyond elapsed boundaries, avoiding a busy loop. A backward wall-clock jump triggers re-evaluation and deadline re-arming; use an injected clock for tests and bounded wall-clock rechecks in the shared coordinator. Timer delivery cannot promise an exact real-time latency while a browser tab is suspended or a process is overloaded. Dynamic attributes need an all-attribute form of this read; an empty attribute array currently means **no dependencies**, not wildcard. Add an explicit public `dependencyStateAll(basis)` with the same source-position/boundary semantics. Until implemented, reject dynamic watches with a typed unsupported-capability error; do not silently run them with no expiry schedule. The design supports them by re-running for every commit and using this wildcard boundary read. Fixed valid-time and future all-valid-time watches need no expiry schedule. The shared idle journal check discovers remote commits when emitters are lost; it is not expiry polling. Expiry uses one deadline scheduler, with no recurring query or full dependency scan just to discover a known time boundary. ## 5. Cost, coalescing and backend behavior Re-execution is the correctness baseline, including recursive rules, negation, aggregates, optional projections and window membership. Incremental maintenance is worthwhile only after WorldVM measurements show repeated evaluation dominates cost for stable, high-fanout subscriptions, with a sufficiently narrow query subset and explicit deletion, negation, aggregate and temporal-edge semantics. Require differential tests against re-execution and measured CPU/memory savings before adding another execution engine. The roadmap's incremental-update goal is deferred, not claimed as delivered by this API. `coalesceMs > 0` explicitly selects observation coalescing. Scan every intervening envelope, union invalidations, and run once at the final captured position when the bounded window closes. Emit reason `coalesced` with the exclusive previous emitted position in `coveredAfter`. Intermediate states can disappear (assert then retract may emit an empty result); the coverage range makes that deliberate. Never postpone a pending temporal deadline to extend a burst window. Default streams backpressure: keep at most one undelivered snapshot per watch, pause its evaluation, and retain only a journal position and earliest due timer. On demand, resume journal replay from that position. Do not retain an unbounded queue of transaction bodies, and never silently switch a slow default consumer into coalescing mode. With coalescing enabled, accumulate a bounded pending coverage range while blocked and emit its final state on demand. A consumer whose required history was pruned fails explicitly. Delayed temporal observations use actual evaluation time; this remains a live observation contract, not a timer log. One scoped coordinator per Triples database generation shares feed reads, idle checks and a minimum-deadline queue across watches. Identical canonical query, window and temporal options can share evaluation; each subscriber keeps its own delivery/checkpoint state. Index watches by attributes, with a wildcard bucket. Bound evaluation concurrency (proposed default four per database) and schedule fairly so a costly query cannot starve expiry work. A lagging subscriber catches up from storage independently rather than blocking all others. Scope shutdown interrupts reads and releases timers. Export metrics for scanned head, delivered position, evaluation duration, pending subscribers and timer lateness. | Backend | Initial strategy and limit | | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | In-memory KV | Share the one ordered journal and attribute-index dependency reads. Synchronous query work still consumes browser/event-loop time; bounded concurrency is not parallel CPU execution. In-memory data and resume tokens survive only while that database survives. | | SQLite | Use position-pinned reads over public Triples APIs; avoid holding a long read transaction while waiting for demand. Share one coordinator per database handle and use shared idle checks for other writers/processes. Measure writer contention and historical-read costs. | | PostgreSQL | Share one coordinator per scoped database runtime; use position predicates for exact historical cuts across statements. Pool/concurrency limits bound simultaneous reads. NOTIFY, if a host supplies it, is only a wakeup hint; disconnected or other-process writers are recovered from the journal. | Multiple processes can each coordinate their own watchers safely; this costs duplicate reads, not lost updates. Hosts own query authorization, subscription quotas, retention and retry policy. Cloudflare and FoundationDB remain frozen, experimental backends: keep existing compilation/tests intact; no new watch certification or backend-specific implementation is required by this RFC. ## 6. Core and runner boundary; missing public reads Put the observation service in core because it is a generic read composition over `Triples`, usable in a browser with Effect clock, Stream and scoped fibers. Import no Node APIs, backend internals, SQL clients or sibling package source. The settled runner remains its own package beside HTTP and CLI, using public exports only. It owns durable derivation catch-up, `ConsumerCheckpoint`, host handlers and scheduling across restarts. It can reuse the observation planner or coordinator through public exports, but must not use a coalescing UI watch as a durable per-transition task log. Do not make core depend on runner. Existing public reads cover `currentPosition`, exact `recordedPosition` query bases, `transactions`, `queryAll`, `queryPage` and fixed-attribute `dependencyState`. No new head read or position allocator is needed. The missing public read is `dependencyStateAll`, required for dynamic temporal dependencies. The later implementation must also verify count/page basis parity, export the new service, and strengthen conservative dependency analysis as above. These are implementation requirements, not changes shipped by this document. ## 7. Agent access through P3.6 MCP Do not make an ordinary MCP tool call wait indefinitely for a Stream. Initially, the planned server keeps its existing bounded query tools; it exposes no watch tool until a transport lifecycle is specified. Agents can issue snapshot reads and use the journal/checkpoint APIs through an authorized host. If WorldVM needs subscription access, add bounded `watch_open`, `watch_next` and `watch_close` tools over this same service. Open returns a scoped opaque handle and initial envelope; next accepts a bounded wait and returns at most one envelope or a timeout with the unchanged resume token; close interrupts the scope. Bind handles to authenticated session, database generation and query permissions; cap idle lifetime and concurrent handles. Resume across server restarts uses the snapshot token, not the ephemeral handle. Notifications may request a next call, but never carry the only result or advance acknowledgement. Opening/reading is read-only and needs no actor/command ID; host-side effects remain explicit writes under P3.6's actor/command requirements. This extension awaits demonstrated need and owner approval; no MCP source belongs in P4.3. ## 8. Worked site-safety observation Use the model and open-work query in [the site-safety application](../snippets/home/site-safety/app.ts). A placement without training for its worker/site produces one open training-work row: ```ts // Proposed watch call; query structure mirrors openWork in the checked snippet. const query = { find: ["?worker", "?site"], where: [ ...Placement.where("?placement", { worker: "?worker", site: "?site" }), Worker.not("?worker", { trainedFor: "?site" }), ], }; const observations = liveQuery.watch(query); // validTime now, no coalescing ``` For this isolated fixture, the release and Maria/Harbor onboarding already exist at captured position H. There are no other writes or checkpoints, so the next three positions are H+1, H+2, H+3. This is a reordered variant of the homepage story, not a claim that its generated timeline changes. All instants below are UTC in 2026. The watch starts March 2, after placement validFrom becomes effective. | Observation | Recorded cut | Valid instant | Action / result | Next boundary | | ----------------- | ------------ | ------------- | ------------------------------------------------------------------------------------------------------- | ------------- | | Initial | H | March 2 | No placement yet: `[]` | none | | Commit 1 | H+1 | March 2 | Dana places Maria at Harbor from March 2: `[{ "?worker": "worker:maria", "?site": "site:harbor" }]` | none | | Commit 2 | H+2 | March 2 | Trainer asserts Harbor training `[March 1, June 1)`: `[]` | June 1 | | Commit 3 | H+3 | March 6 | Auditor retracts that assertion and replaces it with `[March 5, June 1)`, as in `correctTraining`: `[]` | June 1 | | Expiry, no commit | H+3 | June 1 | Training is no longer valid; Maria/Harbor reappears as open work | none | The correction still emits because it may affect the query, even though both evaluations are empty at their stated instants. Each envelope records the table's position and actual millisecond validAt, its event ID and resume token. The last two positions match but event IDs differ. At expiry, `dependencyState` wakes the watch even though the prior result was empty and the expired fact was inside negation. No transaction is invented to represent the passage of time. A separate fixed-valid-time watch at March 3 would emit empty after commit 2, then open work after commit 3: the correction changes what is believed about that past instant. It would not schedule June 1. The now watch does not retroactively emit a March 3 state when the auditor commits on March 6. ## 9. Conformance and owner questions The later implementation must write each regression test first, observe failure, then implement it. Add a shared corpus for in-memory KV, SQLite and PostgreSQL: 1. Initial empty/nonempty observations; commits racing registration, head capture, query, schedule read and sleep; lost/duplicate/reordered emitter hints and a write from another runtime. Verify journal recovery without emitter delivery. 2. Exact position cuts under concurrent writes and equal commit timestamps; atomic multi-operation transactions; raw results beyond 100 rows; wrapper count/window/cursor parity and concurrent reordering outside the visible page. 3. Assertions and retractions through joins, mixed bound/unbound entities, mixed fixed/dynamic attributes across namespaces, negation with zero output, disjunction, recursive rules and all same-named rule definitions. Unknown dependency metadata must re-run, never silently skip. 4. Metadata queries on empty/entry-only commands; unjournaled checkpoint gaps, including a trailing gap with an empty journal page. No recursive checkpoint writes from watch delivery. Position zero and gaps must not look like corruption. 5. Virtual-clock future starts, exact half-open expiry, negated/suppressed facts, expiry outside a watched page, corrected/removed deadlines, shared deadlines, timer/commit races, delayed wakeups and clock rollback. Include the worked three-commit/one-expiry sequence. No periodic query polling for known expiry. 6. `dependencyStateAll` parity with the union of all attributes at the same cut, including historical retractions and future starts. Empty fixed dependencies remain empty. Fixed valid-time watches get corrections but no timers. 7. Exact-token replay/deduplication, crash before/after handling, restart after offline expiry with no writes, changed query/options/database generation, malformed/version-mismatched/ahead tokens and unavailable retained history. 8. Coalescing assert/retract bursts with explicit coverage; default per-commit backpressure; slow consumers, bounded queues and independent catch-up; cancellation/error cleanup, fair concurrency and one shared idle feed reader. 9. Browser-safe public composition and existing sensitive-query rejection rules; typed size/options failures; no silent truncation. Exercise supported query forms regardless of exact derivation-provenance restrictions. The orchestrator runs PostgreSQL integration; frozen-backend existing checks must continue passing. This RFC adds no executable API test because it implements no API. Owner approval gates the source changes, checked examples and release Changeset; there is no breaking package change in this documentation-only item. Owner questions for P4.4 / D6: 1. Approve full snapshots, per-relevant-commit delivery by default, explicit optional coalescing, and observation-time rather than historical timer replay? Recommendation: yes; use the runner for durable business transitions. 2. Approve the core service and shared coordinator, with durable handlers and checkpoints remaining in the separately settled runner package? Recommendation: yes; both use public APIs and core remains browser-safe. 3. Approve `dependencyStateAll` and conservative gap/dynamic-query invalidation? Recommendation: yes; accepting dynamic temporal queries without a complete boundary read would make expiry correctness impossible. 4. Are the proposed one-second idle discovery interval, four concurrent evaluations and raw-row limits suitable for WorldVM? Recommendation: start with these configurable limits and measure lag, evaluation cost and memory. 5. Does WorldVM need MCP subscriptions initially? Recommendation: defer handles until its agents demonstrate a need beyond bounded queries and runner events. These questions do not reopen Effect-only APIs, backend policy, runner ownership or the deferred position allocator. No implementation, package publication or deployment is authorized by accepting this design item alone. --- # RFC: durable derivation runner Status: accepted; implemented as `@triplex-build/triplex/runner`. The approved design below records the delivery contract; see [Runner](../runner.md) for the shipped API. ## 1. Recommendation, API and compliance host Add `@triplex-build/triplex/runner` beside HTTP and CLI. It follows the global ordered journal, materializes affected definitions, delivers pure candidate diffs to Effect handlers, and schedules dependency boundaries. It owns catch-up and wakeups; the host still decides what a task means. It must never infer that a removed candidate means cancellation rather than satisfaction. Use one scoped runner per database generation, with several named registrations. Choose one diff handler per registration rather than three callback variants: host code can keep additions, removals and revisions in one application module. There is no Promise interface. ```ts import * as Runner from "@triplex-build/triplex/runner"; type Delivery = { readonly version: 1; readonly batchId: string; readonly registration: string; readonly definitionId: string; readonly configSnapshot: string; readonly previousRunId: string | null; readonly runId: string; readonly position: number; // exact evaluation cut, not the materialization write readonly sourcePosition: number; // latest dependency-relevant write readonly basis: { readonly recordedPosition: number; readonly validAt: number }; readonly reason: "initial" | "commit" | "time" | "definition"; readonly diff: Derivation.Reconciliation; readonly attempt: number; // diagnostic only; excluded from identity readonly commandId: (kind: "added" | "removed" | "changed", candidateId: string) => string; }; type Registration = { readonly key: string; // durable identity; unique inside this runner readonly definition: Derivation.Definition; readonly onDiff: (delivery: Delivery) => Effect.Effect; }; type Handle = { readonly await: Effect.Effect; readonly stop: Effect.Effect; readonly status: Effect.Effect; readonly retryPaused: (registration: string) => Effect.Effect; }; // start(registrations: readonly Registration[], options: Options) // : Effect, RunnerError, Triples | Scope | R> ``` `start` validates and recovers durable state before returning; its scoped fibers continue processing afterward. Required options are a server-resolved `DatabaseScope` and stable runner `id`. Retry options default to eight attempts per delivery cycle, exponential delays from one second to one minute, with injected randomness for jitter. `stop` stops admission, interrupts and joins in-flight fibers, and releases timers. Scope closure does the same. Interrupted work remains pending; stopping never acknowledges a handler. `handle.await` supervises fatal read, state-corruption and scheduler failures. Exhausted handler retries pause a registration and appear in `status`, rather than killing the other registrations. Defects fail supervision; interruption is not a poison candidate. No background failure is silently swallowed. `status` reports scanned and delivered positions, acknowledged run per registration, pending batch/attempt/error, next deadline, lag, evaluation time and timer lateness. `retryPaused` durably starts another retry cycle for the same batch; it does not invent a new delivery identity. Acquisition rejects duplicate keys, unsupported derivations, a changed database generation, missing deployed definitions needed for recovery, and malformed persisted records with typed `RunnerError` diagnostics. Handlers' typed failures remain inspectable in status. The [compliance host](https://github.com/bjacobso/triplex/tree/main/examples/compliance-host) keeps its release publication, definition and requirement-occurrence functions. Replace `scheduled`, `materializeAt`, `consumeTransactions`, `runDueWakeups` and all `ConsumerCheckpoint` calls with this composition: ```ts import { Effect } from "effect"; import * as Database from "@triplex-build/triplex/database"; import * as Runner from "@triplex-build/triplex/runner"; // Fragment inside the host's scoped Effect, after deployment and definition creation. const serve = Effect.gen(function* () { const db = yield* Database.Database; const handle = yield* Runner.start( [ { key: "site-safety", definition, // task.site-safety-training, pinned to the deployed release onDiff: (delivery) => Effect.gen(function* () { const meta = (kind, candidate) => ({ actor: "demo/requirement-reconciler", commandId: delivery.commandId(kind, candidate.id), correlationId: delivery.batchId, }); for (const candidate of delivery.diff.added) { yield* db.command(meta("added", candidate), (tx) => openRequirement(tx, candidate, delivery), ); } for (const candidate of delivery.diff.removed) { yield* db.command(meta("removed", candidate), (tx) => satisfyRequirement(tx, candidate, delivery), ); } for (const { before, after } of delivery.diff.changed) { yield* db.command(meta("changed", after), (tx) => reviseRequirement(tx, before, after, delivery), ); } }), }, ], { scope: worldScope, id: "compliance", retry: { maxAttempts: 8 } }, ); yield* handle.await; }).pipe(Effect.scoped); ``` These three host helpers are domain command builders, not runner exports. They retain the example's logic: load prior occurrences, allocate the next occurrence only when none is open, satisfy the latest open occurrence, and replace an open occurrence's revision. Their reads use guarded `tx.get`/`tx.fact` and their writes use `tx.create`/temporal verbs. They record candidate ID, revision, definition, config snapshot, run ID, source position and `delivery.basis.validAt` in the requirement model. The full occurrence schema must be published and supplied to the Database layer; the existing status-only demo schema is insufficient for these typed helpers. Source commands and read-only overlay preview remain host code. Integration delivery uses outgoing intents, not network calls in command builders. A later implementation must provide a checked, complete rewrite of the example and retain its self-verifying open → satisfied → reopened scenario. With training valid on `[120, 200)`, the source commit at valid instant 150 removes the candidate and satisfies occurrence 1. The empty materialization still schedules 200. At 200, without a source write, an added candidate opens occurrence 2. Advancing Effect TestClock replaces manual host calls to `runDueWakeups` in the demo test. Its application module contains handlers and no scheduler or checkpoint code: this is the Phase 4 gate. ## 2. Delivery semantics and failure protocol Delivery is **at least once**, ordered per registration, with no automatic coalescing. Every relevant recorded commit produces an evaluation at an exact cut; only a nonempty diff invokes the handler. Unchanged candidates need no callback. A handler receives full added/removed candidates and changed `{ before, after }` pairs, preserving each candidate's stable logical ID, revision, result, provenance, definition/config identity and original basis. Removed candidates retain their old explanation; the delivery envelope supplies the new cut at which absence was observed. Absence is not positive evidence. Hash version, database generation, runner ID, registration key, predecessor delivery head, definition ID and target basis into `batchId` with public canonical content hashing. Include the predecessor even when a later transition returns to an earlier revision. `commandId(kind, candidateId)` hashes batch ID, kind and candidate ID into a bounded string. A candidate ID or revision alone is not an occurrence/idempotency key: expiry may legitimately reopen the same candidate. Attempts, errors and wall-clock retry time never enter either ID. The durable protocol for one registration is: 1. Read its acknowledged delivery head, not the latest materialization. Persist a **prepared batch** containing the predecessor, exact target basis, definition and reason before evaluation. Use `EntityAbsent` for initialization and `EntityState` plus retract-and-replace for later control transitions. Every control write has an idempotent command ID. Once prepared, a crash cannot change the evaluation instant or predecessor. 2. Call public `Materialization.materialize` at that basis. Compute `Derivation.reconcile(acknowledgedCandidates, run.candidates)` explicitly. Ignore `run.reconciliation` for delivery: it compares materialization history, which may include an unhandled run or another caller's speculative run. 3. Atomically append one Outbox entry containing the immutable batch payload and move control to **ready** using `Triples.transact`. The payload binds both candidate sets, run ID, next boundary and all delivery metadata except the runtime `commandId` function/attempt counter. The append key is `batchId`. A payload conflict is corruption, not permission to overwrite it. 4. Persist the attempt count before invoking `onDiff`, then call it. On success, atomically acknowledge the entry and replace the registration's delivery head with the new run, candidates, basis, temporal frontier and deadline. An empty diff follows the same head transition without invoking application code. 5. Only after every registration affected by a journal cut has completed may the runner advance its shared `ConsumerCheckpoint` through that cut. Checkpoint advancement is a separate idempotent operation, not falsely claimed atomic with the handler or outbox acknowledgement. Persist the completed-cut marker first; recover a lagging checkpoint from it before admitting later work. If evaluation succeeds but the process dies before step 3, the prepared batch re-evaluates the same retained basis. If a handler partly succeeds, fails or dies, the entire batch is redelivered with identical diff and IDs. If success occurs but acknowledgement does not, it is redelivered too. If acknowledgement commits but its response is lost, recovery reads the advanced delivery head and does not call that batch again. If a checkpoint call has an uncertain outcome, reread it; never rewind it or derive success from an in-memory cursor. The host makes effects idempotent. `Database.command` returns a prior receipt for the same command ID; for external effects, append an application outbox intent inside that command and have a separate idempotent transport deliver it. Runner acknowledgement and arbitrary host effects cannot share a portable transaction. An inbox can record an incoming integration's deduplication key, but is not a runner lease. External exactly-once execution is not promised. On typed handler failure, persist the failure summary and next retry instant, then retry the same batch with bounded backoff. Exhaustion marks it **paused** and leaves the Outbox entry pending: no skip, acknowledgement or checkpoint advance. A poison candidate pauses its whole batch/registration, including later diffs for that registration. Other registrations may continue with their own durable coverage markers; the shared checkpoint stays at the minimum completed cut. Restart must honor paused state and persisted attempts/retry deadlines. `retryPaused` after a host fix resumes the same batch. V1 has no discard API; administrative abandonment requires a separately reviewed future contract. ## 3. Durable state and public capability audit Use existing storage operations, with no new backend primitives or migrations. Runner-owned control records use ordinary, versioned `runner/*` entity types, `runner:` entity IDs and `:runner/*` attributes, never reserved `_triplex/` or `:triplex/*` writes. The package supplies their schema nodes for the host to publish and recognizes its own maintenance attributes during invalidation. Control data is internal application infrastructure, not user task state. | State | Durable representation | Recovery rule | | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | Global delivered journal position | Existing `ConsumerCheckpoint`, name derived from generation and runner ID | Resume the unfiltered feed exclusively after it; repair from completed-cut marker if needed. | | Registration coverage and delivered base | Guarded ordinary control entity containing schema version, definition/config, acknowledged run, complete candidate snapshot, basis and coverage position | This is the authoritative reconciliation base even if `Materialization.current` reports a newer run. | | Prepared/ready/paused batch | Control entity plus existing Outbox entry scoped to this runner | Recreate a ready payload only from its pinned prepared basis, or redeliver the stored ready payload unchanged. | | Last materialization | Existing immutable materialization runs/candidate facts | Audit and provenance; never imply handler success. | | Next temporal wakeup | `nextTemporalBoundary` in immutable run and acknowledged control head, including empty runs | Reconstruct the minimum deadline queue on startup; a prepared batch is finished before installing its successor schedule. | | Retry attempts, failure and wakeup | Guarded control state with `retryAt`, attempt count and paused marker | Respect on restart; diagnostics exclude sensitive result bodies. | | Host occurrences and outgoing effects | Host facts, receipts and application inbox/outbox entries | Host lifecycle and retention policy. | Use explicit `validFrom: 0` for control facts and guarded `liveFacts` reads, so clock rollback cannot hide the control record. Version and schema-decode records and canonical payloads; validate their content hashes and generation before use. Reject an ahead checkpoint, inconsistent predecessor, missing pending batch or missing required history. Do not silently rebuild from latest state. Plain in-memory KV survives runner restarts only while its backend survives: a process crash guarantee needs persistent KV storage or SQLite/PostgreSQL. Large candidate sets are an explicit limit, not truncation: proposed default 10,000 candidates per evaluation, configurable within a host memory budget. Persisting full delivery snapshots duplicates candidate storage but avoids a new core run decoder contract. Exceeding the configured candidate/payload budget pauses before delivery and leaves the prepared basis intact. Chunking, retention compaction and dead-letter queues wait for measured WorldVM requirements. The runner can already use these public exports: - Core: `Triples` journal/head, exact position-basis reads, `liveFacts`, ordinary guarded `transact`, command receipts and indexed `dependencyState`. - `/derivation`: definition/evaluation, materialization and pure reconciliation. - `/operational`: `ConsumerCheckpoint`, `Outbox.append/get/pending/acknowledge` and corresponding Inbox operations for hosts. - `/content`, `/runtime`, `/subscriptions`: canonical hashing, `DatabaseScope` and conservative dependency extraction. `/database` is available to handlers. No new public store read/write is required for the proposed protocol. In particular, checkpoint advancement cannot currently join an arbitrary caller transaction; the completed-cut repair protocol deliberately works without that extension. There is no public content-verified `Materialization.getRun(id)`: `runsQuery` is an audit query, not a complete run loader, and can omit empty-run membership. Storing complete acknowledged/pending snapshots avoids requiring one. Such a read would be an optional later storage optimization. The live-query coordinator is currently private inside `LiveQuery.layer`; its feed cache and deadline registrations are not public services. V1 can reuse the public dependency planner and scheduling algorithm but cannot import that private implementation. If sharing one physical coordinator with `watch` is required, the missing export is a browser-safe scoped coordinator supporting independent journal cursors, deadline registration/cancellation and wakeup hints. That is a separate core change requiring conformance tests, not an assumed existing API or a new durable store primitive. Do not export a UI snapshot stream as that seam. ## 4. Time, ordering and restart Use a single minimum-deadline queue per database for every registration's temporal and retry deadlines. One Effect Clock sleep waits for its earliest deadline; earlier commits or registrations cancel/re-arm that sleep. Derivation evaluation and dependency reads use exactly the same `(recordedPosition, validAt)`. `nextTemporalBoundary` considers future `validFrom` and `validTo` across dependency attributes, including negation and empty results. Never infer the schedule from positive candidate expiry alone. Half-open intervals become effective at their start and cease at their end. Choose durable transition replay, including elapsed boundaries during downtime, rather than the observation stream's collapse-to-now policy. Define the replay timeline precisely: 1. Initial registration captures head H and wall time T, persists that bootstrap basis and evaluates from an empty acknowledged set. It emits current work, not every transition before registration. A restart resumes that persisted bootstrap; it never chooses a new H/T for an unfinished initial batch. 2. Subsequently scan every journal envelope in position order, bounded by a captured head. Let F be the last durably processed valid-time frontier. For the next commit at position P, set C to `max(F, commit.instant)`. At the prior recorded cut, evaluate every scheduled boundary strictly before C, in order. Then evaluate definitions affected by the commit **or due at C** at `(P, C)`. A commit exactly at an expiry wins the tie: apply the commit and evaluate once at C, avoiding a transient expiry/reopen when replacement evidence arrives at that same instant. 3. After draining the captured journal head, process remaining boundaries through `max(F, Clock.now)`, at that head, evaluating each boundary at its exact instant. A late wakeup or restart therefore does not erase an intermediate candidate that both appears and disappears while the process is offline. Recompute the next boundary after each evaluation until caught up, with bounded work turns for fairness. Preserve the target/head in prepared state when interrupted. 4. A clock rollback never rewinds the durable frontier or repeats completed time transitions. Clamp to F and report clock skew; future deadlines wait until wall time catches up. Commit order is always position order, including equal or decreasing commit timestamps. This deliberately differs from a live UI's current-wall-time observation after rollback. Even an otherwise irrelevant envelope can advance the replay clock and make a deadline due. Relevant corrections recompute the schedule at their cut. Historical corrections are new recorded transitions, not instructions to undo delivered external effects or retroactively create every business state before registration. Application compensation remains a handler decision. Recorded history and definitions must be retained through the oldest pending replay basis; if future retention removes it, stop with a typed history-unavailable error. Use the global feed: entity-prefix filtering can miss training facts on a worker that suppress a placement candidate. Gaps, including unjournaled checkpoint positions, conservatively invalidate definitions at the gap end. Unknown change formats invalidate all. Ignore known runner control/materialization maintenance only after proving it touches none of a definition's dependencies. V1 rejects definitions depending on runner control, checkpoints, materialization metadata or transaction-journal attributes, preventing self-triggered delivery loops. Ordinary handler writes are never blanket-ignored by actor: if they affect source dependencies they legitimately trigger another run; hosts must avoid oscillation. Scanned coverage is not a second stream of maintenance writes. Persist/advance coverage only when crossing source journal work or completing a real time or definition transition; keep irrelevant runner-only tail positions in memory until the next such transition. In particular, do not write a control update merely to record scanning the preceding control update. Capture a finite head per drain and recheck afterward so concurrent commits cannot be missed before sleep. No recurring query or dependency scan discovers expiry: known boundaries drive timers. A shared bounded journal/head check (default one second) remains necessary for other-process commits or lost emitter hints. Its clock sample can also detect rollback and re-arm the timer. This liveness check is not expiry polling. The public API has no reliable cross-process blocking journal subscription today; promising zero periodic head reads would require another capability. Process suspension and overload can delay delivery; durable catch-up preserves ordering, not a real-time deadline guarantee. ## 5. Live queries, client and definition deployment [Live queries](../live-queries.md) and the [live-query RFC](live-queries.md#6-core-and-runner-boundary-missing-public-reads) provide the same foundations: exact position cuts, unfiltered feed coverage, conservative dependencies, indexed boundary discovery, bounded feed caching and one scheduler. Reuse public dependency extraction and its sound attribute-wide invalidation; share backend handles, not sibling source imports. V1 may own a second feed reader/timer alongside `watch` until the public coordinator seam is approved. One runner still shares those resources among all its definitions. Do not implement durable delivery by calling `watch`, even with coalescing off. It returns observations without candidate identity/provenance, can collapse offline time boundaries, has subscriber demand semantics and never acknowledges application work. The runner durably pins predecessors and pending deliveries, replays boundaries, retries handlers and advances checkpoints. Core remains browser-safe and never depends on runner. No MCP subscriptions are added here. Handlers normally use the [Database client](../database.md) for guarded, schema-bound commands and outgoing intents. The runner itself uses public `Triples` for infrastructure records; it never calls the private reserved-system write capability. Hosts publish the runner schema before acquisition and ensure their runtime validators authorize attributed maintenance commands. Runner attribution is not an authorization bypass. `Database.command` pins its own admission-time release, which may differ from the derivation's evaluation pin; retain both in the audit record. Drain before an incompatible release promotion rather than pretending that delivery metadata forces the client to use an old schema. Sensitive candidate payloads receive no automatic attribute-based encryption in operational entries; hosts must secure the store and explicitly protect sensitive application payloads. A registration key survives a definition revision. On redeployment, finish any old pending batch using its stored payload before preparing a definition-change batch at the current captured cut. Compare the acknowledged candidates with the new definition, preserving stable candidate identities by name/key. The old handler implementation must remain replay-compatible, or deployment must drain and stop before replacing it; changing code does not invalidate idempotency keys. Reject a changed definition name under the same key. Removing a registration with unfinished work fails acquisition; v1 requires a drain before removal and does not manufacture removals for undeclared definitions. A distinct key starts a new subscription with bootstrap semantics, not a migration of occurrence state. ## 6. Concurrency, backends and WorldVM V1 requires a **single active runner process per database generation**. The host supervisor assigns ownership and must stop/fence the previous owner before failover. No automatic cross-process leases or active-active handlers are promised. In-process acquisition rejects a second runner for the same scope; this registry is not a distributed lock. Control CAS/checkpoint conflicts detect stale progress but cannot prevent two processes from calling external handlers before either acknowledges. Idempotency is still required under accidental overlap. A future lease needs fencing tokens honored by every side effect; expiration alone is insufficient and is deferred. Serialize each registration's batches and callbacks. Bound evaluation concurrency across independent registrations (proposed default four); never process a later batch for the same registration before its predecessor completes. The global checkpoint is the minimum durable coverage across registrations. Ready snapshots and per-registration coverage permit others to progress while one is paused; recovering from the minimum skips their already completed cuts. Keep only bounded journal pages in memory, rereading retained history for laggards. Configure a maximum pending payload budget; backpressure a registration rather than drop or coalesce work. Report retained-history lag and poison blockage to the host. | Backend | Composition and limits | | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | KV | Public `Triples` and operational APIs only. In-memory KV is suitable for deterministic corpus tests; persistent restart claims require a durable KvBackend. Bounded Effect concurrency does not parallelize synchronous CPU work. | | SQLite | One runner using the database's existing Triples handle; source writers may be other processes. Keep reads short and position-pinned, and never hold a SQL transaction across a callback or sleep. | | PostgreSQL | Same public protocol and database scope, with a host-owned pool/runtime. Other processes may write sources; only the designated owner runs handlers. NOTIFY can accelerate a head check but cannot replace the journal. | WorldVM's reference deployment is one database per world and 16 runners for 16 worlds, rather than 16 workers competing over one database. Each world has its own generation, checkpoint, registration state and deadline queue. A shared process may host several world scopes, subject to host-wide pool/CPU budgets. Start with these boundaries and measure contention, replay lag, candidate sizes and timer lateness before adding leases or sharding one world's runner. KV and SQLite are supported targets; PostgreSQL remains a pre-1.0 production candidate. Cloudflare and FoundationDB remain frozen and experimental; existing compilation/tests must continue to pass, but this RFC adds no certification for them. ## 7. Package and implementation gate The package is `@triplex-build/triplex/runner`, a browser-safe module assembled into the single published package. Runtime dependencies are public exports of `@triplex-build/triplex` and Effect only, with versions in the root catalog and Effect's peer range matching core. No dependency on HTTP, CLI, host, SQL or backend internals is needed. It can stay browser-safe itself: use Effect Scope, Clock, fibers and scheduling, with no Node-only imports. Node process signals and shutdown orchestration belong to the embedding host; a future Node adapter would be an explicit separate subpath. Implementation must add package exports/build wiring, the runner schema and acquisition checks, durable protocol/recovery tests, a complete compliance-host rewrite, checked documentation and a Changeset. The implementation ships these surfaces. An implementation that needs another public core capability must specify and test it rather than import internals or weaken delivery semantics. ## 8. Conformance corpus and owner questions Add a reusable runner corpus parameterized by a public `Layer`, persistent store fixture and injected Effect Clock. Run it on KV and SQLite in the default suite; the orchestrator runs the same corpus on PostgreSQL with deterministic clock wiring. KV tests restart the runner over the same backend, while SQLite also closes/reopens the store. Add separate process-crash tests for any claimed durable KV adapter. Tests must observe committed effects and checkpoints, not only internal scheduler counters. Required cases for implementation: 1. Bootstrap empty/nonempty relations, added/removed/changed/unchanged diffs, merged provenance, exact recorded basis and stable candidate/revision/event identities. Distinguish two commits in the same millisecond. 2. Crash at every protocol boundary: prepared, materialized but unqueued, ready, mid-handler, after handler success before acknowledgement, after atomic head update before checkpoint, and after a successful checkpoint with lost response. Verify identical redelivery payload/command IDs and no lost diff. 3. Partial batch success using `Database.command`: retry returns earlier command receipts, completes remaining work and opens no duplicate occurrence. Test application outbox intent deduplication and payload-conflict failure. 4. A newer unhandled or manually materialized run cannot erase pending removals or become the delivered base. Include an empty run with a future boundary. 5. Poison failure persists attempts/backoff, pauses without advancing coverage, survives restart, leaves other registrations progressing, and resumes the same batch after `retryPaused`. Shutdown interrupts without acknowledgement. 6. Training `[120, 200)` produces open → satisfied → reopened, including expiry without a commit, future `validFrom`, negation suppressing every candidate, earlier corrected deadlines and retracted schedules. 7. Restart past several boundaries reproduces every post-bootstrap transition. Commit-at-boundary tie, late wakeup, equal/decreasing commit timestamps and clock rollback obey the specified frontier without duplicate occurrences or tight timer loops. Multiple definitions share/re-arm the minimum timer. 8. Lost emitter hints and other-process commits are recovered by head checks; paged global feed coverage, checkpoint gaps, concurrent commits during drain and worker facts outside projected entity prefixes cannot lose changes. Maintenance-only idle tails terminate without recursively writing checkpoints. 9. Definition deployment, changed config pin, pending old batch, registration removal, corrupt payloads, missing history, wrong generation and ahead cursors fail or recover exactly as specified. Validate schema/validator rejection of infrastructure writes without acknowledging work. 10. Second local runner rejection, simulated overlapping processes/CAS conflict, per-registration ordering, fair evaluation, payload limits/backpressure and 16 isolated world scopes prove the ownership limits. No test may claim that CAS alone provides distributed exactly-once delivery. Owner approval should settle these proposed policies before implementation: - Approve durable offline boundary replay with commit-first ties and a monotonic valid-time frontier after clock rollback. This can create and satisfy overdue work immediately during catch-up; live-query observations intentionally differ. - Approve single active process per world with host-supervised failover, and no distributed leases or poison-discard API in v1. - Approve ordinary published runner control schema plus full acknowledged and pending snapshots, rather than new reserved core records/run-loader APIs. - Approve one diff handler, bootstrap-at-current semantics, drain-before-removal and replay-compatible handler deployments. Confirm the proposed retry and candidate limits against WorldVM; measure rather than promise a throughput SLA. - Decide whether sharing one physical coordinator with live queries is required immediately. Recommendation: reuse public planning now; defer coordinator extraction until duplicate feed/timer cost is measured. This does not reopen the settled separate-package/core-browser-safety decision. These policies were approved in D14. The Phase 4 gate remains the working compliance host and shared conformance evidence, not this document alone. --- # TX1.4 investigation: database-owned invariant scope authority Status: **synthetic mechanism evidence and proposed design; no public scope API, registry, migration or allocator change**. The owner selected database-owned invariant scopes. This follows that direction while the receipt/read-boundary choice in [ordered publication](./tx1-ordered-publication-investigation) is pending. Scopes do not repair that candidate's older-writer snapshot hole. ## Native experiments Seven PostgreSQL tests use synthetic policy, scope-head and host tables: 1. An older repeatable-read writer establishes its snapshot before policy epoch 2 replaces epoch 1. A plain SELECT still returns the old policy and lets it commit. The initial safety assertion failed. Replacing the policy read with `FOR SHARE` raises a native serialization failure and rolls its host write back. 2. The same sequence at serializable also fails under plain SELECT, then rejects the stale writer with a shared row lock. Serializable alone does not require a concurrent transaction to adopt newly committed policy authority. 3. Sixteen serializable transactions simultaneously hold shared locks on epoch 1 and create distinct scope heads. A separate `FOR UPDATE NOWAIT` activation attempt fails while they are active. All 16 claims commit; activation succeeds afterward. This avoids a per-command update to one policy row. 4. Initial scope creation advances absence to version 1. An exact predecessor update advances 1 to 2; asking for version 7 of an absent scope cannot create version 8. After one successful claim followed by a second stale claim, the entire transaction rolls back the first claim and its host write. 5. Two writers observing an absent mandatory scope cannot both create its first version. At read committed the second receives an explicit predecessor mismatch. 6. At repeatable read it receives a native serialization conflict. 7. At serializable it receives a native serialization conflict. The rejected host mutation rolls back in all three cases; the scope remains version 1. The first two plain-policy reads were tested before changing the helper. Exact command: ```sh pnpm --filter @triplex-build/triplex/postgres exec vitest run test/integration/invariant-scopes.test.ts ``` These experiments establish lock/claim mechanisms, **not a complete scope planner**. The “registered-unique” test identity is deliberately fixed. No test pretends to derive every required scope from actual Triplex graph rules, or to enforce a full uniqueness predicate merely by claiming a version. A claim serializes a decision; the correct invariant must still be checked afterward. ## Proposed authority and lifecycle The new profile needs an installed database-owned registry of active invariant rules, a canonical policy identity and activation epoch. A caller may add read-set claims but cannot remove mandatory scopes, submit an alternate active rule set, or disable participation by omitting today's optional `enforce` field. This is an opt-in database profile; the compatibility profile retains existing semantics. The registry is not a loose cache of whichever caller last supplied rules. Each eligible writer locks the registry epoch row **for share** before deriving policy-dependent plans, and holds it through outer commit. Readers of a stable policy share this lock. Activation locks/updates it exclusively, so it cannot change authority under active writers. A writer with an older native snapshot is rejected if that row has changed. A plain snapshot read plus an advisory gate is insufficient, as the red tests establish. Policy changes can legitimately force all stale writers to retry; ordinary disjoint commands must not update the epoch. Registration and activation are privileged host operations, with explicit policy version/configSnapshot compatibility. Activation must define validation of existing data, accepted violations and observe/enforce rollout; it cannot silently turn call-local enforcement into a different database policy. Concurrent old/new runtime versions must be excluded through schema/profile admission and a coordinated upgrade. A library flag alone cannot stop an older client ignoring the registry. Direct adapter/SQL writers are already outside Triplex's invariant guarantees; new runtime roles and privileged maintenance procedures must make that boundary explicit rather than advertising arbitrary SQL as participating automatically. Deleting a scope head or resetting a version is forbidden while historical expectations can be presented. Scope garbage collection needs durable lifetime/ generation semantics. Scope identities are canonical and domain separated; never use delimiter concatenation that aliases IDs or expose sensitive plaintext in scope keys. Sealed equality scopes use the actual token domain/family identity, not ciphertext or an invented cross-vault equality relation. ## Claim protocol and plan validation A claim compares an observed predecessor and advances exactly once inside the same transaction as all other claims, facts, retractions, journal, host writes and entries. Missing predecessor with expected version greater than zero is conflict, not permission to invent a new chain. Native serialization/deadlock failures use the existing retryable TransactionConflictError boundary. Multi-scope success is all-or-nothing, including failed outer COMMIT and savepoint rollback. Mandatory planning needs at least these phases: - Claim always-required entity/dependency scopes for input entities and relevant targets before deriving scopes from their state. Validate every source version; an old snapshot must not omit a newly added value/type/ref and derive too few keys. - Derive old and projected new value keys from the authoritative rules and the validated entity state, including retractions and type membership changes. - Acquire all remaining keys in deterministic phase/key order, then check the projected invariant. If discovery introduces an earlier-phase dependency, restart or validate a complete fixed point; do not acquire ad hoc keys in a conflicting order and call it deadlock-free. - Freeze the plan before ordered publication. Finalization must not discover a previously unclaimed invariant. Validate pinned policy identity throughout. A possible implementation can use transactional head rows with conditional updates and absence inserts, as tested. It is not yet a public ConflictScope or ScopeClaim contract. Version granularity for multiple staged commands in one outer commit must be settled together with pending/final receipt semantics. SQL predicate-lock false positives can still produce native serialization conflicts even on different business keys; the synthetic tests do not establish zero production SSI retries. ## Required coverage of actual graph invariants | Rule/input | Mandatory dependencies and decision | | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | Entity/attribute preconditions, absence | Entity/attribute revision even when no fact exists; all writers touching it participate | | Cardinality and required attributes | Subject, attribute and type membership; every valid-time boundary in projected state | | Uniqueness | Registered rule applicability, canonical old/new typed values, membership transitions and overlapping valid intervals | | Sealed uniqueness | Blind domain/family/token identity, scrub barriers and token rotation; no plaintext scope keys | | Reference targets | Target existence/type/deletion and incoming-reference decisions; both ends cannot be protected by source entity alone | | Policy activation | Exclusive authority epoch, existing-data/violation rollout and pinned config identity | | Maintenance | Erasure, rewrap, compaction, backfill and index changes must preserve affected claims/authority atomically | Current full-graph evaluator scans cannot simply be left in place and labeled fine-grained: their read dependencies and native predicate locks may couple unrelated writers. A production change must replace or validate those reads using exact indexed dependencies. The planner must prove soundness for every supported built-in rule. General caller-defined Datalog invariants do not acquire a false guarantee from a branded scope type. ## Relationship to ordered positions Business version heads are not replay positions. They may advance independently only when the corresponding graph decisions are independent. Global publication still needs the pending-receipt/committed-reader contract and proof in the separate investigation. Nothing here replaces the mutable production counter, changes its commit order or allows a reader to see N+1 without N. The original allocator benchmark target remains unmet. Scope correctness invariant: for any two commands whose projected effects can change the outcome of a checked invariant, their validated mandatory scope sets must overlap at a predecessor protected by the same database policy. A committed change to that dependency after the other's observed cut causes mismatch or native snapshot conflict before the invariant decision is accepted. Scope enumeration, not the conditional-update syntax, is the missing substantial proof. ## Remaining work and review boundary No runtime code or consumer API changes in this checkpoint. PostgreSQL 17.8 supplies seven diagnostic cases; SQL/SQLite/Cloudflare and KV/FoundationDB keep their current safe transaction boundary. Adapter-independent public scope planning, native FoundationDB transaction tests, Cloudflare workerd coverage, policy migration and actual graph/cheap-precondition conformance remain required for a future implementation. The owner direction settles registry authority over caller-only scopes; it does not settle the new staged API or constitute proof of the planner. Recommendation: settle the pending receipt/read profile, then review a concrete registry/planner contract before introducing public types or migrations. Keep #63/#64 as design history and link this evidence; do not mark TX1.3/4 implemented. Validation: focused seven-case diagnostic passes. `pnpm check` passes (40 documentation links); `pnpm test:postgres:integration` passes all 45 tests across three files. The focused seven-case suite also passes after strengthening the activation assertion to SQLSTATE 55P03. `pnpm pack:check` verifies 11 tarballs and a clean consumer. The default PostgreSQL test command now quotes its exclusion glob (fixed in the preceding investigation PR), retaining six unit tests and keeping native tests only in the explicit integration gate. Empty Changeset records this design/test checkpoint, with no package version, adoption or migration requirement. The two new protocol diagnostic files also run through TypeScript in the package typecheck gate (`tsconfig.protocol-tests.json`). Their scoped callback requirements are explicit; Vitest transpilation alone would not catch that missing annotation. --- # TX1.3: late allocation needs a transaction protocol decision Update after owner direction: The owner selected staged ordered publication with database-owned invariant scopes. The [follow-up investigation](./tx1-ordered-publication-investigation) now tests that direction and exposes an older-writer own-snapshot hole. The remaining owner decision concerns pending receipts and a committed-only reader boundary, rather than whether to investigate the direction. The original options below are retained as design history; production allocation remains unchanged. Status: **proposal requiring an owner decision; no allocator change implemented**. This is the TX1.3 checkpoint in the WorldVM operational-primitives goal. It follows the contention baseline and typed conflict work in PRs [#61](https://github.com/bjacobso/triplex/pull/61) and [#62](https://github.com/bjacobso/triplex/pull/62). ## Evidence and recommendation Moving the counter update later can shorten part of a lock's lifetime, but it does not by itself remove snapshot conflicts. The recorded 16-writer baseline rejected 2,250 of 2,400 attempts at either repeatable read or serializable isolation, with the same counts in the zero-hold control. The measured workload and limitations are in [the baseline report](https://github.com/bjacobso/triplex/blob/bench/tx1-position-contention/test/evidence/tx1-baseline.md). A new deterministic Postgres test establishes an older snapshot in transaction B, writes B's own host table, then lets A update a different host table and commit its counter update as its **final SQL statement**. B then issues the same final counter upsert. B still receives a serialization conflict, and its host write rolls back. This passes at both repeatable read and serializable isolation, without timed sleeps, a held allocator lock, shared host-table predicate locks, or planted errors. The SQL experiment uses a separate synthetic counter and does not mutate Triplex's production allocator or journal. This matches PostgreSQL's rule for updating rows changed since a repeatable-read snapshot, and its stronger serializable behavior. It is a snapshot freshness problem as well as a lock-duration problem. [PostgreSQL 17 isolation documentation](https://www.postgresql.org/docs/17/transaction-iso.html) explains why retrying the complete transaction is required. Moving a row update to the end also does not release its lock before the enclosing transaction ends. See [PostgreSQL's locking documentation](https://www.postgresql.org/docs/17/explicit-locking.html). **Recommendation:** retain the current allocator and TX1.2's typed retry boundary while deciding the TX1.4 global ordering protocol. Do not introduce temporary positions or weaken the serialized invariant checks as an incidental optimization. A narrower late-allocation optimization can follow if its staging contract and measured benefit are approved; it must not be reported as resolving the 16-writer serialization requirement. ## What the current contracts require The current `StorageAdapter.insert`, `batchInsert`, and `retract` operations all receive their final position. Facts, retraction visibility, journal envelopes, pagination, and dependency state rely on those positions being atomically stored. The shared SQL service allocates before checking entity-state and graph constraints. The KV service follows the same check/mutate atomic boundary. Existing concurrent absence and uniqueness decisions depend on that ordering. Consequently, allocating after writes is not a local movement of one statement: - SQL and KV mutation contracts need a way to stage and finalize positions. - All staged assertion/retraction indexes must be finalized before commit, including journal metadata and in-transaction reads. - Preconditions and cross-entity constraints need an equivalent serialization or scope-claim mechanism before their reads. A final counter update alone cannot protect an earlier absence or uniqueness check from a concurrent writer. - A nested Triplex call returns its receipt before the host commits. Triplex cannot shorten host work after that return by moving statements within the nested call. These are public runtime/storage decisions, not an established contract in the current adapter interface. This proposal does not claim that no alternative protocol could satisfy the goal; it identifies what a local relocation cannot do. ## Options for the owner | Option | Benefit | Cost / remaining limitation | Ordering and constraints | | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | A. Keep allocation unchanged while designing TX1.4 | Preserves existing composition; typed conflicts are available now | Contention and higher-isolation failures remain; TX1 is incomplete | Existing guarantees retained | | B. Approve a staged-mutation/finalization contract, with an early invariant gate | Could reduce the allocator window for eligible read-committed commands | Cross-backend schema/index/rollback work; the gate may remain contended; final counter updates still fail against old snapshots | Must prove equivalent invariant protection and finalization before exposing a receipt | | C. Opt-in host commit admission before the transaction's first snapshot | Can avoid beginning overlapping snapshots behind one shared updater | Serializes unrelated transactions and requires explicit host participation; does not meet the concurrency objective | Must be designed before host SQL/reads begin; cannot be retrofitted inside an ambient transaction | Choose **A** for the current implementation, and authorize a concrete ordering protocol investigation for TX1.4. Choose B only if its narrower read-committed benefit is worth a separate public staging API, with explicit scope and acceptance criteria. C is a possible operating mode, not the requested parallel-writer fix. An ordinary sequence is not an admissible shortcut: one writer can reserve N and remain uncommitted while another commits N+1. Rollback can also leave reservations unused. Sequence identity is not ordered publication, and would violate the stated snapshot/commit-position invariant. The non-transactional behavior is documented in [PostgreSQL sequence functions](https://www.postgresql.org/docs/17/functions-sequence.html). ## Required decision and blocked work The owner must decide whether to keep TX1.3 coupled to the TX1.4 ordering design (recommended), or approve a narrower staged-write optimization and its contract. Until then, production late allocation is **not implemented**, the zero-failure 16-writer target remains unmet, and no allocation/constraint guarantee is weakened. This draft does not authorize a change in position semantics or isolation level. Any implementation must first add failing cross-adapter tests for finalization, read-your-writes, abort/savepoint cleanup, preconditions, cross-entity uniqueness, reference targets, and exact historical reads. Then rerun the full TX1 baseline with the same settings and report both failures and waiting. A shorter measured lock window alone is not evidence of eliminating snapshot conflicts. ## Verification and compatibility Exact diagnostic command: ```sh pnpm --filter @triplex-build/triplex/postgres exec vitest run test/integration/postgresql.test.ts -t 'final-statement counter' ``` It passes both isolation cases on Postgres 17.8 in the existing testcontainer fixture. `pnpm test:postgres:integration` passes all 30 tests and `pnpm check` passes, including the new documentation page and 36 local llms links. An initial docs build caught a link to evidence outside the published docs root; it now uses the repository evidence URL. This is a reproducible mechanism test, not a throughput measurement or a replacement for the 1/4/16-writer benchmark. There has been no new allocation implementation to benchmark. The PR changes only the diagnostic test and this proposal. Public API before/after: unchanged. Consumer adoption and migration: none. Postgres supplies the mechanism evidence; SQL, SQLite, Cloudflare, KV, and FoundationDB runtime implementations are unchanged. The native FoundationDB limitation recorded in the goal still applies. An empty Changeset records this non-publishable design/test checkpoint. --- # TX1.3 investigation: staged ordered publication Status: **experimental evidence and proposed profile; no production allocator change**. The owner selected staged ordered publication with database-owned invariant scopes after the independent packages. This follows that direction, without interpreting it as approval to weaken the snapshot invariant. ## Candidate mechanism and observed result A synthetic PostgreSQL candidate stages disjoint host-table writes first, then: 1. Acquires one transaction-owned advisory publication gate. 2. Uses a separate client's fresh read-committed statement to find the maximum committed position in an immutable publication table. 3. Inserts the successor position into the writer's original transaction. 4. Commits immediately, releasing admission only when that native transaction ends. No ordinary sequence or shared mutable position row is used. On abort, the publication row and host writes roll back; the next admitted writer reuses the successor. The fresh client has a distinct fiber-local transaction service and pool, so its read does not accidentally inherit the writer's older snapshot. This auxiliary resource must be reserved independently: allowing every writer to occupy the same finite pool while waiting for another connection can deadlock. The experiment uses only synthetic tables; it does not install a Triplex profile, change its allocator or migrate a consumer. Its public before/after API is identical. Four native tests establish: - At repeatable read and serializable, an older disjoint writer can commit position 2 after another writer commits position 1, without a shared-counter update error. **Before its own commit, that older writer sees `[2]`, not `[1,2]`.** Both native transactions commit. A second experimental view excludes rows whose stored writer xid8 equals the current transaction ID: the older writer sees no final positions there, while fresh readers see [1,2] after commit. This tests the smallest committed-only read filter, not all fact/journal paths or role isolation. The complete-cut assertion was run first and failed in both cases; the retained diagnostic pins the counterexample rather than blessing it. - An external reader cannot see an uncommitted publication; a repeatable-read snapshot pinned before publication remains empty after two commits, and a fresh reader sees `[1,2]`. The gate rejects another transaction's try-lock while held. Aborting position 1 leaves no row; the next publisher receives 1, then 2. - Separate batches of 1, 4 and 16 simultaneously prepared writers succeed at each of read committed, repeatable read and serializable: 63 native transactions in nine cells, no retries, all successful and contiguous external positions. Each writer has its own host table; a 32-connection pool permits all 16 to reach the preparation barrier. No writer queries or host callbacks run after finalization. The last item is a **mechanism diagnostic**, not the original throughput benchmark: there is no Triples journal, sensitive policy, shared fact index, scoped invariant checking, long command body or production allocator integration. It cannot satisfy TX1's required rerun or establish production zero-failure throughput. The other three tests include the reader counterexample that rules out ambient substitution. Exact command: ```sh pnpm --filter @triplex-build/triplex/postgres exec vitest run test/integration/staged-publication.test.ts ``` The candidate matches PostgreSQL's documented snapshot behavior: repeatable reads retain their snapshot while seeing their own writes. Transaction advisory locks supply admission, not a refreshed snapshot. [Isolation documentation](https://www.postgresql.org/docs/17/transaction-iso.html), [locking documentation](https://www.postgresql.org/docs/17/explicit-locking.html). Serializable does not require serialization order to equal commit order for concurrent native transactions. Our stronger Triplex position contract must be implemented explicitly. ## Why the existing ambient API cannot adopt this transparently A nested `Triples.transact` currently returns a final position while the host's native transaction stays open. If its snapshot precedes another committed position, exposing its own successor makes that native snapshot incomplete. Neither a database-owned business scope nor an advisory gate makes predecessor rows visible in an old snapshot. An automatic second connection for Triplex reads would split atomic host state from Triplex state and lose coherent read-your-writes. Silently changing isolation level or retrying only a nested savepoint is not a fix. This is a counterexample to this candidate under the unchanged ambient contract, not a proof that every possible database implementation is impossible. A design that aborts every such older writer preserves safety but reintroduces the very allocation-driven retry rate the goal seeks to remove. ## Proposed commit-owned staged profile Recommend an **opt-in, commit-owned staged transaction API**, retaining today's ambient profile as the compatibility path. Names below describe semantics, not proposed exports ready to implement: - The outer staged runner owns the native transaction and its commit. Reject entry from an already active ambient native transaction, rather than accidentally treating a nested savepoint as commit ownership. - Its callback may perform host SQL and stage Triplex commands. It receives a pending command identity, never a provisional final commit position. Read APIs expose a coherent preparation cut plus explicitly unpositioned local overlays, or reject unsupported queries with a typed error. They must not label that overlay as a complete global recorded-position cut. - The callback finishes before finalization. The runner validates mandatory scopes and frozen staged plans, acquires publication admission, obtains a fresh committed successor, writes final fact/retraction/index/journal/entry positions and commits. No user callback or native read runs after finalized positions become visible to that writer. Final receipts are returned only after successful native COMMIT. - Abort/savepoint/cancellation cleanup is atomic. A failed commit yields no final receipt. Connection loss has an indeterminate outcome and requires command-ID reconciliation, not blind retry. Multiple staged commands need explicitly settled one-position-per-command versus one-position-per-outer-commit semantics. - The invariant applies to published cuts and ordinary snapshot readers. A reader cannot obtain N+1 through the supported profile before predecessors are visible. **Direct host SQL must also be addressed**: a trigger executing during finalization could read the writer's incomplete native publication view. Disallowing callbacks alone is not a proof about arbitrary host triggers, deferred constraints or direct SQL against finalizing tables. Admission needs restricted database access/trigger rules or a committed-only read surface that makes such readers safe. The experimental view suggests excluding the current writer’s final publication records, with pending overlays handled separately. Every fact/journal/position read must use equivalent filtering, and runtime roles must lack direct private- table access (including through host trigger functions). Those permissions and complete query-path coverage are not implemented here. This remains a design gate, not an implicit exception to “any snapshot reader.” The recommended next design must specify that last boundary precisely. Exposing finalizing rows to arbitrary native host triggers is incompatible with the observed counterexample. A separate post-commit visibility layer can hide staged positions, but then atomic host visibility, crash recovery and receipt acknowledgement become new contracts; do not claim it preserves the old ambient semantics automatically. ## Owner choice required before production implementation | Choice | Consequence | | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Design a restricted commit-owned staged profile (recommended next investigation) | Pending receipts inside callbacks, final receipts after commit, explicit limits on native finalization readers/triggers; requires a precise proof and API review | | Require arbitrary ambient/native snapshot readers to retain the current API | Keep the proven allocator and typed full-transaction retries; this candidate cannot meet the zero-allocation-conflict target under that contract | | Investigate post-commit publication instead | Different visibility/recovery/acknowledgement contract, no immediate final position from nested calls; must be designed separately | The owner selected the overall direction, but has not yet selected these material receipt and native-reader restrictions. Record that choice before implementing a public staging API or schema. Database-owned invariant scopes are investigated separately; they solve conflict authority, not this snapshot issue. ## Remaining proof and adapter coverage External-reader prefix proof for this synthetic mechanism: admission is exclusive until native transaction completion; the fresh head query therefore observes the previous admitted commit or its rollback before choosing a successor; publication and business rows commit atomically. A snapshot after successor commit includes its already committed predecessors; a snapshot before it omits the successor. The proof assumes all publishers use this gate and no publication records are removed or altered. It does not cover the older writer's own snapshot, as shown. No adapter ships this mechanism. PostgreSQL 17.8 supplies the diagnostic evidence. KV/FoundationDB, shared SQL/SQLite and Cloudflare keep their current allocator; no cross-adapter public interface has changed. SQLite/DO serialization may supply a simpler commit-owned implementation; KV/FDB require native conflict/order and snapshot proofs, including native FDB infrastructure currently unavailable here. A production proposal still needs staged read/write/index semantics, policy epoch and scope claims, all graph invariants, old/new writer profile exclusion, commit failure/savepoint/crash recovery, sensitive journal copies, operational entries, replay/page/host-read parity and the original 1/4/16 benchmark matrix. The existing allocation target is **unmet**. No migration or consumer adoption is available. Validation of this checkpoint: focused diagnostic passes four tests. `pnpm check` passes (39 documentation links), `pnpm pack:check` verifies 11 tarballs and a clean consumer, and `pnpm test:postgres:integration` passes all 38 tests across the existing suite and this four-case diagnostic. The native integration script now selects the whole integration directory, so this diagnostic runs in the existing integration gate rather than only manually. An empty Changeset records this non-publishable experiment and design. --- # TX1.4: scope claims need an ordered-publication protocol Update after owner direction: The owner selected database-owned invariant scopes and staged ordered publication. Follow-up [publication](./tx1-ordered-publication-investigation) and [scope-authority diagnostics](./tx1-invariant-scope-investigation) now test those directions. The initial authority choice is resolved; the new receipt/read-profile decision and full scope-planner proof remain pending. The original options below are design history, not a repeated request for the same direction decision. Status: **conditional design proposal; owner decisions required; no implementation**. Read [the late-allocation diagnostic](./tx1-late-allocation) and the existing [scoped-concurrency sketch](../operational-primitives#deferred-scoped-optimistic-concurrency). The TX1.3 diagnostic is deliberately stronger than merely removing a timed hold: the second writer updates the counter after the first writer has committed, yet its older snapshot still fails. ## Recommendation Approve a concrete, opt-in protocol design before adding `ConflictScope` or scope head tables. Preserve the current runtime as the compatibility profile while the new protocol is proved. The design must settle **both** scope authority and global publication. Sharding business version heads while keeping the same mutable global counter does not remove the measured allocation conflict. Do not ship scope claims alone as completion of TX1. It would add public concepts and maintenance work while leaving the proven shared updater unchanged. Do not weaken absence/uniqueness enforcement or replace the counter with an uncoordinated sequence. The strict ordered-position target remains a requirement, not an option this proposal relaxes. ## What the roadmap already settles The operational-primitives sketch requires a branded conflict identity, atomic multi-scope claims, successor continuity, and claims/facts/journal in one transaction. It correctly distinguishes a business conflict version from a global replay cursor. An entity-only version is insufficient for cross-entity invariants. An illustrative claim shape is: ```ts interface ScopeClaim { readonly scope: ConflictScope; readonly expectedVersion: number; } ``` This is a proposal fragment, not an exported API. A successful claim must compare the current version and advance to exactly its successor inside the same atomic boundary as every other claim and mutation. A unique `(scope, version)` record alone is insufficient: it does not compare the complete predecessor state. ## Decision 1: who determines mandatory scopes? All Triplex writers that can affect a checked invariant must participate. A caller cannot opt out of backend-derived mandatory claims merely by omitting `enforce` or naming only its own entity. Direct raw adapter/SQL writes remain outside the existing Triplex enforcement guarantee, but ordinary `Triples` writes do not gain a new silent bypass. | Concern | Scope obligations that a concrete protocol must settle | | ----------------------- | ------------------------------------------------------------------------------------------------------------------ | | Entity/attribute state | Claims for changed entities and guarded attributes; both old and new keys on replacement/retraction | | Absent entity/attribute | A claim that exists even when no fact exists, so an insertion races with the absence check | | Required/cardinality | Subject/type/attribute identity and relevant valid-time boundaries | | Uniqueness | Canonical value identity, type/rule applicability, old/new values, and changes to type membership | | Sealed uniqueness | Existing blind-token equality domains and codec/key scope; no plaintext scope identities | | Reference targets | Target existence/type/deletion and incoming references; entity-only locking cannot protect both ends automatically | | Policy/config changes | Scope identity and participation across pinned rule versions and activation changes | | Maintenance | Unjournaled operations must still maintain any affected scopes/indexes atomically | The present API supplies plain versioned constraint rules per command. It does not install a database-wide authoritative active-constraint registry. That leaves a material choice: - **Database-registered invariant participation** (recommended for investigation): all writers derive required keys from a versioned database policy, and policy activation/change is itself guarded. This can support narrow value scopes but introduces an authority/lifecycle contract not present today. - **Conservative always-derived scopes:** derive enough entity/type/attribute/value keys without such a registry. This avoids trusting caller lists, but common attributes/values may become new database-wide hot spots. It needs a proof of completeness and contention measurements before being called fine-grained. - **Caller-only scopes:** insufficient for built-in invariant guarantees; not an admissible replacement for existing serialization. Explicit application read dependencies may add claims, but cannot remove mandatory ones. Scope enumeration that reads facts must also validate that its source state did not change before all claims are acquired. Deterministic acquisition order avoids one class of deadlocks, but is not a substitute for validating predecessor versions or claiming a previously absent scope atomically. ## Decision 2: how is the global commit cut published? Two disjoint sets of scope heads do not change a later upsert of the same global counter. The TX1.3 diagnostic already models that separation with independent host version rows followed by one final counter update. At higher isolation the global updater can still reject an older snapshot. The owner must choose a concrete publication protocol/profile to investigate: | Candidate | Possible direction | Proof or contract still missing | | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Keep the current native ambient profile | Retain final positions in mutation calls and typed full-transaction retries | Safe compatibility baseline, but the allocation-contention target remains unmet | | Staged mutations plus a short ordered commit gate | Mutate independent business scopes in parallel, finalize positions/journal near commit; investigate a non-MVCC reservation source with serialized commit admission and immutable committed-position records | A complete commit-order protocol, crash/savepoint behavior, all-writer participation, and visibility to the host's older snapshot; an ordinary sequence alone is insufficient | | Explicit host-coordinated preparation/publication API | Prepare from an observed cut outside the final transaction, then validate scopes and perform a short atomic host/Triplex commit | New host composition contract; an already-open arbitrary host snapshot cannot silently be replaced; short serialized mutation work still needs measured throughput | | Post-commit ordered publication | Investigate an authoritative backend commit record and ordered publication of facts already durably committed | A nested call cannot return its final commit-order position before that order is known; acknowledgement, read visibility, recovery, and adapter parity need a different contract | **Recommend investigating staged mutations/ordered admission first**, with the current profile retained. This is an investigation recommendation, not a claim that the candidate already satisfies the invariants. It must include immutable committed-position discovery rather than just moving the same MVCC counter row. Do not assume that PostgreSQL-specific machinery automatically supplies a portable KV/SQLite/Durable Object implementation. ### The older writer's own view is an explicit gate External readers are not the only case. A host transaction may have an old snapshot, write its own facts, and read them again before its outer commit. If a proposed protocol lets that writer obtain N+1 after another transaction commits N, its own native snapshot may include its new writes while omitting N. A publication proposal must specify how `currentPosition`, transaction feeds, explicit position reads, default Datalog pages, and read-your-writes behave in that situation. Returning an apparently complete N+1 cut with N missing is forbidden. It must provide a coherent cut or a deliberate typed failure/profile restriction, and keep existing behavior in the compatibility profile. This cannot be left as an implicit exception to the goal's "any snapshot reader" invariant. Likewise, a nested Triplex call currently returns a position before the host commits. If a candidate changes when that value becomes final or how long admission is held, the public receipt and ambient transaction contracts must say so. Ordering an internal journal separately from host commit order is not an equivalent guarantee. ## Cross-backend proof required before implementation is accepted Any selected protocol must demonstrate all of the following: 1. Initial scope creation and claims cannot be split from record insertion or publication; partial multi-scope success rolls back completely. 2. Predecessor comparison is atomic and successor versions cannot skip or fork. 3. Two absent-value claimants, insertions racing with absence guards, and mixed enforcing/non-enforcing Triplex writers cannot evade built-in guarantees. 4. Unique values and reference targets remain correct across types, retractions, replacements, policy versions, and relevant valid-time intervals. 5. Old readers, current readers, and older writers reading their own changes never receive a purported global cut with a missing committed predecessor. 6. Savepoint rollback, failed outer commit, cancellation, connection loss, and process failure leave neither orphan claims nor falsely published positions. 7. Command receipts and host rows/outgoing intents retain their atomic boundary. 8. Shared KV/SQLite/Postgres conformance covers the protocol; Cloudflare's native Durable Object transaction behavior is tested. FoundationDB's native coverage must be run where the client is available, not inferred from in-memory KV alone. 9. The original 1/4/16-writer matrix is rerun. Report allocation failures, other serialization/deadlock causes, waits, latency, and throughput. Moving contention to a new lock or renaming the failure does not satisfy the goal. ## Owner decision and present status The decisions are (1) mandatory invariant-scope authority and (2) the concrete ordered-publication profile to prototype, including older-writer visibility. Approve or revise those directions before public scope/staging APIs or migrations are implemented. Until then TX1.4 is **blocked on design**, TX1.3 remains coupled to it, and the allocator performance objective remains unmet. This proposal changes no public API, data format, backend implementation, or consumer migration. Its before/after behavior is identical. Evidence is the existing 54-cell benchmark and the new deterministic TX1.3 diagnostic; no scoped concurrency implementation or new scoped benchmark has been run. The empty Changeset records a documentation checkpoint without versioning private packages. Other requested work can use the current safe transaction boundary and proceed independently, including inbox/outbox, cheap preconditions, filtered feeds, and the host-sealing RFC. Validation: `pnpm check` passes, including formatting, lint, TypeScript, the shared backend suites/workerd, package builds, and the docs build with 37 local llms links. No runtime changed in this checkpoint, so no new scoped-concurrency benchmark or adapter migration has been executed. The preceding TX1.3 Postgres diagnostic/full suite remains the evidence for the unchanged counter protocol. --- # RFC: host-sealed values and erasure Status: proposed; owner decision required before implementation. This document recommends an explicit trusted-host import contract. It does not change the sensitive decorator's rejection of caller-supplied envelopes or promise erasure that Triplex has not implemented. ## Consumer and existing behavior WorldVM seals before calling Triplex, with keys scoped by vault and retention class. That boundary is a consumer decision supplied with this request; no WorldVM checkout or modification is needed for this RFC. It wants Triplex's indexed sealed equality and joins without exposing plaintext to Triplex's sealing service. The motivating claim needs qualification. Raw `Triples` already accepts `SealedValue` (declared scalar type, key ID, ciphertext and optional blind token). The shared `sealedStorageCases` explicitly exercise raw imports, blind equality and joins. Conversely, `Sensitive.prepareAssert` rejects sealed input with `SensitiveValueError` reason `sealed-input`; the decorator corpus pins this behavior. The documented non-goal concerns a supported client-sealing policy boundary, not an absent physical ciphertext representation. Current source contracts: - `ValueCodec.SensitiveContext`: database, entity ID, attribute and declared type. - `ValueCodec.aesGcmWith`: synchronous `subjectOf(context)` plus database/attribute blind scope. Authenticated data binds database, entity, attribute and type. - `KeyProvider.KeyContext`: database and opaque subject. Data keys are selected by that pair; opening also receives the stored key ID. - `KeyProvider.blindKey(database)`: one database-wide HMAC key, without subject, vault, retention class or historical version lookup. - The reference codec computes blind tokens itself. Its joins compare token identity, not cryptographically verified plaintext equality at query time. See [sensitive values](../sensitive-values.md) and the [operational-primitives roadmap](../operational-primitives.md). The relevant sources and shared tests are in `packages/core/src/sensitive`, `packages/core/src/Value.ts` and `packages/testkit/src/sensitive.ts`. ## Options | Option | Meets existing WorldVM plaintext boundary | Main consequence | | --------------------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | Continue raw sealed writes | Yes | Already possible, but bypasses decorator policy and supplies no trusted-import/erasure integration contract | | Explicit trusted-host envelope import | Yes | New opt-in authority, token-domain and maintenance contracts; reverses the stated non-goal narrowly | | Adopt reference codec with custom KeyProvider | No, unless WorldVM changes its decision | Reuses sealing/authentication, but Triplex codec receives plaintext and blind scoping needs extension | | Custom ValueCodec | Plaintext seal method still receives PlainValue | Can replace crypto, but is not itself an envelope-import API; disguising envelopes as plaintext abuses logical typing | Recommend **explicit trusted-host envelope import**, gated on owner approval of the trust model and erasure design below. Keep ordinary `Sensitive` writes plaintext-only. Do not expose import automatically through HTTP, CLI or ordinary tenant write permissions. Do not advise raw adapter access as the integration API. ## Proposed trusted-host contract A host composes a separate import capability into its runtime. Proposed names and shape are illustrative, not exported API. An import supplies the existing sealed envelope plus a registered opaque encryption/erasure domain and blind-token namespace/version. The host determines domain membership from authenticated application policy; an untrusted caller cannot choose a weaker scope or mark someone else's value erased. Domain identifiers and key IDs contain no sensitive values. Authorization is host-owned; Triplex enforces the configured import policy before the same atomic transaction as its facts, journal and outgoing entries. Validate envelope shape, allowed logical type, size limits, registered formats, key/domain association and token namespace/version. A registered host verifier may verify a signature/MAC binding the envelope, blind token, domain, database, entity, attribute and declared type. It must not require Triplex to hold data keys. Merely checking base64url or accepting a token string is not authentication. Verification and domain mapping must be deterministic for retries; failures are redacted typed errors. Keep key material and plaintext out of errors/telemetry. Triplex would guarantee atomic persistence, historical cuts, opaque round-trip, indexed token equality, full journal retention and the declared redaction/query rules. It would **not** guarantee that ciphertext decrypts, that the declared type matches decrypted plaintext, nonce safety, key destruction, canonical plaintext, or that equal tokens imply equal plaintext. Those depend on the host's crypto and canonicalization. Even a signature authenticates the submitting authority, not plaintext equality. A dishonest or buggy importer can create false joins and uniqueness violations or evade uniqueness with different tokens. Hosts accepting multiple importers must share a trusted canonicalization/token authority. Blind equality is permitted only within an explicitly registered comparable domain and compatible scalar family. Tokens must encode a collision-resistant namespace and version, and domain separation must participate in actual engine identity; metadata-only labels would not prevent cross-domain joins. Database-wide joins across vaults are an explicit leakage decision. Per-vault/retention tokens prevent those joins by design. Missing tokens preserve presence reads but support no blind equality/uniqueness. Constants need a host token service or authorized typed token query API; existing plaintext constant rewriting cannot invent foreign tokens. Do not allow an untrusted caller to supply arbitrary token lookup requests. Retries preserve the exact imported envelope where command idempotency compares payloads. Randomly resealing the same plaintext is not byte-identical. Key or blind-version rotation requires an explicit transition protocol: dual-version lookups and uniqueness checks, reindexing, and eventual retirement. None exists merely because envelopes contain key IDs. Old ciphertext formats need a registered opener or remain opaque; the reference AES-GCM opener cannot decode arbitrary host envelopes. ## Alternative: custom KeyProvider Data-key vault/retention scoping often needs **no KeyProvider signature change**: `subjectOf` can return a stable opaque encoding of vault plus retention class, and a provider resolves that subject's current key and historical key IDs. This works when that mapping is derivable synchronously from context and remains valid for old facts. It is insufficient when class membership depends on the plaintext, asynchronous policy lookup or changes over time without a stored historical mapping. The current envelope has no explicit subject field and `openingKey` receives the subject recomputed from today's context. A provider must not silently open under a newly assigned retention domain. A first-class version of this alternative would need an immutable authenticated key-domain reference recorded with the envelope, an asynchronous policy resolver where required, historical-domain opening and a defined erasure domain enumerator. Those concern codec/context/envelope policy as well as KeyProvider; adding a `retentionClass` argument alone does not solve historical resolution. The larger gap is blind keys. `blindKey(database)` cannot select vault/retention keys. Extend it with a registered equality-domain context and versioned historical resolution, update `blindScope`/query planning to describe domain comparability, and implement rotation/dual-version uniqueness. Database-wide blind tokens remain linkable after a vault data key is destroyed, so data-key scoping alone is not erasure. Keeping the existing signature is viable only if WorldVM deliberately accepts database-wide equality leakage and separate scrubbing. This alternative also requires WorldVM to let Triplex's codec see plaintext and adopt its canonicalization, envelope/AAD and opening boundary, or supply a custom codec with equivalent guarantees. It cannot be presented as respecting the existing pre-Triplex sealing decision automatically. Choose it if the owner and consumer explicitly prefer that boundary change over trusted import. ## Erasure and blind-token scrub The existing design calls for an unjournaled, resumable blind scrub plus one journaled erasure fact with no attribute/value, retaining presence and audit positions while removing equality. That maintenance API is not built. Imported values must join the same protocol; a separate host SQL blind table does not make Triplex's retained copies disappear. Required design before implementation: 1. Persist authenticated domain-to-fact membership independently of current key availability and mutable application facts. Include retracted historical facts, journal `:_tx/change` copies and any canonical token-bearing projections. 2. Durably fence a domain against new assertions/imports and in-flight writers; otherwise a scan can finish while a concurrent write restores a token. Define crash-safe ordering with external KMS destruction, retries and a persisted erasure job. External key destruction and database mutation are not one atomic transaction. Do not claim completion until the required participants confirm. 3. Remove blind tokens from SQL value_blind, KV META and token-bearing EAVT/AEVT/ AVET keys, historical journal JSON copies and relevant derived/configuration copies. Update caches and indexes together; TX5's live index stores IDs only. Keep ciphertext/fact identity/times/positions when permitted. No ordinary retraction suffices: recorded-position replay must not resurrect scrubbed tokens. 4. Handle snapshots/checkpoints, exports, replicas and backups explicitly. Immutable content-addressed objects cannot be rewritten under their old content IDs; retire/delete or replace manifests with an erasure-aware read barrier. Offline copies require their own retention/deletion policy. Triplex cannot revoke a token or ciphertext already copied by a consumer. 5. Define completion and reader behavior during incremental scrub. Prefer a durable domain read barrier suppressing equality immediately, followed by resumable physical cleanup. Uniqueness reuse, concurrent inserts and historical reads must observe the same barrier. Presence and opaque erased-value reads survive; equality/joins do not. A final erasure fact records an opaque domain/job ID, completion time and policy version, with no token/key material or values. 6. Outbox/inbox payloads are host-owned canonical payloads, outside automatic Sensitive sealing. They can carry ciphertext or tokens and need an explicit participant/retention policy. Audit these and external blobs rather than claiming attribute erasure also erases operational payload history. Scope should support one vault/retention domain rather than assuming one entity or subject. The host destroys the corresponding data keys. Blind-token deletion is still necessary even if blind keys are also destroyed: a previously retained token remains a correlation identifier. An erasure job must not depend on opening ciphertext with a destroyed key to discover its scope. ## Acceptance and owner decisions Owner approval is required for reversing the client-sealing non-goal and for the chosen equality-domain/leakage boundary. Open questions: host verification trust, immutable domain representation, token query authority, domain fencing/read-barrier protocol, erasure fact semantics and backup responsibility. Recommend first approving the trusted-host direction, then a separate detailed API/maintenance RFC before code. Existing raw sealed writes remain available with their current limited guarantees; this proposal adds no default acceptance or weaker policy. Any later implementation must cover KV/FoundationDB and SQL/SQLite/Postgres/ Cloudflare, including sensitive decorator composition, journal receipts and cheap preconditions. Required failing tests: forged/moved envelopes rejected; domain-isolated joins; opaque round-trip/replay; token-version uniqueness; erasure concurrent with writes and reader snapshots; interruption/restart at every maintenance phase; raw-storage scans finding no scrubbed token in retained copies. Native FoundationDB infrastructure must be arranged for its maintenance proof. No runtime tests are added for this documentation-only RFC. Adoption today: none. No migration, public API change or release version change. After approval, a future migration may add domain membership, erasure barriers and maintenance indexes on all adapters; existing envelopes need an explicit backfill policy. Legacy data with unknown domains must not be declared erased by inference. --- # RFC: one explanation operation, `why` Status: proposed for owner approval before P3.2. This RFC changes no runtime or public exports. It follows the settled decision: stop at the asserting transaction and its pinned release by default; follow command causation only to an explicitly requested depth. New implementation targets KV, SQLite and PostgreSQL. ## Subjects and ownership Add the browser-safe `Why` Effect service in core, exported from the proposed `@triplex-build/triplex/why` subpath. `Why.layer` requires `Triples` and `ConfigStore.ConfigStore`; it imports no backend, host, codec or Node API. One `why(subject, options)` operation has a discriminated subject, rather than four operations with divergent temporal and serialization rules: ```ts // Proposed contracts, not currently exported. Brands are strings on the wire. type BasisInput = { readonly validAt?: number; readonly recordedAt?: number; readonly recordedPosition?: number; }; type WhySubject = | { readonly kind: "fact"; readonly tripleId: TripleId } | { readonly kind: "attribute"; readonly entityId: EntityId; readonly attribute: string } | { readonly kind: "entity"; readonly entityId: EntityId } | { readonly kind: "candidate"; readonly definition: Derivation.Definition; readonly candidateId: ContentId; readonly revision: ContentId; }; type WhyOptions = { readonly basis?: BasisInput; readonly causationDepth?: number; // default 0; integer 0..32 }; interface WhyService { readonly why: (subject: WhySubject, options?: WhyOptions) => Effect.Effect; } ``` Attribute and entity subjects select every visible fact at the basis, preserving multi-valued attributes. They do not pick a latest value or infer cardinality. An empty selection is a successful explanation with `facts: []`, not evidence that the entity never existed. A fact subject selects that exact assertion, including one no longer visible. An assertion first recorded after the requested cut is indistinguishable from a missing ID: `WhySubjectNotFound`. A candidate subject supplies the complete immutable definition, candidate ID and revision, plus an explicit recorded basis and `validAt`. Evaluate it through `Derivation.evaluate` and select the matching candidate. No match is `WhySubjectNotFound`; the same ID with a different revision is `WhyCandidateRevisionMismatch`. This explains a replayed candidate at a stated basis, not an implicitly current materialization. Callers can take the candidate and basis from the public materialization APIs, but must also retain its definition. Historical definition lookup by ID alone is not part of P3.2. Hypothetical overlay candidates are outside this initial contract. ## Result and serialization Return the following versioned DTO. The fragments use existing public `TripleValue`, `TripleId`, `EntityId`, `TransactionId`, `ContentId`, `Derivation` and Effect types. Every DTO field is required. `null` means absent; arrays are complete and ordered. Times are finite epoch milliseconds and positions are safe integers. Never expose Effect `Option`, `Map`, `Date`, `undefined`, bigint or non-finite numbers in a result. `TripleValue` retains its existing JSON-safe tagged encoding, including opaque sealed envelopes. ```ts type Release = | { readonly status: "unpinned" } | { readonly status: "missing"; readonly id: string } | { readonly status: "found"; readonly id: string; readonly label: string; readonly rootCid: ContentId; }; type TransactionExplanation = { readonly txId: TransactionId; readonly position: number; readonly instant: number; readonly actor: string | null; readonly commandId: string | null; readonly correlationId: string | null; readonly causationId: string | null; readonly release: Release; }; type FactExplanation = { readonly tripleId: TripleId; readonly entityId: EntityId; readonly entityType: string | null; readonly attribute: string; readonly value: TripleValue; readonly valid: { readonly from: number; readonly to: number | null }; readonly recorded: { readonly from: number; readonly to: number | null }; readonly visible: boolean; readonly assertion: TransactionId | null; readonly retraction: TransactionId | null; }; type CausationTrace = { readonly start: TransactionId; readonly ancestors: readonly TransactionId[]; // immediate parent first readonly stop: | { readonly reason: "disabled" | "root" } | { readonly reason: "depth-limit" | "missing" | "after-basis"; readonly commandId: string } | { readonly reason: "cycle"; readonly commandId: string; readonly txId: TransactionId }; }; type Explanation = { readonly version: 1; readonly subject: | { readonly kind: "fact"; readonly tripleId: TripleId } | { readonly kind: "attribute"; readonly entityId: EntityId; readonly attribute: string } | { readonly kind: "entity"; readonly entityId: EntityId } | { readonly kind: "candidate"; readonly candidateId: ContentId; readonly revision: ContentId }; readonly basis: { readonly validAt: number; readonly recordedPosition: number }; readonly facts: readonly FactExplanation[]; readonly transactions: readonly TransactionExplanation[]; readonly derivation: null | { readonly name: string; readonly candidate: Derivation.Candidate; readonly release: Release; readonly evaluationNextTemporalBoundary: number | null; }; readonly causation: { readonly maxDepth: number; readonly traces: readonly CausationTrace[]; }; readonly gaps: readonly { readonly kind: "missing-assertion" | "missing-retraction"; readonly tripleId: TripleId; readonly txId: TransactionId | null; }[]; }; ``` The embedded candidate uses its existing JSON contract: optional properties are omitted, never assigned `undefined`. Its resolved basis is the result's exact basis. `SourceFact` already carries source IDs, entity, attribute, valid interval, and optional assertion transaction/position. `facts` expands all those positive sources to their typed values and full intervals. A candidate has no asserting transaction of its own: a materialization receipt records persistence, not why the candidate holds. Its definition's release and each source write's release remain separate; they may differ. `transactions` contains journal-derived envelopes, deduplicated by ID, sorted by position then ID. It includes assertions, known retractions and requested causal ancestors, each with its own release. It deliberately omits unrelated transaction changes; the explained facts already identify the relevant assertions and retractions. `facts` sorts by entity ID, attribute, then triple ID using code-unit ordering. Source arrays retain the evaluator's ordering. Traces sort by starting transaction ID; gaps sort by triple ID then kind. No silent truncation is allowed. Resolve a release only from the journal's `configSnapshot` using `ConfigStore.snapshotById`, never from today's `live` ref. No pin means `unpinned`; an unresolvable pin, including a non-content-ID string accepted by older writers, means `missing`. Found releases expose their immutable ID, label and root commitment, not a copied configuration tree. A pin records declared governance; it does not prove enforcement was requested or successful outside Triplex's transaction boundary. Legacy/direct-adapter facts can lack journal attribution. Retain those facts, set the corresponding transaction reference to `null`, and emit a gap with the stored ID if known. Never invent an actor from `createdBy`, infer a write's release from a nearby release commit, or silently substitute another receipt. ## Recorded and valid time Resolve the basis once. Omitted `validAt` uses the Effect clock once, while omitted recorded basis captures one committed head. Reject simultaneous `recordedAt` and `recordedPosition`. Resolve a timestamp to the greatest committed position whose instant is at most that timestamp, bounded by the captured head; use zero if none exists. Equal timestamps include all commits at that instant visible at capture. A supplied position must be at most the captured head. Return the exact position so a caller can replay the answer without timestamp ambiguity. Historical timestamps in the future resolve to the captured head. Selection uses half-open intervals: assertion position at or before the cut, no retraction position at or before the cut, and `validFrom <= validAt < validTo` (an absent end is unbounded). For a fact subject, `visible` reports this predicate instead of filtering away a retracted or future-valid assertion. Entity and attribute subjects contain only visible facts. A future-only fact is explainable by ID with `visible: false`, and by a state subject at its future valid date if already recorded. The result represents knowledge at the recorded cut. If a later retraction is present in today's raw history but is after the cut, return `recorded.to: null` and `retraction: null`, and do not include that later receipt. At a cut after the retraction, report its timestamp and transaction; the assertion remains available by fact ID with `visible: false`. A supersession is a retraction and a new assertion, not an inferred special event: explaining the old ID reports its retractor; explaining current state reports the replacement. `recorded.to` does not shorten `valid.to`. If a correction reasserts a bounded interval, that new assertion owns its own ID and provenance. Fetch selected assertions with `get`/`history` and state reads at the resolved position. Fetch assertion and retraction envelopes with `transaction(txId)`, outside the selected fact's valid-time filter, and check their positions against the cut. Never join `:_tx/actor` under the historical `validAt`. As documented in [the modeling guide](../agents.md#5-answer-audit-questions), `_tx` metadata starts at commit time even when an application backdates its assertions. A write made in November for March must still explain Dana as the actor at a March valid date. For candidates, evaluate at the same exact cut. Sources are positive witnesses, not a fabricated fact for an absent training record. The definition's negated clause and the pinned evaluation explain that absence at this basis; this is not a separately persisted proof of every non-match. Existing unsupported provenance queries remain rejected. Replaying a stored stale run explains that run's basis, not freshness today. An expired source is absent from a current re-evaluation; it can still support the historical candidate. ## Causation and cost Interpret a transaction's `causationId` as the exact, case-sensitive `commandId` of its immediate cause in the same database. Resolve it with `transactionByCommand`; do not interpret it as a transaction ID, correlation ID, external lookup or cross-database address. Existing writers may store external labels: `training:correct:maria-harbor` names `audit:2026-q1`, for which this scenario has no receipt. A trace for that correction reports `missing`. Correlation groups related activity but does not establish causation. Depth zero performs no parent lookups and returns `disabled` for every starting receipt. For positive depth D, start independently from every assertion and retraction receipt in `facts`, following at most D parent edges. Candidate source assertions are starts too. Ancestors get envelopes and releases but their changed facts are not recursively expanded. Reuse receipt/release reads across traces. At each step, no causation ID means `root`. If a link remains when the budget is spent, report `depth-limit` with that command ID without reading it. A missing receipt reports `missing`. A receipt after the recorded cut reports `after-basis` without including it or its metadata in the transaction table. Maintain a visited transaction-ID set per trace, initially containing the start; a repeated ID reports `cycle` and is not appended again. Detect self-links as cycles. Terminate at the first such condition. Cycles and missing links are successful partial explanations, not retries or a claim of complete causality. No automatic forward fan-out is performed and actor-supplied causation is attribution, not proof that the cause was legitimate. Let F be selected facts, T distinct local assertion/retraction receipts, H the histories needed to reconstruct intervals, and A distinct traversed ancestors, where A is at most T × D. The default costs no causal queries. Each extra unique ancestor requires a command lookup plus its envelope and at most one uncached release lookup; definitions and releases can dominate payload size. | Backend | Expected access and cost | | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | KV | Direct fact lookup, entity history prefix scans, command-attribute AVET lookup, transaction entity-prefix reads; roughly O(H + F + T + A) records plus ordered-index seeks and reached config nodes. No whole-journal causation replay. | | SQLite | Primary fact lookup, indexed entity/history reads, indexed command attribute lookup and transaction entity reads; O(log N) seeks plus returned rows per lookup. Sequential parents can require D rounds; no recursive SQL dependency. | | PostgreSQL | Same indexed SQL shape; remote round trips make depth and distinct starts material. Memoize and use bounded concurrency for independent starts. No N-per-database journal scan. | Candidate evaluation retains the existing derivation query/provenance cost; `why` cannot make a broad definition cheap. Whole-entity history is unbounded in this first version. Cancellation remains an Effect interruption; CLI/MCP hosts can impose time and response budgets and must fail explicitly rather than return an unexplained subset. None of these estimates promises a backend-wide single SQL transaction or includes an unbounded configuration graph as constant work. ## Public reads and typed errors Use public `Triples.get`, `entity`/`match`, `history`, `transaction`, `transactionByCommand`, and where necessary paged `transactionsForEntity` to recover legacy retraction attribution. Match retraction changes by triple ID, not value. Use `ConfigStore.snapshotById` and `Derivation.evaluate`, with the public materialization APIs only for callers choosing a durable run. Never reach into `StorageAdapter`, KV keys, SQL tables, or config system entities from `Why`. Cache only immutable receipt/release identities; filter every cached result against the current request's cut. One public read is missing for an efficient, general exact-cut contract: ```ts // Proposed addition to Triples, required before wiring Why.layer. interface RecordedBasisReader { readonly resolveRecordedBasis: (basis?: { readonly recordedAt?: number; readonly recordedPosition?: number; }) => Effect.Effect<{ readonly recordedPosition: number }, ReadError>; } ``` It captures the committed head, validates the request, and implements the timestamp/position rules above without exposing adapters. KV needs an ordered journal timestamp/position seek (or a documented bounded-head scan until indexed); SQL needs a committed journal instant/position lookup with a suitable index. `transactionsForEntity` already exposes a captured head but cannot resolve an arbitrary timestamp efficiently; paging `transactions` to find it would make ordinary explanations depend on database age. This is a proposed public read, not an existing capability. P3.2 must implement it through the public contract or return to the owner if its allowed files do not cover it. No new public fact-history or command-parent read is necessary: full history and journal receipts provide intervals and positions. `get` is not a historical read; reconstruct its requested recorded view using the assertion/retraction receipts. Missing journal positions for a legacy assertion prevent exact-cut classification when timestamps tie: fail `WhyIncompleteHistory` rather than guess. Missing metadata that does not affect selection remains a reported gap. An inconsistent source ID, transaction position or immutable receipt is also `WhyIncompleteHistory`. ```ts type WhyError = | ReadError | ConfigStore.LoadError | DatalogQueryError | CanonicalJson.CanonicalEncodingError | Schema.SchemaError | Derivation.CandidateConflictError | WhyInvalidRequest | WhySubjectNotFound | WhyCandidateRevisionMismatch | WhyIncompleteHistory; ``` `WhyError` combines existing read/config/derivation errors with proposed tagged errors: `WhyInvalidRequest` (invalid basis, depth, missing candidate basis or hypothetical input), `WhySubjectNotFound`, `WhyCandidateRevisionMismatch`, and `WhyIncompleteHistory`. Preserve underlying typed failures; do not turn storage failure or corrupt config into a missing link. New errors carry subject IDs and machine-readable reason codes, never fact values or candidate bodies. There is no write-error channel and no implicit repair. Implementation must use the actual exported derivation error union, including sensitive-query and canonical-encoding errors, rather than `unknown`. ## Sensitive values `why` neither requests `ValueCodec`/`KeyProvider` nor calls `Sensitive.open`. Selected sealed values remain the stored `sealed` envelopes, including opaque key/ciphertext metadata and optional blind token under the existing read policy. They never become plaintext, a display string, or a hash of plaintext. Journal copies must receive the same treatment. Candidate results retain the evaluator's withheld sealed bindings; source expansion may expose only sealed envelopes. Do not echo a supplied definition's plaintext sensitive equality constants into the result, errors or telemetry. The result carries its ID and name, not its raw query. Hosts still authorize access to actors, ciphertext and equality tokens; this read operation creates no new decryption or authorization capability. ## Worked site-safety examples The following are exact proposed DTO outputs using concrete IDs and instants captured from running the existing [site-safety snippets](../snippets/home/site-safety/app.ts): publish `hr-2026.1`, onboard, record training, place Maria, publish `hr-2026.2`, then correct training. These are the six successful commits from `run.ts`; the retry and rejected placement add no commit. Fact/transaction IDs and wall clock times vary on another run; content IDs below were calculated by the existing public derivation/configuration code, not invented aliases. The proposed serializer is specified here; `why` itself is not yet implemented. ### Why is this placement's site harbor? Request the attribute of `placement:maria-harbor` at recorded position 6 and valid time March 3 (`1772496000000`), with default causation depth. The placement started March 2 (`1772409600000`). Dana's write pins `hr-2026.1` even though `live` has moved to `hr-2026.2`: ```ts const explanation = why.why( { kind: "attribute", entityId: EntityId.make("placement:maria-harbor"), attribute: ":placement/site", }, { basis: { recordedPosition: 6, validAt: 1772496000000 } }, ); ``` ```json { "version": 1, "subject": { "kind": "attribute", "entityId": "placement:maria-harbor", "attribute": ":placement/site" }, "basis": { "recordedPosition": 6, "validAt": 1772496000000 }, "facts": [ { "tripleId": "01M762QPRVRMSBZ3AHBY7F7E3F", "entityId": "placement:maria-harbor", "entityType": "Placement", "attribute": ":placement/site", "value": { "type": "ref", "value": "site:harbor" }, "valid": { "from": 1772409600000, "to": null }, "recorded": { "from": 1794426788633, "to": null }, "visible": true, "assertion": "_tx/01M762QPRSDFXDBQSPDJAQP2XF", "retraction": null } ], "transactions": [ { "txId": "_tx/01M762QPRSDFXDBQSPDJAQP2XF", "position": 4, "instant": 1794426788633, "actor": "user:dana", "commandId": "create:placement:maria-harbor", "correlationId": "request:7f3a", "causationId": null, "release": { "status": "found", "id": "sha256-92c859ed4c5ff7b06b57a97b6f4a9774abf6d862a63487135965b9457cde8a3c", "label": "hr-2026.1", "rootCid": "sha256-e09adbce90b60e492eb2e8b39a18242be6dd7163c97fe2b2a700fb6c4cfcbe41" } } ], "derivation": null, "causation": { "maxDepth": 0, "traces": [ { "start": "_tx/01M762QPRSDFXDBQSPDJAQP2XF", "ancestors": [], "stop": { "reason": "disabled" } } ] }, "gaps": [] } ``` ### Why is there an open training task for Maria at harbor? The snippets call this open work, but persist no Task entity. Explain the candidate from `task.site-safety-training`, defined by `openWork`, evaluated at recorded position 6 and March 3. Request its exact ID and revision below with that definition and basis. Its two positive source facts are the placement's worker and site; both come from Dana's transaction at position 4. The auditor at position 6 retracted the March 1 training belief and asserted training for [March 5, June 1). Consequently the negated training clause succeeds on March 3, as `output/open-work.json` shows. That absent fact is not a source; the correction receipt is not fabricated as a positive witness. The evaluation's next boundary is March 5, while the candidate itself has no boundary from its unbounded positive sources. A caller needing the correction can separately explain the old training triple by ID. In April this candidate is absent; on June 1 it exists again when training expires. ```ts const explanation = why.why( { kind: "candidate", definition: training, // the definition made in openWork candidateId: candidate.id, revision: candidate.revision, }, { basis: { recordedPosition: 6, validAt: 1772496000000 } }, ); ``` ```json { "version": 1, "subject": { "kind": "candidate", "candidateId": "sha256-d8a4f233ae0e9452c7f0edfa1c415fb9afa32bd6e75f73e72f712336620eb32f", "revision": "sha256-a7063abe0bb4df2f48b0f3213b54efaa2e807e19aa2d33845c949d433dacfad4" }, "basis": { "recordedPosition": 6, "validAt": 1772496000000 }, "facts": [ { "tripleId": "01M762QPRVRMSBZ3AHBY7F7E3F", "entityId": "placement:maria-harbor", "entityType": "Placement", "attribute": ":placement/site", "value": { "type": "ref", "value": "site:harbor" }, "valid": { "from": 1772409600000, "to": null }, "recorded": { "from": 1794426788633, "to": null }, "visible": true, "assertion": "_tx/01M762QPRSDFXDBQSPDJAQP2XF", "retraction": null }, { "tripleId": "01M762QPRVRMSBZ3AHBY7F7E3E", "entityId": "placement:maria-harbor", "entityType": "Placement", "attribute": ":placement/worker", "value": { "type": "ref", "value": "worker:maria" }, "valid": { "from": 1772409600000, "to": null }, "recorded": { "from": 1794426788633, "to": null }, "visible": true, "assertion": "_tx/01M762QPRSDFXDBQSPDJAQP2XF", "retraction": null } ], "transactions": [ { "txId": "_tx/01M762QPRSDFXDBQSPDJAQP2XF", "position": 4, "instant": 1794426788633, "actor": "user:dana", "commandId": "create:placement:maria-harbor", "correlationId": "request:7f3a", "causationId": null, "release": { "status": "found", "id": "sha256-92c859ed4c5ff7b06b57a97b6f4a9774abf6d862a63487135965b9457cde8a3c", "label": "hr-2026.1", "rootCid": "sha256-e09adbce90b60e492eb2e8b39a18242be6dd7163c97fe2b2a700fb6c4cfcbe41" } } ], "derivation": { "name": "task.site-safety-training", "candidate": { "id": "sha256-d8a4f233ae0e9452c7f0edfa1c415fb9afa32bd6e75f73e72f712336620eb32f", "revision": "sha256-a7063abe0bb4df2f48b0f3213b54efaa2e807e19aa2d33845c949d433dacfad4", "definitionId": "sha256-6d430a3fcdb169282b5dfe77b1d00ea297f32b614f6b5fda40d099e3ff2cb7cb", "configSnapshot": "sha256-92c859ed4c5ff7b06b57a97b6f4a9774abf6d862a63487135965b9457cde8a3c", "identity": { "?worker": "worker:maria", "?site": "site:harbor" }, "result": { "?worker": "worker:maria", "?site": "site:harbor" }, "basis": { "recordedPosition": 6, "validAt": 1772496000000 }, "sources": [ { "tripleId": "01M762QPRVRMSBZ3AHBY7F7E3E", "transactionId": "_tx/01M762QPRSDFXDBQSPDJAQP2XF", "transactionPosition": 4, "entityId": "placement:maria-harbor", "attribute": ":placement/worker", "validFrom": 1772409600000 }, { "tripleId": "01M762QPRVRMSBZ3AHBY7F7E3F", "transactionId": "_tx/01M762QPRSDFXDBQSPDJAQP2XF", "transactionPosition": 4, "entityId": "placement:maria-harbor", "attribute": ":placement/site", "validFrom": 1772409600000 } ] }, "release": { "status": "found", "id": "sha256-92c859ed4c5ff7b06b57a97b6f4a9774abf6d862a63487135965b9457cde8a3c", "label": "hr-2026.1", "rootCid": "sha256-e09adbce90b60e492eb2e8b39a18242be6dd7163c97fe2b2a700fb6c4cfcbe41" }, "evaluationNextTemporalBoundary": 1772668800000 }, "causation": { "maxDepth": 0, "traces": [ { "start": "_tx/01M762QPRSDFXDBQSPDJAQP2XF", "ancestors": [], "stop": { "reason": "disabled" } } ] }, "gaps": [] } ``` ## Verification for P3.2 Before implementation, add failing shared KV/SQLite/PostgreSQL cases for all four subjects; multi-valued and empty state; assertion/retraction receipts and release pins; a pin differing from `live`; candidate revision mismatch; positive sources and negation without fabricated witnesses; and sealed source/journal values with no opener supplied. Exercise historical recorded cuts before and after correction, future-valid assertions, valid interval endpoints, the `_tx` backdated-actor trap, equal commit timestamps, and a concurrent retraction after basis capture. Verify depth zero does no parent reads, exact depth limits, self/multi-command cycles, external missing commands, after-basis links, shared ancestor deduplication and cancellation. Legacy attribution gaps and corrupt receipts must remain distinct from empty selections and storage failures. Use the worked outputs as DTO fixtures, capturing clocks/IDs explicitly or comparing against the fixture's generated IDs. Verify JSON round trips and no Effect `Option` objects, plaintext leaks, unrelated journal changes, or later-cut retraction metadata. This RFC adds no package tests or generated snippet outputs. ## Non-goals and owner questions P3.2 does not add task lifecycle, a natural-language explanation generator, authorization, decryption, external causation resolution, downstream command fan-out, automatic recursive expansion of derived facts, proof of a human's motivation, or a persisted proof for negation. It does not extend the derivation query subset or implement CLI/MCP transport; those follow in P3.5/P3.6. Recommendations awaiting approval with this RFC: - Approve one `Why` service/subpath and the exact-cut `resolveRecordedBasis` prerequisite, including any timestamp index cost. The alternative of using a fresh timestamp on each read cannot promise consistent multi-read explanations. - Approve the proposed depth ceiling of 32 and complete whole-entity results. If WorldVM needs fact-count budgets or pagination, settle that before P3.2; truncation must be explicit in a revised result contract. - Approve candidate lookup by supplied definition plus exact ID/revision/basis. An ID-only historical registry or explicit immutable-run lookup could follow, but requires a public definition/run read contract beyond today's candidate provenance. No new registry is implied here. - Confirm whether WorldVM needs independently structured negative witnesses. This proposal preserves today's positive-source contract and identifies the evaluated definition/basis; stronger absence proofs need a separate derivation design rather than pretending a missing training fact has a transaction. These questions concern the proposed API details, not the settled default stop at transaction/release or the optional bounded causation chain. This document alone introduces no breaking API, migration, Changeset or adoption requirement.