@voltro/runtime 0.4.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,27 @@ _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
+
42
63
  ## [0.4.0] — 2026-07-18
43
64
 
44
65
  ### ⚠ 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';
@@ -758,6 +758,28 @@ export declare interface AppContext {
758
758
  * triggers. `emit(name, payload)` records the event and fans out to
759
759
  * matching workflow triggers. */
760
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'];
761
783
  }
762
784
 
763
785
  /**
@@ -911,6 +933,9 @@ export declare interface AttachAnalyticsMirrorOptions {
911
933
  */
912
934
  export declare const awaitServerListening: (server: ReturnType<typeof createServer>, timeoutMs?: number) => Promise<void>;
913
935
 
936
+ /** Exponential backoff with a ceiling — 1s, 2s, 4s … capped at 5 minutes. */
937
+ export declare const backoffMs: (attempt: number) => number;
938
+
914
939
  /**
915
940
  * Override the subject for an active connection. Called from the
916
941
  * `auth.signin` (or any "I just authenticated this caller") handler
@@ -973,9 +998,17 @@ cache?: {
973
998
  readonly swrMs: number | undefined;
974
999
  readonly scope: "subject" | "global";
975
1000
  readonly baseKey: string;
976
- }) => 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>;
977
1010
 
978
- 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>;
979
1012
 
980
1013
  export declare interface BrandedScheduleDefinition extends ScheduleDefinition {
981
1014
  readonly [SCHEDULE_BRAND]: true;
@@ -1295,6 +1328,21 @@ export declare const currentRoutingContext: () => RoutingContext | undefined;
1295
1328
  /** The trace context active on the current async stack, if any. */
1296
1329
  export declare const currentTraceContext: () => LogTraceContext | undefined;
1297
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
+
1298
1346
  export { DataStore }
1299
1347
 
1300
1348
  /** DataStore-backed store over `_voltro_api_keys`. */
