@voltro/runtime 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -39,6 +39,56 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.5.0] — 2026-07-18
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **@voltro/runtime** — `.one()` now fails when a query matches MORE than one row, not only when it matches none. It previously probed with `LIMIT 1`, so a filter that quietly stopped being unique returned an arbitrary row while the thrown error still claimed "expected exactly one row". It now probes with `LIMIT 2` and fails with `NoRowFound({ found: 2 })`. Migration: a `.one()` whose filter is not unique and that meant "any match" becomes `.first()` / `.maybeOne()`; one that meant "the unique row" stays as-is and now fails loudly when that assumption breaks. `NoRowFound` and `OptimisticLockError` are now `Schema.TaggedError`s, so they can be declared directly in a descriptor's `error:` union and caught with `Effect.catchTag`. Previously they carried a `_tag` field that looked declarable but did not typecheck there, forcing every caller to catch and re-wrap them in a hand-written tagged error — that wrapper can be deleted. Constructing one directly now takes an object: `new NoRowFound({ table, found })`, `new OptimisticLockError({ table, expected })`. `instanceof` and `_tag` checks are unaffected.
47
+
48
+ ### Added
49
+
50
+ - **@voltro/protocol, @voltro/runtime, @voltro/cli, @voltro/mcp** — Relationship (ReBAC) authorization is now declarative. A descriptor can carry `guards: [{ action, resourceType, resource: (input) => input.id }]` alongside its scope guards; the framework resolves it before the executor (for a mutation, before the transaction opens) and fails with a typed `ScopeError` naming `<resourceType>:<action>`. `defineResourcePolicy` previously enforced nothing on its own. Making it bite required hand-building a map from rpc tag to policy rule and installing an interceptor — undocumented plumbing that nobody wired, and **fail-open by omission**: an rpc missing from the map passed unchecked, with no type error and no boot warning. A guard on the descriptor cannot be forgotten for an rpc that exists, because it is part of the rpc. `setTupleSource` registers where relations are read from — a real registry, not a per-call parameter. Both entrypoints register a default reading `_voltro_rebac_tuples`; an app whose relations already live in its own tables (a `teamMembers` row) registers its own instead of copying data into a framework table. Every unanswerable case DENIES: no tuple source, no policy for the type, an input that does not identify a resource, or a tuple source that throws. An authorization question nobody can answer is a refusal. The capability manifest now carries the declared guards, so a client gates UI on the same declaration the server enforces instead of a hand-kept copy. Only the data crosses the wire — the pure `resource` extractor stays server-side and is reported as `resourceScoped: true`, so a client knows the real answer is per-row and asks rather than assuming. Because guards are re-checked on every subscription delivery, a relationship revoked mid-session now ends the stream rather than continuing to serve it.
51
+ - **@voltro/database, @voltro/runtime, @voltro/cli** — `paginateBy(descriptor, column, cursor, limit, direction?)` generalises `paginateById` to any orderable column. `paginateById` hardcoded `id`, which is right for a sortable key and useless for what feeds actually need — "the next page by `createdAt`" — so apps fell back to hand-rolled `limit + 1` / slice / `hasMore` triples on cursors the helper could not express. `paginateById` is now literally `paginateBy(descriptor, 'id', …)`, so the two cannot drift. `direction` flips the comparison as well as the sort: a `desc` feed pages with `<`. Mismatching those is the classic keyset bug — an ascending comparison under a descending sort returns the same first page forever. The docs example previously demonstrated a related trap (ordering by `createdAt desc` and then calling `paginateById`, which silently re-orders by `id asc`); it now shows the correct form and names the trap. `ctx.load` / `ctx.loadMany` add request-scoped batching. Same-tick reads of one table coalesce into a single `WHERE id IN (...)`, so a breadth-first walk costs one query per LEVEL rather than per node — the assembly shape `relations()` + `.with()` cannot express, because each level's ids come from the level above. Misses are cached too (a repeated dangling reference is fetched once) and a failed batch rejects its waiters without poisoning the cache, so a transient store error does not become "these rows do not exist" for the rest of the request. The cache is request-scoped as a correctness requirement, not a tuning choice: anything longer-lived would serve one subject's rows to another. Workflow steps can now `yield* EffectStore`. Handlers always could; workflow executors could not, so a step reading the store had to lift `ctx.store` with `Effect.tryPromise` — the idiom the rest of the framework tells you to avoid, because it discards the typed `StoreError` channel. The asymmetry was an oversight; the layer is now provided from the workflow context's own store.
52
+ - **@voltro/cli** — `voltro capabilities [--json]` enumerates the framework's export surface by reading the `.d.ts` files in the project's own `node_modules`, so the answer to "what does this framework export" can be verified rather than recalled. The `--json` form is locale-independent and byte-stable, so it can be diffed across upgrades. Symbols that ship but appear nowhere in the project's seeded agent guide are flagged — limited to primitives and hooks, because counting every undocumented export on a real tree gave 1,088 (mostly types and internal Layers), a number too large to act on. `voltro doctor`'s hand-roll detector gained server rules: a hand-written not-found branch on `rows[0]` (→ `.one()`), several sequential `store.query` calls assembling related data (→ `relations()` + `.with()`), `ctx.store` lifted with `Effect.promise` (→ `EffectStore`), an imperative scope check at the top of an executor (→ `guards:`), a credential-shaped column with no `.encrypted()`, a notify/webhook helper at a mutation's tail (→ `defineSubscriber` / `defineReaction`), and hand-rolled cursor pagination (→ `paginateById`). Its scan roots now include the API app directories (`queries/`, `mutations/`, `database/`, …) — without that the server rules could never have fired. The always-loaded agent core now carries a "Pick the SERVER primitive" rubric alongside the client one, and a doc-coverage gate keeps the server surface from drifting out of it.
53
+ - **@voltro/runtime, @voltro/cli** — `ctx.outbox.enqueue(effect, payload, options?)` — a reliable external side effect from a mutation. The enqueue writes through `ctx.store`, which inside a mutation IS the transactional view, so the intent to deliver commits in the same transaction as the domain write or not at all. That closes the window a post-commit tap cannot: `@voltro/plugin-cdc-out` is at-least-once *from enqueue* (its own docs say so — a crash between commit and tap loses the event). Here the enqueue cannot be lost, because losing it means the domain write rolled back too. Delivery after commit remains at-least-once, so handlers must be idempotent; `idempotencyKey` makes that easy to honour. Delivery is declared per effect in a `*.outbox.ts` via `defineOutboxHandler`. The worker is nudged on commit for the fast path and polls every 5s — the poll is the contract, not the optimisation: it recovers rows whose nudge was lost to a crash, rows from another replica, and rows waiting out a backoff. Exponential backoff capped at 5 minutes, configurable `maxAttempts` (default 8), and a dead-letter that stays queryable in `_voltro_outbox` with its `lastError`. An effect with no registered handler is left PENDING rather than dead-lettered — the usual cause is a deploy where the enqueuing code shipped ahead of its handler, and discarding those would turn a rollout ordering detail into permanent loss of a side effect the app believes happened. Two handlers claiming one effect are refused at boot with both filenames. `_voltro_outbox` rides the declarative differ and is created only when the app declares at least one handler. Wired in both `voltro dev` and `voltro serve`.
54
+ - **@voltro/database, @voltro/runtime** — `ctx.store.query()` now returns the row type instead of `Readonly<Record<string, unknown>>`. `QueryDescriptor<R>` carries a phantom row type, so the shape the typed builder already knew survives `.descriptor` into the store: ```ts const rows = await ctx.store.query(database.notes.where(eq('id', id)).descriptor) rows[0].title // string — previously `rows[0]['title'] as string` ``` The type was always available; it was dropped at exactly this boundary, which is why reading a field meant casting. One downstream app accumulated 2,032 of those casts against a surface that could have typed them all along. `EffectStore.query` is typed the same way, so an Effect-form handler keeps both the row type and the `StoreError` channel. A hand-built descriptor still resolves to `Row` — this never types less than before, so it is additive: existing code keeps compiling, and the casts it contains simply become redundant. The driver-level `DataStore.query` stays untyped deliberately. It is the SPI every dialect store and transactional view implements, and those genuinely do return untyped rows off the wire; the type is re-applied one layer up, at the handler-facing `FluentStore`.
55
+
56
+ ### Fixed
57
+
58
+ - **@voltro/runtime, @voltro/cli** — Declarative `guards:` are now re-checked before EVERY subscription delivery, not only when the subscription is opened. A subscription is a long-lived authorization grant, and the scopes that justified it can be withdrawn while it is still open — a role revoked, a resource un-shared, a membership ended. Previously the gate ran once at subscribe and every later delivery re-ran the query and pushed rows out without re-asking, so a revoked subject kept receiving live updates until their socket happened to drop. A denial now ends the subscription with the typed `ScopeError` rather than silently freezing the subscriber on its last authorized value, and the re-check runs BEFORE the read, so a revoked subject's rows are never materialised. Both the descriptor and computed-query paths are covered, in both `voltro dev` and `voltro serve`. Non-authorization failures (a bad predicate, a dropped connection) still leave the subscriber on its last good snapshot as before.
59
+ - **@voltro/testing, @voltro/cli** — - **`@voltro/testing`'s context now provides `ctx.load` / `ctx.loadMany`.** The request-scoped batching helpers were added to the handler context but not to the test harness, so `makeTestContext` no longer satisfied `AppContext` — any suite building a context failed to typecheck, and a handler calling `ctx.load` could not be tested at all. The harness now builds a loader over the same underlying store (one per context, so `withSubject` / `withTenant` can't serve one subject's cached rows to another), mirroring the real builder. - **The `.one()` codemod is declared for 0.5.0, not 0.4.0.** It was authored while 0.4.0 was unreleased; since codemods are selected with `from < version <= to`, leaving it at 0.4.0 would have silently skipped it for everyone upgrading from 0.4.0 — exactly the users who need it.
60
+
61
+ ---
62
+
63
+ ## [0.4.0] — 2026-07-18
64
+
65
+ ### ⚠ BREAKING
66
+
67
+ - **@voltro/protocol, @voltro/plugin-rbac, @voltro/runtime** — **Resource-aware declarative guards + one denial tag.** `guards: [{ scope, resource }]` now actually scopes to a resource. Register a resolver — either `setResourceScopeResolver((req) => Effect<boolean>)` from `@voltro/protocol`, or `rbacPlugin({ resolveResourceRoles: (subject, resourceId) => roleSlugs })` — and the framework asks it, per request, whether the caller holds the scope **on the extracted resource** (a team, workspace, document), before the executor runs (mutations: before the transaction opens). A globally-held scope or `admin:full` still short-circuits without a resolver call; a resolver error fails **closed**. With no resolver registered, `resource` stays advisory (global check) exactly as before, so single-tenant apps are unaffected. **BREAKING — `@voltro/plugin-rbac` no longer exports `Forbidden`.** rbac's `permission()` / `assertPermission()` / `anyPermission()` now fail with protocol's `ScopeError` — the SAME typed error the declarative `guards:` enforcement already raised. One denial tag across the framework: a client that matches `_tag === 'ScopeError'` (or `errorTag(err)`) recognizes both a descriptor-guard denial and a `permission()` denial with one branch. The `@voltro/plugin-rbac/errors` subpath is removed. Migration (the `voltro update` codemod rewrites the mechanical part): - `import { Forbidden } from '@voltro/plugin-rbac/errors'` → `import { ScopeError } from '@voltro/protocol'`; `import { Forbidden } from '@voltro/plugin-rbac'` → `import { ScopeError } from '@voltro/plugin-rbac'` (re-exported from the root). - Every `Forbidden` identifier → `ScopeError` (incl. `error: Forbidden` on a descriptor). - By hand: a constructed `new Forbidden({ required, reason })` becomes `new ScopeError({ required, message })` (the field is `message` and is REQUIRED); and any `Effect.catchTag('Forbidden', …)` / `err._tag === 'Forbidden'` string becomes `'ScopeError'`. Guards are normally caught, not constructed, so most apps only need the import rewrite the codemod does. Also note: `requireScope` / declarative `guards:` enforcement is now async-capable (it awaits the resource resolver when one is registered) — no change to handler code, which never called the internal enforcement directly.
68
+ - **@voltro/client, @voltro/plugin-rbac** — **`@voltro/plugin-rbac/web` is removed — the UI permission gate lives in `@voltro/client`.** The framework shipped `useCan` TWICE, with the same name and the same semantics, over two different React contexts. An app that mounted `<PermissionProvider>` and imported the plugin's `useCan` got an empty scope set and silently hid every gated affordance — no error, just a UI where nothing is permitted. One of the two had to go. `@voltro/client` survives because scopes are a FRAMEWORK concept: `@voltro/protocol` owns `ScopeError`, `guards: [{ scope }]` and `ctx.access`, and rbac is only one way to PRODUCE scopes — an app can register its own `setResourceScopeResolver` over its own tables and never install the plugin. The hook consumes scopes, which is generic; producing them is rbac-specific. Keeping the hook behind the plugin would force an rbac dependency on apps that deliberately don't use rbac, purely to gate a button. `@voltro/plugin-rbac` keeps everything that IS rbac: the roles map, role→scope compilation, the `permission()` / `assertPermission()` / `anyPermission()` server guards, the role tables, and `resolveResourceRoles`. The codemod rewrites every mechanical site: `RbacScopeProvider` → `PermissionProvider`, `canFromScopes` → `canCall`, `canAnyFromScopes` → `canCallAny`, and repoints `useCan` / `useCanAny` / `ADMIN_SCOPE` to `@voltro/client`. A raw `useContext(RbacScopeContext)` is ANNOTATED rather than rewritten — the client equivalent is `usePermissions()`, which returns `{ scopes }` instead of the bare array, so a silent rewrite would compile and then hand back the wrong shape.
69
+
70
+ ### Added
71
+
72
+ - **@voltro/runtime, @voltro/cli** — **`read({ where })` on aggregates.** Aggregate reads were all-or-nothing, so every consumer that wanted one slice pulled the whole materialised set over the wire and filtered in the component — the cost scaling with the aggregate, not the slice. `where` entries are ANDed; a scalar means strict equality, an array means IN; filtering runs before `orderBy`/`limit`. Deliberately a data filter over already materialised rows, not a predicate language — aggregates stay aggregates.
73
+ - **@voltro/client** — **Two ergonomics that remove the two most-repeated shapes in a Voltro frontend.** - **`mutate(input, options)` — `onSuccess` / `onError` / `notify`.** Every write site used to re-type `try { await mutate() } catch { toast.error(…) } finally { setSubmitting(false) }`. `pending` already replaced the `finally`; these replace the rest. Load-bearing semantic: supplying an error handler (`onError` or `notify.error`) marks the failure HANDLED — `mutate` resolves with `undefined` instead of rejecting, which is what actually deletes the try/catch. With no handler it rejects exactly as before, so unhandled failures stay loud. `notify` routes to an app-registered sink (`setMutationNotifier`) — the framework stays unbound to any toast library. - **`useSubscription` now returns `loading` and `isEmpty`, and accepts `fallback`.** Call sites branched on `data === undefined`, conflating "no snapshot yet" with "zero rows" — the cause of a flash of empty-state before the first snapshot. They are now distinct, derived once. `fallback` fills `data` while loading without lying about `loading`. Also documented: `error` carries a COLD-START stream failure (check it to avoid an infinite skeleton); a failure after data arrived deliberately does not blank good data — those reach the error bus (`useOnRpcError`).
74
+ - **@voltro/cli** — **Make the client hook surface discoverable — and keep it that way.** A downstream app built its whole frontend on three transport hooks (`useSubscription` / `useMutation` / `useAction`) and hand-rolled forms, tables, upload, permission gates, debounce and pagination, because the agent guide documented ~none of the ~40 client hooks that already ship. Three changes: - **The always-loaded core now carries a "Pick the CLIENT primitive" rubric** — the same decision-rubric treatment the backend primitives get, plus a "you're about to write X → reach for Y" table (per-field `useState` → `useFormBinding`, hand-rolled table → `useDataTable`, `FileReader` → `useUpload`, `useMemo` fan-in → `useDerived`, …). - **The hook reference is complete.** `reference/hooks-overview.md` presented itself as "the client-side hook surface" while documenting 13 of 44 hooks; it now enumerates the schema-driven-UI, files/permissions/utilities, and AI families (en + de). - **A coverage GATE** (`agentsMdTemplate.test.ts`): every exported `use*` hook of `@voltro/client` must appear in the composed agent-docs corpus, or the test fails with the list. Docs can no longer silently fall behind the export surface. Plus **`voltro doctor` now flags hand-rolls**: it scans an app's source for the patterns a shipped primitive covers (hand-rolled form/table, `FileReader` upload, `setTimeout` debounce, `useMemo` fan-in, local Next.js compat shims, hand-rolled presence) and names the primitive to use. Advisory, never fatal — a false-positive lecture must not fail a build.
75
+ - **@voltro/cli** — **Generated `matchError` / `AppError` / `AppErrorTag`.** Codegen now emits a per-app exhaustive error matcher into `rpcGroup.generated.ts`, derived BY REFERENCE from every descriptor's `error:` schema plus the cross-cutting plugin errors. `matchError(err, { [tag]: handler }, fallback?)` dispatches on `_tag` with the handler keys constrained to the app's ACTUAL error tags — so a hand-maintained tag list that silently drifts (dead/renamed tags) is gone; a stale tag is a compile error. Browser-safe (reads `_tag` structurally, no runtime dep). Workflows keep their existing `WorkflowErrors`.
76
+ - **@voltro/cli** — **`voltro doctor` + `voltro update --codemods-only`/`--from`** — two upgrade-path ergonomics from AWB adoption feedback: - **`voltro doctor [app]`** (and **`voltro serve --preflight`**) preflights an API app for production serve: it verifies the precompiled serve bundle exists and, if not, prints the exact fix and exits 1 — so a Dockerfile / CI step catches "prod `voltro serve` with no `voltro build`" at BUILD time instead of at cold-start (0.3.0 made an unbuilt production serve fatal). - **`voltro update --codemods-only` (alias `--run-codemods`) + `--from <version>`** — re-apply the codemods + manual notes for an explicit `[from, to]` delta WITHOUT bumping `package.json` or installing. The recovery path for a hand-edited version bump (`bump package.json` + install first), which otherwise makes `voltro update` report "already on X — nothing to do" and silently skip the codemods. `--from` also overrides the auto-detected source version on a normal update.
77
+ - **@voltro/runtime** — **`encryptField` / `decryptField` — standalone field cipher for raw-SQL paths.** `.encrypted()` columns encrypt/decrypt transparently inside the `ctx.store` middleware, so a code path that reaches the DB by RAW SQL (an auth strategy with no store handle reading/writing a session token, a one-off backfill) bypasses it. `@voltro/runtime` now exposes the SAME registered cipher standalone: `encryptField` encrypts a value; `decryptField` decrypts an `enc:v1:…` value and passes a non-ciphertext value through unchanged (so a raw-SQL path can adopt encryption while pre-existing plaintext rows keep working). Both throw a clear error when no cipher is registered. Encryption stops being all-or-nothing tied to going through `ctx.store`.
78
+ - **@voltro/testing** — **`makeVoltroTestClient` — the missing half of the test story.** The framework shipped a strong BACKEND test story (`makeTestContext`, `mockStore`, `mockAi`, the workflow runner) and nothing for the client. A Voltro frontend is a reactive-data app: with no way to render a component against mocked `useSubscription` / `useMutation` — and to inject a loading state, a stream error, or a failing write — it is structurally untestable, which is why real apps end up with zero frontend tests. `import { makeVoltroTestClient } from '@voltro/testing/client'` returns a `Provider` plus `setSubscription` / `failSubscription` / `resetSubscription` and a `calls` log of every write with its input. A tag ABSENT from the fixture stays in the loading state — the distinction from an empty result is exactly what you want to assert. Deliberately NOT a renderer: it hands you a Provider, so it works with `react-dom/client` + `act`, testing-library, or your own harness, and locks you into none of them.
79
+ - **@voltro/i18n** — **Plurals, Intl formatters, and catalogs a bundler can actually split.** - **`plural(locale, count, forms)` + `usePlural`** — real CLDR categories via `Intl.PluralRules`. Replaces the hardcoded `"{count} epic(s)"` pattern, whose literal `(s)` is simply wrong outside English, and which cannot express languages that distinguish few from many (Polish 2–4 vs 5+). An explicit `zero` form is honoured for exactly 0; ordinals via `{ type: 'ordinal' }`. - **`useFormatDate` / `useRelativeTime` / `useFormatNumber` / `useFormatCurrency` / `useFormatters`** — locale-bound `Intl` wrappers, so an app stops carrying several divergent hand-rolled "X minutes ago" helpers that each round differently. - **`defineCatalogs` + `LazyI18nProvider`** — code-split catalogs. `pickCatalog({ en, de })` is a STATIC import map: every locale is a value-level import, so the bundler must emit them in one chunk and a German-only visitor downloads English too, growing linearly per locale. A map of `() => import()` loaders is the only shape a bundler treats as a chunk boundary. `preload(locale)` before hydration keeps first paint synchronous via `peek()`; the provider's `fallback` is for a locale SWITCH, not first paint. Unknown locales degrade to the base catalog, and a failed chunk load stays retryable.
80
+ - **@voltro/protocol, @voltro/client** — **Nested optimistic: `shapeItem` + bulk (multi-item) targets.** Two additive improvements to path-targeted auto-optimistic: - **`shapeItem`** — the nested counterpart of `shape`, typed to the ITEM of the nested array (not the mutation's output), so a path-target patch reads/returns the item without casting `current`. (Previously the nested shaper was the flat `shape`, pinned to the output row — every adopter had to cast. `shape` stays bound to the output for flat targets; a single field can't be both, so the nested shaper is its own.) - **Bulk `identify`** — a target's `identify` may now return `string[]` to patch/delete MANY items (or top-level rows) in one mutation — the group-drag / batch-edit case where per-item parallel writes used to race. Each patched item keeps its own key. Runtime-compatible: a nested target that still uses `shape` falls back transparently. No codemod — additive.
81
+ - **@voltro/web, @voltro/client** — **Two hooks apps kept hand-rolling, because nothing shipped them.** - **`useTheme` (`@voltro/web`)** — returns `{ theme, resolvedTheme, setTheme }` over the framework's own `voltro:theme` cookie and the `html.dark` class. The bug it removes is specific: an app that hand-rolls a second theme store ends up with two writers on one class, so the toggle's state and the rendered theme can disagree, and the pre-paint script flashes the wrong one. `'system'` resolves through `matchMedia` and tracks live OS changes. - **`useConnectionStatus` (`@voltro/client`)** — `'connected' | 'degraded' | 'offline'`, derived from the only two signals the client honestly has: `navigator.onLine` (browser says the network is gone) and the rpc error bus (a call actually failed). `degraded` means "we saw a failure and no success since"; coming back online clears it, because the failures counted during an offline window ARE that window. Deliberately no polling ping just to colour an indicator.
82
+ - **@voltro/database** — **`.uniqueActive([cols])` now works on mysql / mariadb** (it previously failed loud at migrate — those engines have no partial index). It lowers automatically to a STORED generated column per key column — `CASE WHEN <predicate> THEN CAST(<col> AS CHAR(255)) ELSE NULL END` — plus a UNIQUE over them. NULL-distinct uniqueness means a soft-deleted row's generated columns are all NULL and never collide, so re-creating the key just works — the same resurrection-safe semantics the partial index gives on postgres/sqlite/mssql. The lowering is applied identically on the emit side and the declared-snapshot side, so it round-trips through the declarative differ (verified live on MariaDB: declare→migrate→introspect→re-plan is a no-diff, and a duplicate among active rows is rejected while soft-deleting one frees the key). You write the same `.uniqueActive([...])` on every dialect.
83
+
84
+ ### Fixed
85
+
86
+ - **@voltro/cli** — **Standalone `voltro codegen` now emits the SAME `rpcGroup.generated.ts` as `voltro dev` / `voltro build`.** Previously the standalone command skipped the app's plugins, so it dropped every plugin's cross-cutting error union AND its client RPC routes — an inconsistent generated file (typed plugin errors gone, plugin route tags unresolved) vs the dev/serve boot path. The command now loads `app.config` and gathers the plugins' codegen inputs through the SAME `gatherCodegenPluginImports` the boot path uses (single-sourced, so the three paths can't drift).
87
+ - **@voltro/cli** — **The hook-coverage gate was too weak, and it let dead documentation through.** It asked only whether a hook's NAME appeared in the agent-docs corpus — which a one-line row in a link table satisfies. Ten client hooks had exactly that, and six of those rows linked to a page that never mentions the hook, so a reader following the link learned nothing. Two sharper checks replace it: - **Substance** (`agentsMdTemplate.test.ts`): a hook must appear at least once OUTSIDE a table row, so a bare row no longer counts as documentation. - **Dead references** (`gen-agent-docs.mjs`): a row promising `[`useX`](/docs/…)` must point at a page that actually mentions `useX`. This lives in the generator because that is the only thing which reads the docs tree — and it walks BOTH language trees, since an en-only check leaves every German dead link invisible.
88
+ - **@voltro/cli** — - **Serve bundle: inline the pure-JS SQL drivers (`pg`/`mysql2`/`tedious`) instead of runtime-shimming them.** A production `voltro serve` with a postgres/mysql/mssql store crashed at boot with `Pg.Pool is not a constructor` (and the mysql/mssql equivalents): the driver's `@effect/sql-*` consumer links its leaf as a namespace (`import * as Pg from 'pg'; new Pg.Pool()`), but the native-leaf CJS shim (`module.exports = <leaf>`) did not surface the named members through esbuild's `import * as` interop, so `Pg.Pool` was `undefined`. These drivers are pure JS (no `.node`), so they are now inlined into the serve bundle like the rest of the framework — esbuild links the real module and the namespace resolves correctly. Only genuinely-native leaves (`better-sqlite3`, the turso/libsql addons, `pg-native`) and the dynamically-imported `ioredis`/`nodemailer` still resolve via the runtime shim. Verified end-to-end from a relocated prod tree (a real `POST /rpc` through the inlined `pg`).
89
+
90
+ ---
91
+
42
92
  ## [0.3.0] — 2026-07-18