@@ -1346,6 +1394,16 @@ export declare const defineAggregate: <Row>(input: AggregateDefinitionInput<Row>
1346
1394
 
1347
1395
  export declare const defineEventTrigger: <EventPayload = unknown, WorkflowPayload = EventPayload>(spec: Omit<WorkflowEventTriggerDefinition<EventPayload, WorkflowPayload>, "_tag">) => WorkflowEventTriggerDefinition<EventPayload, WorkflowPayload>;
1348
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
+
1349
1407
  /** Declare a reaction. Validates that the MANDATORY guard is present — an
1350
1408
  * ungated reaction is a spend-storm footgun, so this fails LOUD at boot. */
1351
1409
  export declare const defineReaction: (def: ReactionDefinition) => ReactionDefinition;
@@ -1451,7 +1509,26 @@ export declare class Dispatcher {
1451
1509
  /** Snapshot-cache binding (Layer 3). When present AND `deps.cache` is
1452
1510
  * wired, the initial snapshot is served through the cache, tagged with
1453
1511
  * the dependent-table set, and kept warm by the recompute path. */
1454
- 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;
1455
1532
  /**
1456
1533
  * Register a COMPUTED-query subscription. The handler already ran once
1457
1534
  * (its value is `computed.value`); we emit that as the initial snapshot,
@@ -1496,6 +1573,31 @@ export declare interface DispatcherDependencies {
1496
1573
  readonly cache?: SnapshotCache;
1497
1574
  }
1498
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
+
1499
1601
  /**
1500
1602
  * Up to `limit` wakeups that are due now (`wakeAt <= now`), earliest
1501
1603
  * first. Pending rows are read ordered by `wakeAt`, so the earliest —
@@ -1514,7 +1616,11 @@ export declare class EffectStore extends EffectStore_base {
1514
1616
  declare const EffectStore_base: Context.TagClass<EffectStore, "@voltro/EffectStore", EffectStoreOps>;
1515
1617
 
1516
1618
  export declare interface EffectStoreOps {
1517
- 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>;
1518
1624
  readonly insert: (table: string, row: Row_4) => Effect.Effect<Row_4, StoreError>;
1519
1625
  readonly update: (table: string, primaryKey: string, patch: Readonly<Record<string, unknown>>) => Effect.Effect<Row_4 | null, StoreError>;
1520
1626
  readonly delete: (table: string, primaryKey: string) => Effect.Effect<boolean, StoreError>;
@@ -1530,6 +1636,15 @@ export declare const emptySchemaRegistry: SchemaRegistry;
1530
1636
  * columns use). Throws if no cipher is registered. */
1531
1637
  export declare const encryptField: (plaintext: string) => string;
1532
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
+
1533
1648
  /** The always-available default — reads `process.env`. Sync under the hood,
1534
1649
  * Promise-wrapped to satisfy the async contract. */
1535
1650
  export declare const envSecretsBackend: SecretsBackend;
@@ -1575,7 +1690,25 @@ export declare interface FieldChange {
1575
1690
  */
1576
1691
  export declare const fingerprintFor: (table: string, predicate: Predicate, indexHint?: string) => string;
1577
1692
 
1578
- 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>>;
1579
1712
  /** Fluent, scope-applying read builder: `select('notes').where(...).all()`. */
1580
1713
  select(table: string): SelectBuilder;
1581
1714
  /** Fluent predicate update: `update('notes').where('id', id).set({...})`. */
@@ -1655,6 +1788,8 @@ export declare const getResourcePolicy: (resourceType: string) => ResourcePolicy
1655
1788
  * tap feeds it; the inspect endpoint reads it. */
1656
1789
  export declare const getTimelineRecorder: () => TimelineRecorder;
1657
1790
 
1791
+ export declare const getTupleSource: () => TupleSource | undefined;
1792
+
1658
1793
  /** Stable group key from the groupBy column values (JSON to disambiguate types). */
1659
1794
  export declare const groupKeyOf: (row: Row_2, groupBy: ReadonlyArray<string> | undefined) => string;
1660
1795
 
@@ -1858,6 +1993,25 @@ export declare interface InspectStream {
1858
1993
 
1859
1994
  export { inspectWorkflow }
1860
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
+
1861
2015
  /** Fire every registered interrupt for `clientId` (called on disconnect). */
1862
2016
  export declare const interruptConnectionStreams: (clientId: number) => void;
1863
2017
 
@@ -1993,6 +2147,13 @@ export declare const listResourcePolicies: () => ReadonlyArray<ResourcePolicy>;
1993
2147
 
1994
2148
  export { listRetentions }
1995
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
+
1996
2157
  /** Load the tuples for one (subject, resource) — the interceptor's tuple source.
1997
2158
  * Returns [] for an anonymous subject (→ `can` denies anyway). */
1998
2159
  export declare const loadResourceTuples: (store: DataStore, subjectId: string | null, resourceType: string, resourceId: string) => Promise<ReadonlyArray<RelationTuple>>;
@@ -2123,6 +2284,12 @@ export declare const makeBufferingSpanProcessor: (onSpanEnd: (record: TraceSpanR
2123
2284
  */
2124
2285
  export declare const makeCoordinatedScheduler: (deps: CoordinatedScheduleDeps) => ((name: string, intervalMs: number, effect: () => void | Promise<void>) => CoordinatedScheduleHandle);
2125
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
+
2126
2293
  /**
2127
2294
  * Build the Layer that provides `EffectStore` from a `MutationStore`.
2128
2295
  * dev.ts (CLI) and any standalone test setup uses this to wire up the
@@ -2168,6 +2335,8 @@ export declare const makeLazyWorkflowFacade: (resolve: () => WorkflowsAppContext
2168
2335
  */
2169
2336
  export declare const makeMutationRunner: (deps: MutationRunnerDeps) => (mutation: MutationLike, input: unknown, requestContext: ServeRequestContext) => Promise<unknown>;
2170
2337
 
2338
+ export declare const makeOutboxFacade: (deps: OutboxFacadeDeps) => OutboxFacade;
2339
+
2171
2340
  export declare const makePostCommitWorkflowFacade: (base: WorkflowsAppContext, afterCommit: (work: () => Promise<unknown>) => void) => WorkflowsAppContext;
2172
2341
 
2173
2342
  /**
@@ -2188,6 +2357,27 @@ export declare const makeProcessAdapter: (supervisor: AppSupervisor) => WakeAdap
2188
2357
  */
2189
2358
  export declare const makeQueryDescriptorProducer: <D>(deps: QueryProducerDeps<D>) => (query: MutationLike, input: unknown) => (requestContext: ServeRequestContext) => D | ComputedQuery | Effect.Effect<D | ComputedQuery, unknown, never>;
2190
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
+
2191
2381
  export declare const makeRouterActivity: () => RouterActivity;
2192
2382
 
2193
2383
  /**
@@ -2366,7 +2556,7 @@ export declare interface MutationLike {
2366
2556
  readonly descriptor: {
2367
2557
  readonly name: string;
2368
2558
  readonly source?: string | ReadonlyArray<string> | undefined;
2369
- readonly guards?: ReadonlyArray<GuardCheckSpec> | undefined;
2559
+ readonly guards?: ReadonlyArray<AnyCheckSpec> | undefined;
2370
2560
  };
2371
2561
  executor(input: unknown, ctx: unknown): unknown;
2372
2562
  }
@@ -2495,14 +2685,25 @@ export declare const noopAnalyticsSpec: AnalyticsSinkSpec;
2495
2685
  * misses, writes are dropped. Mirrors the cache's `noopCache`. */
2496
2686
  export declare const noopKv: AsyncKv;
2497
2687
 
2498
- /** Thrown by `.one()` when a query that must match exactly one row found
2499
- * none. Distinct from a found-but-wrong row this is strictly "0 rows". */
2500
- export declare class NoRowFound extends Error {
2501
- readonly table: string;
2502
- readonly _tag = "NoRowFound";
2503
- 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;
2504
2698
  }
2505
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
+
2506
2707
  export declare interface Notification {
2507
2708
  readonly subscriberId: string;
2508
2709
  readonly fingerprint: string;
@@ -2520,13 +2721,17 @@ export declare const onBindConnectionSubject: (listener: (clientId: number, subj
2520
2721
 
2521
2722
  /** Thrown by `.expectVersion(n).set(...)` when the optimistic-lock guard
2522
2723
  * matched no row (the row was concurrently updated or deleted). */
2523
- export declare class OptimisticLockError extends Error {
2524
- readonly table: string;
2525
- readonly expected: number;
2526
- readonly _tag = "OptimisticLockError";
2527
- constructor(table: string, expected: number);
2724
+ export declare class OptimisticLockError extends OptimisticLockError_base {
2725
+ get message(): string;
2528
2726
  }
2529
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
+
2530
2735
  export declare interface OrchestratorTickDeps {
2531
2736
  readonly store: DataStore;
2532
2737
  readonly supervisor: AppSupervisor;
@@ -2537,6 +2742,51 @@ export declare interface OrchestratorTickDeps {
2537
2742
  readonly log?: WakeOrchestratorLogger;
2538
2743
  }
2539
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
+
2540
2790
  declare interface P2COptions {
2541
2791
  /**
2542
2792
  * Random selector — `Math.random()` by default. Tests inject a
@@ -3882,7 +4132,17 @@ export declare class SelectBuilder {
3882
4132
  maybeOne(): Promise<Row_4 | null>;
3883
4133
  /** Alias of `maybeOne` — the first row or `null`. */
3884
4134
  first(): Promise<Row_4 | null>;
3885
- /** 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
+ */
3886
4146
  one(): Promise<Row_4>;
3887
4147
  /** Terminal: COUNT(*) of matching rows (real aggregate, not a fetch). */
3888
4148
  count(): Promise<number>;
@@ -3922,6 +4182,14 @@ export declare const setSystemStoreHandle: (handle: SystemStoreHandle) => void;
3922
4182
  /** Test seam — swap (or reset with `undefined`) the process recorder. */
3923
4183
  export declare const setTimelineRecorderForTest: (recorder: TimelineRecorder | undefined) => void;
3924
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
+
3925
4193
  export declare const sha256Hex: (input: string) => string;
3926
4194
 
3927
4195
  export declare type ShapeClassification = {
@@ -4492,6 +4760,20 @@ export declare const triggerWorkflow: <EventPayload = unknown, WorkflowPayload =
4492
4760
  */
4493
4761
  export declare const tryClaimWakeup: (store: DataStore, ref: WakeupRef) => Promise<boolean>;
4494
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
+
4495
4777
  /**
4496
4778
  * Remove the override for a connection. Called by the WS-close
4497
4779
  * finalizer (or explicit logout flows). Idempotent.