43
93
 
44
94
  ### ⚠ BREAKING
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { AnyCheckSpec } from '@voltro/protocol';
1
2
  import { AuthStrategy } from '@voltro/protocol';
2
3
  import { CaughtUpVerdict } from '@voltro/database';
3
4
  import { ChangeEvent } from '@voltro/database';
@@ -12,7 +13,6 @@ import { DialectReplicationAdapter } from '@voltro/database';
12
13
  import { Duration } from 'effect';
13
14
  import { Effect } from 'effect';
14
15
  import { FieldCipher } from '@voltro/database';
15
- import { GuardCheckSpec } from '@voltro/protocol';
16
16
  import * as http from 'node:http';
17
17
  import { HttpClient } from '@effect/platform';
18
18
  import { HttpRequestInterceptor } from '@voltro/protocol';
@@ -341,6 +341,23 @@ export declare interface AggregateQuery {
341
341
  }
342
342
 
343
343
  export declare interface AggregateReadOptions {
344
+ /**
345
+ * Filter the materialized rows — the PARAMETERIZED read.
346
+ *
347
+ * Without it an aggregate can only ever be "the one global roll-up", so every
348
+ * tenant-/team-/year-scoped roll-up (most real ones) has to stay client-side.
349
+ * A row matches when EVERY entry matches: a scalar compares by strict
350
+ * equality, an array means "one of" (an IN set).
351
+ *
352
+ * ```ts
353
+ * yield* handle.read({ where: { teamId, year: 2026 } })
354
+ * yield* handle.read({ where: { status: ['open', 'blocked'] } })
355
+ * ```
356
+ *
357
+ * Deliberately DATA, not a predicate function: it stays serializable, so the
358
+ * filter can later push down to SQL instead of filtering in memory.
359
+ */
360
+ readonly where?: Readonly<Record<string, unknown>>;
344
361
  /** Reject if last refresh is older than this. Throws `AggregateStale`. */
345
362
  readonly maxAgeMs?: number;
346
363
  /** Pagination — max rows to return. */
@@ -741,6 +758,28 @@ export declare interface AppContext {
741
758
  * triggers. `emit(name, payload)` records the event and fans out to
742
759
  * matching workflow triggers. */
743
760
  readonly events?: EventsAppContext;
761
+ /**
762
+ * Transactional outbox (`ctx.outbox`). Absent when the app declares no
763
+ * `*.outbox.ts` handler — an enqueue with nobody to deliver it would be a
764
+ * side effect the app believes happened and which never will, so the field
765
+ * simply isn't there rather than silently accepting writes.
766
+ */
767
+ readonly outbox?: OutboxFacade;
768
+ /**
769
+ * Request-scoped batching (`ctx.load` / `ctx.loadMany`). Coalesces
770
+ * same-tick reads of one table into a single `WHERE id IN (...)`.
771
+ *
772
+ * For assembly whose SHAPE depends on the data — a tree walk where each
773
+ * level's ids come from the level above — which `relations()` + `.with()`
774
+ * cannot express statically. Reach for the relation first; this is the
775
+ * fallback, not the default.
776
+ *
777
+ * Scoped to the request on purpose: a longer-lived cache would serve one
778
+ * subject's rows to another (a data-isolation bug on a tenant-scoped store,
779
+ * not a performance detail) and would go stale across a mutation.
780
+ */
781
+ readonly load: DataLoader['load'];
782
+ readonly loadMany: DataLoader['loadMany'];
744
783
  }
745
784
 
746
785
  /**
@@ -894,6 +933,9 @@ export declare interface AttachAnalyticsMirrorOptions {
894
933
  */
895
934
  export declare const awaitServerListening: (server: ReturnType<typeof createServer>, timeoutMs?: number) => Promise<void>;
896
935
 
936
+ /** Exponential backoff with a ceiling — 1s, 2s, 4s … capped at 5 minutes. */
937
+ export declare const backoffMs: (attempt: number) => number;
938
+
897
939
  /**
898
940
  * Override the subject for an active connection. Called from the
899
941
  * `auth.signin` (or any "I just authenticated this caller") handler
@@ -956,9 +998,17 @@ cache?: {
956
998
  readonly swrMs: number | undefined;
957
999
  readonly scope: "subject" | "global";
958
1000
  readonly baseKey: string;
959
- }) => Stream.Stream<SubscriptionEvent<T>, never, SubjectService | ConnectionInfo>;
1001
+ },
1002
+ /**
1003
+ * Per-subject authorization re-check, built by
1004
+ * `makeQueryReauthorizer(query, input)` in the serve pipeline. Subscribing
1005
+ * passes `guards:` once; this re-runs them before every delivery so a
1006
+ * revoked grant CLOSES the stream rather than continuing to serve it.
1007
+ * Omitted for a query that declares no guards.
1008
+ */
1009
+ reauthorize?: (subject: Subject) => () => Promise<unknown>) => Stream.Stream<SubscriptionEvent<T>, never, SubjectService | ConnectionInfo>;
960
1010
 
961
- export declare const bindSubscriptionUntyped: (descriptor: QueryDescriptor, dispatcher: Dispatcher, context: RuntimeContext, indexHint: MatcherIndexHint | undefined, label: string, cacheBinding: SnapshotCacheBinding | undefined) => Stream.Stream<SubscriptionEvent<ReadonlyArray<Row_4>>, never, never>;
1011
+ export declare const bindSubscriptionUntyped: (descriptor: QueryDescriptor, dispatcher: Dispatcher, context: RuntimeContext, indexHint: MatcherIndexHint | undefined, label: string, cacheBinding: SnapshotCacheBinding | undefined, reauthorize: (() => Promise<unknown>) | undefined) => Stream.Stream<SubscriptionEvent<ReadonlyArray<Row_4>>, never, never>;
962
1012
 
963
1013
  export declare interface BrandedScheduleDefinition extends ScheduleDefinition {
964
1014
  readonly [SCHEDULE_BRAND]: true;
@@ -1278,6 +1328,21 @@ export declare const currentRoutingContext: () => RoutingContext | undefined;
1278
1328
  /** The trace context active on the current async stack, if any. */
1279
1329
  export declare const currentTraceContext: () => LogTraceContext | undefined;
1280
1330
 
1331
+ export declare interface DataLoader {
1332
+ /**
1333
+ * Load one row by primary key. Calls made in the same tick for the same
1334
+ * table are coalesced into ONE `WHERE id IN (...)`.
1335
+ *
1336
+ * Returns `null` for a missing row rather than throwing — a loader is used
1337
+ * to assemble, and a missing edge in a graph walk is usually data, not an
1338
+ * error. Use `.one()` when absence IS an error.
1339
+ */
1340
+ load(table: string, id: string): Promise<Row_4 | null>;
1341
+ /** Load many by key, in the order asked. Missing rows come back as `null`,
1342
+ * so the result lines up positionally with the input. */
1343
+ loadMany(table: string, ids: ReadonlyArray<string>): Promise<ReadonlyArray<Row_4 | null>>;
1344
+ }
1345
+
1281
1346
  export { DataStore }
1282
1347
 
1283
1348
  /** DataStore-backed store over `_voltro_api_keys`. */
@@ -1288,6 +1353,13 @@ export declare const dataStoreIdempotencyStore: (store: DataStore) => Idempotenc
1288
1353
  /** Build a durable `KvStoreShape` over a raw `DataStore`. */
1289
1354
  export declare const dataStoreKvStore: (store: DataStore) => KvStoreShape;
1290
1355
 
1356
+ /** Decrypt a value produced by `encryptField` / an `.encrypted()` column. A
1357
+ * value that is NOT ciphertext (`enc:v1:…`) is returned unchanged — so a
1358
+ * raw-SQL read path can be switched to encryption while pre-existing plaintext
1359
+ * rows keep working until they're re-written encrypted. Throws only when no
1360
+ * cipher is registered (a genuine ciphertext with a wrong key throws GCM). */
1361
+ export declare const decryptField: (value: string) => string;
1362
+
1291
1363
  /** Default `POST /rpc` body cap: 8 MiB. Generous for any JSON rpc envelope,
1292
1364
  * small enough to stop a pathological body being buffered into memory. */
1293
1365
  export declare const DEFAULT_MAX_RPC_BODY_BYTES: number;
@@ -1322,6 +1394,16 @@ export declare const defineAggregate: <Row>(input: AggregateDefinitionInput<Row>
1322
1394
 
1323
1395
  export declare const defineEventTrigger: <EventPayload = unknown, WorkflowPayload = EventPayload>(spec: Omit<WorkflowEventTriggerDefinition<EventPayload, WorkflowPayload>, "_tag">) => WorkflowEventTriggerDefinition<EventPayload, WorkflowPayload>;
1324
1396
 
1397
+ /**
1398
+ * Declare who delivers an effect. One per `*.outbox.ts` file.
1399
+ *
1400
+ * The handler runs AFTER the enqueuing transaction committed, outside it, and
1401
+ * may do external I/O — that is the entire point. It must be IDEMPOTENT:
1402
+ * delivery is at-least-once, so a process that dies between "the remote
1403
+ * accepted it" and "we marked it delivered" will retry.
1404
+ */
1405
+ export declare const defineOutboxHandler: (definition: OutboxHandlerDefinition) => OutboxHandlerDefinition;
1406
+
1325
1407
  /** Declare a reaction. Validates that the MANDATORY guard is present — an
1326
1408
  * ungated reaction is a spend-storm footgun, so this fails LOUD at boot. */
1327
1409
  export declare const defineReaction: (def: ReactionDefinition) => ReactionDefinition;
@@ -1427,7 +1509,26 @@ export declare class Dispatcher {
1427
1509
  /** Snapshot-cache binding (Layer 3). When present AND `deps.cache` is
1428
1510
  * wired, the initial snapshot is served through the cache, tagged with
1429
1511
  * the dependent-table set, and kept warm by the recompute path. */
1430
- cacheBinding?: SnapshotCacheBinding): Promise<() => void>;
1512
+ cacheBinding?: SnapshotCacheBinding,
1513
+ /** Re-run the query's `guards:` before every delivery — see
1514
+ * `ActiveSubscription.reauthorize`. Omitted for unguarded queries. */
1515
+ reauthorize?: () => Promise<unknown>): Promise<() => void>;
1516
+ /**
1517
+ * Tear a subscription down because authorization was WITHDRAWN mid-stream,
1518
+ * and tell the client why.
1519
+ *
1520
+ * A denial is not a transient failure, so it must not be handled like one.
1521
+ * The re-query paths deliberately keep a failing subscriber on its last
1522
+ * good snapshot (a bad predicate shouldn't wedge the stream) — but doing
1523
+ * that for a revoked subject would leave authorized data sitting in a
1524
+ * client that is no longer entitled to it, with no signal that anything
1525
+ * changed. Emitting the typed `ScopeError` and closing is the honest
1526
+ * outcome: the client surfaces the denial and can re-subscribe if the
1527
+ * grant comes back.
1528
+ */
1529
+ private revokeSubscription;
1530
+ /** `revokeSubscription` for the computed path. Same reasoning. */
1531
+ private revokeComputed;
1431
1532
  /**
1432
1533
  * Register a COMPUTED-query subscription. The handler already ran once
1433
1534
  * (its value is `computed.value`); we emit that as the initial snapshot,
@@ -1472,6 +1573,31 @@ export declare interface DispatcherDependencies {
1472
1573
  readonly cache?: SnapshotCache;
1473
1574
  }
1474
1575
 
1576
+ export declare interface DrainDeps {
1577
+ readonly store: Pick<DataStore, 'query' | 'update'>;
1578
+ readonly handlers: ReadonlyMap<string, OutboxHandlerDefinition>;
1579
+ readonly now?: () => Date;
1580
+ /** Max rows per drain pass. */
1581
+ readonly batchSize?: number;
1582
+ }
1583
+
1584
+ /**
1585
+ * One drain pass: claim due rows, run their handler, record the outcome.
1586
+ *
1587
+ * A row whose handler is unknown is left PENDING rather than dead-lettered —
1588
+ * the usual cause is a deploy where the enqueuing code shipped before the
1589
+ * handler, and discarding those would turn a rollout ordering detail into
1590
+ * permanent data loss.
1591
+ */
1592
+ export declare const drainOutbox: (deps: DrainDeps) => Promise<DrainResult>;
1593
+
1594
+ export declare interface DrainResult {
1595
+ readonly delivered: number;
1596
+ readonly failed: number;
1597
+ readonly dead: number;
1598
+ readonly skipped: number;
1599
+ }
1600
+
1475
1601
  /**
1476
1602
  * Up to `limit` wakeups that are due now (`wakeAt <= now`), earliest
1477
1603
  * first. Pending rows are read ordered by `wakeAt`, so the earliest —
@@ -1490,7 +1616,11 @@ export declare class EffectStore extends EffectStore_base {
1490
1616
  declare const EffectStore_base: Context.TagClass<EffectStore, "@voltro/EffectStore", EffectStoreOps>;
1491
1617
 
1492
1618
  export declare interface EffectStoreOps {
1493
- readonly query: (descriptor: QueryDescriptor) => Effect.Effect<ReadonlyArray<Row_4>, StoreError>;
1619
+ /** Typed the same way `FluentStore.query` is — the row type rides in on the
1620
+ * descriptor, so an Effect-form handler reads real fields instead of
1621
+ * casting off `Record<string, unknown>`. Falls back to `Row` for a
1622
+ * hand-built descriptor. */
1623
+ readonly query: <R = Row_4>(descriptor: QueryDescriptor<R>) => Effect.Effect<ReadonlyArray<R>, StoreError>;
1494
1624
  readonly insert: (table: string, row: Row_4) => Effect.Effect<Row_4, StoreError>;
1495
1625
  readonly update: (table: string, primaryKey: string, patch: Readonly<Record<string, unknown>>) => Effect.Effect<Row_4 | null, StoreError>;
1496
1626
  readonly delete: (table: string, primaryKey: string) => Effect.Effect<boolean, StoreError>;
@@ -1502,6 +1632,19 @@ export declare interface EffectStoreOps {
1502
1632
  * discovered tables. */
1503
1633
  export declare const emptySchemaRegistry: SchemaRegistry;
1504
1634
 
1635
+ /** Encrypt a value with the registered field cipher (same one `.encrypted()`
1636
+ * columns use). Throws if no cipher is registered. */
1637
+ export declare const encryptField: (plaintext: string) => string;
1638
+
1639
+ export declare interface EnqueueOptions {
1640
+ /** Drop this enqueue if an undelivered row already carries the same key. */
1641
+ readonly idempotencyKey?: string;
1642
+ /** Override the handler's `maxAttempts` for this one effect. */
1643
+ readonly maxAttempts?: number;
1644
+ /** Delay the first attempt (ms from now). */
1645
+ readonly delayMs?: number;
1646
+ }
1647
+
1505
1648
  /** The always-available default — reads `process.env`. Sync under the hood,
1506
1649
  * Promise-wrapped to satisfy the async contract. */
1507
1650
  export declare const envSecretsBackend: SecretsBackend;
@@ -1547,7 +1690,25 @@ export declare interface FieldChange {
1547
1690
  */
1548
1691
  export declare const fingerprintFor: (table: string, predicate: Predicate, indexHint?: string) => string;
1549
1692
 
1550
- export declare interface FluentStore extends Omit<MutationStore, 'update' | 'delete'> {
1693
+ export declare interface FluentStore extends Omit<MutationStore, 'update' | 'delete' | 'query'> {
1694
+ /**
1695
+ * Execute a query descriptor and return the matching rows — TYPED.
1696
+ *
1697
+ * The row type rides in on the descriptor (`QueryDescriptor<R>`), so
1698
+ * `ctx.store.query(database.notes.where(...).descriptor)` gives you
1699
+ * `ReadonlyArray<Note>`, not `ReadonlyArray<Record<string, unknown>>`. The
1700
+ * builder always knew the shape; it used to be dropped exactly here, which
1701
+ * is why reading a field meant writing `row['title'] as string` — one
1702
+ * downstream app accumulated 2,032 of those casts.
1703
+ *
1704
+ * A hand-built descriptor still resolves to `Row`, i.e. the previous
1705
+ * behaviour. This never types LESS than before.
1706
+ *
1707
+ * The driver-level `DataStore.query` stays untyped on purpose — it is the
1708
+ * SPI every dialect store implements, and it genuinely does return untyped
1709
+ * rows off the wire. The type is re-applied here, at the handler boundary.
1710
+ */
1711
+ query<R = Row_5>(descriptor: QueryDescriptor<R>): Promise<ReadonlyArray<R>>;
1551
1712
  /** Fluent, scope-applying read builder: `select('notes').where(...).all()`. */
1552
1713
  select(table: string): SelectBuilder;
1553
1714
  /** Fluent predicate update: `update('notes').where('id', id).set({...})`. */
@@ -1627,6 +1788,8 @@ export declare const getResourcePolicy: (resourceType: string) => ResourcePolicy
1627
1788
  * tap feeds it; the inspect endpoint reads it. */
1628
1789
  export declare const getTimelineRecorder: () => TimelineRecorder;
1629
1790
 
1791
+ export declare const getTupleSource: () => TupleSource | undefined;
1792
+
1630
1793
  /** Stable group key from the groupBy column values (JSON to disambiguate types). */
1631
1794
  export declare const groupKeyOf: (row: Row_2, groupBy: ReadonlyArray<string> | undefined) => string;
1632
1795
 
@@ -1830,6 +1993,25 @@ export declare interface InspectStream {
1830
1993
 
1831
1994
  export { inspectWorkflow }
1832
1995
 
1996
+ /**
1997
+ * Install the resolver that answers `guards: [{ action, resourceType }]`.
1998
+ *
1999
+ * Called once at boot by both entrypoints. Every denial path is explicit
2000
+ * because each one is a place where a plausible implementation would instead
2001
+ * pass:
2002
+ *
2003
+ * - unknown resource type → DENY. A guard naming a policy that was never
2004
+ * registered is a misconfiguration, and a misconfigured authorization
2005
+ * check must not be a permissive one.
2006
+ * - no tuple source → DENY. Nothing can answer the question.
2007
+ * - tuple source throws → DENY, and log it. A database blip must not become
2008
+ * an open door.
2009
+ *
2010
+ * `can()` itself already handles the `admin:full` bypass, anonymous denial and
2011
+ * cross-tenant denial, so this layer does not re-implement them.
2012
+ */
2013
+ export declare const installPolicyGuardResolver: () => void;
2014
+
1833
2015
  /** Fire every registered interrupt for `clientId` (called on disconnect). */
1834
2016
  export declare const interruptConnectionStreams: (clientId: number) => void;
1835
2017
 
@@ -1965,6 +2147,13 @@ export declare const listResourcePolicies: () => ReadonlyArray<ResourcePolicy>;
1965
2147
 
1966
2148
  export { listRetentions }
1967
2149
 
2150
+ export declare interface LoaderDeps {
2151
+ readonly store: Pick<DataStore, 'query'>;
2152
+ /** Coalesce window. Loads issued within the same microtask batch together;
2153
+ * the default (a resolved promise tick) needs no timers. */
2154
+ readonly schedule?: (flush: () => void) => void;
2155
+ }
2156
+
1968
2157
  /** Load the tuples for one (subject, resource) — the interceptor's tuple source.
1969
2158
  * Returns [] for an anonymous subject (→ `can` denies anyway). */
1970
2159
  export declare const loadResourceTuples: (store: DataStore, subjectId: string | null, resourceType: string, resourceId: string) => Promise<ReadonlyArray<RelationTuple>>;
@@ -2095,6 +2284,12 @@ export declare const makeBufferingSpanProcessor: (onSpanEnd: (record: TraceSpanR
2095
2284
  */
2096
2285
  export declare const makeCoordinatedScheduler: (deps: CoordinatedScheduleDeps) => ((name: string, intervalMs: number, effect: () => void | Promise<void>) => CoordinatedScheduleHandle);
2097
2286
 
2287
+ /**
2288
+ * Build a request-scoped loader. One instance per AppContext — see the module
2289
+ * header for why this must not be shared across requests.
2290
+ */
2291
+ export declare const makeDataLoader: (deps: LoaderDeps) => DataLoader;
2292
+
2098
2293
  /**
2099
2294
  * Build the Layer that provides `EffectStore` from a `MutationStore`.
2100
2295
  * dev.ts (CLI) and any standalone test setup uses this to wire up the
@@ -2140,6 +2335,8 @@ export declare const makeLazyWorkflowFacade: (resolve: () => WorkflowsAppContext
2140
2335
  */
2141
2336
  export declare const makeMutationRunner: (deps: MutationRunnerDeps) => (mutation: MutationLike, input: unknown, requestContext: ServeRequestContext) => Promise<unknown>;
2142
2337
 
2338
+ export declare const makeOutboxFacade: (deps: OutboxFacadeDeps) => OutboxFacade;
2339
+
2143
2340
  export declare const makePostCommitWorkflowFacade: (base: WorkflowsAppContext, afterCommit: (work: () => Promise<unknown>) => void) => WorkflowsAppContext;
2144
2341
 
2145
2342
  /**
@@ -2160,6 +2357,27 @@ export declare const makeProcessAdapter: (supervisor: AppSupervisor) => WakeAdap
2160
2357
  */
2161
2358
  export declare const makeQueryDescriptorProducer: <D>(deps: QueryProducerDeps<D>) => (query: MutationLike, input: unknown) => (requestContext: ServeRequestContext) => D | ComputedQuery | Effect.Effect<D | ComputedQuery, unknown, never>;
2162
2359
 
2360
+ /**
2361
+ * Build the per-subscription authorization re-check.
2362
+ *
2363
+ * `guards:` are enforced once when a subscription is opened. That is not
2364
+ * enough on its own: a subscription is a LONG-LIVED grant, and the scopes
2365
+ * that justified it can be withdrawn while it is still open (a role
2366
+ * revoked, a resource un-shared, a membership ended). Without a re-check
2367
+ * the socket keeps delivering rows the subject may no longer read until
2368
+ * the client happens to disconnect.
2369
+ *
2370
+ * So every delivery re-runs the same `checkGuardsEffect` the subscribe-time
2371
+ * gate ran — including the async resource-scope resolver, which is where a
2372
+ * per-row/per-team revocation actually shows up. Returns `null` when the
2373
+ * subject still passes, or the typed `ScopeError` that denied it.
2374
+ *
2375
+ * Returns a closure that always resolves `null` when the descriptor carries
2376
+ * no guards, so the caller needs no branch and an unguarded query pays only
2377
+ * a resolved promise.
2378
+ */
2379
+ export declare const makeQueryReauthorizer: (query: MutationLike, input: unknown) => (subject: Subject) => () => Promise<unknown>;
2380
+
2163
2381
  export declare const makeRouterActivity: () => RouterActivity;
2164
2382
 
2165
2383
  /**
@@ -2338,7 +2556,7 @@ export declare interface MutationLike {
2338
2556
  readonly descriptor: {
2339
2557
  readonly name: string;
2340
2558
  readonly source?: string | ReadonlyArray<string> | undefined;
2341
- readonly guards?: ReadonlyArray<GuardCheckSpec> | undefined;
2559
+ readonly guards?: ReadonlyArray<AnyCheckSpec> | undefined;
2342
2560
  };
2343
2561
  executor(input: unknown, ctx: unknown): unknown;
2344
2562
  }
@@ -2467,14 +2685,25 @@ export declare const noopAnalyticsSpec: AnalyticsSinkSpec;
2467
2685
  * misses, writes are dropped. Mirrors the cache's `noopCache`. */
2468
2686
  export declare const noopKv: AsyncKv;
2469
2687
 
2470
- /** Thrown by `.one()` when a query that must match exactly one row found
2471
- * none. Distinct from a found-but-wrong row this is strictly "0 rows". */
2472
- export declare class NoRowFound extends Error {
2473
- readonly table: string;
2474
- readonly _tag = "NoRowFound";
2475
- constructor(table: string);
2688
+ /**
2689
+ * `.one()` matched a number of rows other than exactly one.
2690
+ *
2691
+ * `found` distinguishes the two failure modes without a second error type:
2692
+ * `0` is "the row you required is missing", `2` is "your filter is not as
2693
+ * unique as you assumed" (the terminal probes with LIMIT 2, so `2` means
2694
+ * "at least two" — it never counts the whole set just to report a number).
2695
+ */
2696
+ export declare class NoRowFound extends NoRowFound_base {
2697
+ get message(): string;
2476
2698
  }
2477
2699
 
2700
+ declare const NoRowFound_base: Schema.TaggedErrorClass<NoRowFound, "NoRowFound", {
2701
+ readonly _tag: Schema.tag<"NoRowFound">;
2702
+ } & {
2703
+ table: typeof Schema.String;
2704
+ found: typeof Schema.Number;
2705
+ }>;
2706
+
2478
2707
  export declare interface Notification {
2479
2708
  readonly subscriberId: string;
2480
2709
  readonly fingerprint: string;
@@ -2492,13 +2721,17 @@ export declare const onBindConnectionSubject: (listener: (clientId: number, subj
2492
2721
 
2493
2722
  /** Thrown by `.expectVersion(n).set(...)` when the optimistic-lock guard
2494
2723
  * matched no row (the row was concurrently updated or deleted). */
2495
- export declare class OptimisticLockError extends Error {
2496
- readonly table: string;
2497
- readonly expected: number;
2498
- readonly _tag = "OptimisticLockError";
2499
- constructor(table: string, expected: number);
2724
+ export declare class OptimisticLockError extends OptimisticLockError_base {
2725
+ get message(): string;
2500
2726
  }
2501
2727
 
2728
+ declare const OptimisticLockError_base: Schema.TaggedErrorClass<OptimisticLockError, "OptimisticLockError", {
2729
+ readonly _tag: Schema.tag<"OptimisticLockError">;
2730
+ } & {
2731
+ table: typeof Schema.String;
2732
+ expected: typeof Schema.Number;
2733
+ }>;
2734
+
2502
2735
  export declare interface OrchestratorTickDeps {
2503
2736
  readonly store: DataStore;
2504
2737
  readonly supervisor: AppSupervisor;
@@ -2509,6 +2742,51 @@ export declare interface OrchestratorTickDeps {
2509
2742
  readonly log?: WakeOrchestratorLogger;
2510
2743
  }
2511
2744
 
2745
+ export declare const OUTBOX_TABLE = "_voltro_outbox";
2746
+
2747
+ export declare interface OutboxFacade {
2748
+ /**
2749
+ * Persist the intent to run `effect` after this transaction commits.
2750
+ *
2751
+ * Returns the outbox row id, which doubles as the delivery id a client can
2752
+ * watch to render external-side-effect progress ("saving… syncing… synced").
2753
+ */
2754
+ enqueue(effect: string, payload: Record<string, unknown>, options?: EnqueueOptions): Promise<string>;
2755
+ }
2756
+
2757
+ export declare interface OutboxFacadeDeps {
2758
+ /** The REQUEST's store. Inside a mutation this is the transactional view —
2759
+ * which is what makes the enqueue atomic with the domain write. */
2760
+ readonly store: Pick<DataStore, 'insert' | 'query'>;
2761
+ readonly subject: Subject;
2762
+ readonly traceId: string | null;
2763
+ /** Nudge the drain worker once the transaction commits. Optional: without
2764
+ * it the row still delivers on the next poll tick, just later. */
2765
+ readonly afterCommit?: (work: () => Promise<unknown>) => void;
2766
+ /** Wake the drain loop. */
2767
+ readonly nudge?: () => void;
2768
+ readonly now?: () => Date;
2769
+ }
2770
+
2771
+ export declare interface OutboxHandlerContext {
2772
+ readonly payload: Record<string, unknown>;
2773
+ readonly attempt: number;
2774
+ readonly subjectId: string | null;
2775
+ readonly tenantId: string | null;
2776
+ readonly traceId: string | null;
2777
+ }
2778
+
2779
+ export declare interface OutboxHandlerDefinition {
2780
+ readonly effect: string;
2781
+ readonly handler: (ctx: OutboxHandlerContext) => Promise<unknown>;
2782
+ /** Give up after this many attempts, then dead-letter. Default 8. */
2783
+ readonly maxAttempts?: number;
2784
+ }
2785
+
2786
+ export declare type OutboxStatus = 'pending' | 'delivering' | 'delivered' | 'dead';
2787
+
2788
+ export declare const outboxTable: TableLike;
2789
+
2512
2790
  declare interface P2COptions {
2513
2791
  /**
2514
2792
  * Random selector — `Math.random()` by default. Tests inject a
@@ -3854,7 +4132,17 @@ export declare class SelectBuilder {
3854
4132
  maybeOne(): Promise<Row_4 | null>;
3855
4133
  /** Alias of `maybeOne` — the first row or `null`. */
3856
4134
  first(): Promise<Row_4 | null>;
3857
- /** Terminal: exactly one row; throws `NoRowFound` on zero matches. */
4135
+ /**
4136
+ * Terminal: EXACTLY one row. Fails with `NoRowFound` on zero matches —
4137
+ * and equally on two or more.
4138
+ *
4139
+ * The over-fetch to LIMIT 2 is deliberate. A `LIMIT 1` probe cannot tell
4140
+ * "the one row you meant" from "the first of several", so a filter that
4141
+ * silently stopped being unique would keep returning an arbitrary row and
4142
+ * the bug would surface far away from its cause. One extra row on the
4143
+ * wire buys a loud failure at the point the assumption breaks. Use
4144
+ * `.first()` / `.maybeOne()` when you genuinely want "any match".
4145
+ */
3858
4146
  one(): Promise<Row_4>;
3859
4147
  /** Terminal: COUNT(*) of matching rows (real aggregate, not a fetch). */
3860
4148
  count(): Promise<number>;
@@ -3894,6 +4182,14 @@ export declare const setSystemStoreHandle: (handle: SystemStoreHandle) => void;
3894
4182
  /** Test seam — swap (or reset with `undefined`) the process recorder. */
3895
4183
  export declare const setTimelineRecorderForTest: (recorder: TimelineRecorder | undefined) => void;
3896
4184
 
4185
+ /**
4186
+ * Register (or clear) the process-global tuple source. Last write wins.
4187
+ *
4188
+ * Registering a source is what ACTIVATES relationship guards. Until then they
4189
+ * deny — see `installPolicyGuardResolver`.
4190
+ */
4191
+ export declare const setTupleSource: (source: TupleSource | undefined) => void;
4192
+
3897
4193
  export declare const sha256Hex: (input: string) => string;
3898
4194
 
3899
4195
  export declare type ShapeClassification = {
@@ -4464,6 +4760,20 @@ export declare const triggerWorkflow: <EventPayload = unknown, WorkflowPayload =
4464
4760
  */
4465
4761
  export declare const tryClaimWakeup: (store: DataStore, ref: WakeupRef) => Promise<boolean>;
4466
4762
 
4763
+ /**
4764
+ * Reads the relationship tuples for one (subject, resource).
4765
+ *
4766
+ * Registered once at boot. The default implementation reads
4767
+ * `_voltro_rebac_tuples`; an app whose relationships live in its own tables
4768
+ * (a `teamMembers` row, say) registers its own instead of copying data into a
4769
+ * framework table.
4770
+ */
4771
+ export declare type TupleSource = (req: {
4772
+ readonly subjectId: string | null;
4773
+ readonly resourceType: string;
4774
+ readonly resourceId: string;
4775
+ }) => Promise<ReadonlyArray<RelationTuple>> | ReadonlyArray<RelationTuple>;
4776
+
4467
4777
  /**
4468
4778
  * Remove the override for a connection. Called by the WS-close
4469
4779
  * finalizer (or explicit logout flows). Idempotent.