@voltro/runtime 0.80.0 → 0.81.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,96 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.81.0] — 2026-09-25
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **The compose baseline gives each api app its own database** — `@voltro/cli`
47
+
48
+ `voltro baseline sync` now gives every api app in a `project.json` its own database, login and migrate service. The primary api (the app named `api`, else the first api app) keeps the project's database; every other api app connects to `<project>_<app>` with `<PROJECT>_<APP>_DB_PASSWORD`, and each api app gets `VOLTRO_API_<APP>_URL` for its sibling api apps by service name. Migration: sync, move each secondary app's own tables into its new database, and set the new password variables in production.
49
+
50
+ **`voltro update` carries you across this** — codemod `0.81.0/03_compose-baseline-one-database-per-api-app`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.81.0).
51
+ - **A crashed process blocks its idempotency key for 30 seconds, not a day** — `@voltro/protocol`, `@voltro/runtime`, `@voltro/client`, `@voltro/voltro`
52
+
53
+ A process that died inside an idempotent mutation left its key `in_flight` for the whole replay window — 24 hours by default — so every retry with that key was refused with `MutationInFlight` (REST: `409`), including the retry of a workflow step whose callee had simply restarted. A running claim now holds on a 30-second lease that the server renews while the handler runs, over the socket and on REST alike; a dead process stops renewing, and the next send with the key takes over and runs the mutation once — the dead call's transaction never committed. A completed response still replays for the whole window. A takeover is conditional on the claim still being stale, so a claim renewed a moment earlier is never taken from a live handler. `ctx.apis` calls treat `MutationInFlight` as no answer yet: the same key is re-sent when the callee says, until it answers or the call's deadline passes (then `ApiUnavailable`). BREAKING only for an app that implements `IdempotencyStore` itself: `claim` takes the lease, and `renew(scope, key, now)` is required.
54
+
55
+ **`voltro update` carries you across this** — codemod `0.81.0/05_idempotency-store-renews-its-lease`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.81.0).
56
+ - **Each api app owns its database schema; a second app on it refuses to boot** — `@voltro/database`, `@voltro/cli`
57
+
58
+ The first boot that migrates a database records which app owns it (`_voltro_schema_owner`). A different app booting against the same database and schema now refuses — naming both apps — before it plans anything, under `voltro dev`, `voltro serve` and `VOLTRO_AUTO_MIGRATE=0` alike; `voltro db apply` refuses the same way and claims an unclaimed database — as its first write, after every other check has passed, so an apply refused for drift or a blocked operation leaves the database as it found it. `voltro data` export and import treat the owner row as the deployment's own: it never travels to another database. Two apps on one schema each diffed it against their own declarations and planned to drop the other's tables. Migration: give every api app its own database (`DB_URL`) or schema (`DB_SCHEMA`) and reach the other app's data through `apis:` and `service:` procedures; `voltro doctor` lists api apps of a project that resolve the same target. `voltro db claim --yes` hands a schema to a RENAMED app.
59
+
60
+ **`voltro update` carries you across this** — codemod `0.81.0/01_one-database-per-app`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.81.0).
61
+ - **ReactionDefinition is the union of the table form and the query form** — `@voltro/runtime`, `@voltro/voltro`
62
+
63
+ With reactions that watch a live query's rows, `ReactionDefinition` is now `EdgeReactionDefinition | LevelReactionDefinition`. Code holding a value of that type and reading a member only the table form has — `watch.table`, `watch.on`, `guards.dedupeKey`, `when(event)` — no longer compiles: type it `EdgeReactionDefinition`, or narrow with `isLevelReaction(def)` first. `defineReaction({ … })` is unchanged: an object literal resolves to its own form, and a value typed as the union is still accepted and validated by its `watch`.
64
+
65
+ **`voltro update` carries you across this** — codemod `0.81.0/04_reaction-definition-is-a-union`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.81.0).
66
+ - **Store calls on a table the app does not declare now fail** — `@voltro/runtime`, `@voltro/cli`
67
+
68
+ Every store verb — in handlers, startups, schedules and workflows — now fails with `UndeclaredTableAccess` on a table that is neither declared by the app (its `table()`s, a plugin's `extendSchema.tables`) nor framework-owned (`_voltro_*`), in every environment. A string table name used to reach another app's table past its guards, audit and tenant rules. `store.raw(sql)` is unchanged. Migration: call the owning app through `apis:` and a `service:` procedure, or declare the table if it is this app's own.
69
+
70
+ **`voltro update` carries you across this** — codemod `0.81.0/02_store-refuses-undeclared-tables`. If you pin versions by hand and never run it, print the notes without changing anything: `voltro update --codemods-only --from <your current version> --dry-run` (this one ships in 0.81.0).
71
+
72
+ ### Added
73
+
74
+ - **A plugin can bring app-shaped building blocks with `sources`** — `@voltro/protocol`, `@voltro/cli`
75
+
76
+ `definePlugin({ sources: fileURLToPath(new URL('./app', import.meta.url)) })` points at a directory of the same file conventions an app uses — queries and mutations with their executors, `schema.ts` tables, `*.workflow.tsx`, `*.cron.tsx`, subscribers, reactions — and every app that installs the plugin discovers them exactly as it discovers its own: the same codegen, browser-safety guard, access gate, migrations, and both boot paths. A reusable building block (a ledger, an audit trail, an approval flow) is shared between apps of a project without copying it; each installing app gets its own copy of the block's tables in its own database. `voltro build` keys the block's modules by plugin and path, so the serve bundle finds them wherever the block lives after a deploy.
77
+ - **A reaction can watch the rows of a live query, including another app's** — `@voltro/runtime`, `@voltro/cli`
78
+
79
+ `defineReaction({ watch: { query: 'deployments.pending', api: 'cloud' }, key: (row) => row.id, act: { kind: 'workflow', workflow: 'deploy.run' } })` reacts to every ROW of a live query's current result — the set of open work — instead of to each change of a table. Every delivery is the whole set, so a row that appeared while the reacting process was down, restarting or deploying is acted on when it comes back; nothing is lost. The workflow's `idempotencyKey` makes each row's act exactly-once across every replica, so no claim table is involved and a start that failed is tried again on the next delivery or on `resync` (default 5 minutes). `resync` and `stuckAfter` take milliseconds or a duration from `'30s'` to `'8 days'`. With `api:` the query is another app's `service:` query, subscribed over `ctx.apis` — push without the watched app knowing its watcher; without it, one of this app's own queries, read through the same guards and row filter as its REST projection. A row still in the set long after its workflow started is reported as `stuck` and never restarted. `voltro_reaction_pending`, `voltro_reaction_oldest_seconds` and `voltro_reaction_connected` expose the queue per reaction. The table form (`watch: { table }`) is unchanged; `runReaction` now takes the table form's type, `EdgeReactionDefinition`.
80
+ - **Apps of one project call each other through typed service procedures** — `@voltro/voltro`, `@voltro/protocol`, `@voltro/runtime`, `@voltro/client`, `@voltro/cli`, `@voltro/testing`, `@voltro/workflow`
81
+
82
+ An api app can now call another app of the same project, typed, over the socket, without touching its tables. The called app declares `service: { callers: ['worker'] }` on a query, mutation or action and lists the caller under `serviceCallers: { worker: {} }`; codegen emits those procedures into `serviceGroup.generated.ts` and never into the browser's `rpcGroup.generated.ts`. The calling app binds it under `apis: { cloud: { package: '@acme/api' } }` and calls `ctx.apis.cloud.deployments.activate(input)` (Promise) or `.effect(input)` (Effect), with every input, output and typed error inferred from the callee's declarations. A service authenticates with a per-edge credential (`VOLTRO_API_<BINDING>_TOKEN` on the caller, `VOLTRO_SERVICE_CALLER_<CALLER>_TOKEN` on the callee, `_NEXT` for rotation); `voltro dev` derives and mints it into both apps' `.env.local`, production supplies it (`voltro secret generate service`) and a missing one fails boot. A caller name is letters, digits and dashes, at most 56 characters, so its `service:<name>` identity fits an `id()` column. The called app plants an `actors` row for each accepted caller at boot (`service:<name>`, `kind: 'serviceAccount'`), so `audit()`-stamped writes name the calling app instead of failing on the foreign key. A service subject holds no scopes: it reaches exactly the procedures that name it, is refused everywhere else — framework built-ins included — and no user, API key or `admin:full` session reaches a service procedure. A write made inside a workflow step carries an idempotency key derived from the step, so a step retried after a lost reply replays instead of writing twice; an app with `serviceCallers` deduplicates mutations by default. A call without an answer fails with `ApiUnavailable` within a deadline (10 s by default); calls are measured as `voltro_api_call_duration_seconds` and `voltro_api_call_errors_total`. `voltro doctor` reports a caller listed without a procedure, a procedure naming an unlisted caller and a binding the callee does not accept. `@voltro/testing` adds `makeServiceApiMock`, which decodes every input and output through the callee's real schemas.
83
+
84
+ `apiSurface: compatible` — the replaced golden lines in `@voltro/protocol` (and the `@voltro/voltro` aggregate re-exporting it) are `AnyCheckSpec` and `DeclaredAccess` gaining the member `ServiceAccessSpec`. Every existing member is unchanged and an app keeps writing the guards it wrote; the framework is the only producer of the new member, which it normalises from `service:`. Only a switch exhaustive over guard specs needs an arm.
85
+ - **startTestApps boots several apps of one project, each service edge wired** — `@voltro/testing`
86
+
87
+ `startTestApps({ apps, edges })` from `@voltro/testing` boots the apps of one project as real `voltro` processes (`dev` by default, `serve` with `mode: 'serve'`), each on a free port — every callee before its callers, because a caller imports the contract its callee's codegen writes — and resolves once every app answers `/internal/readiness`; a cycle of edges is refused before anything starts. Every listed edge gets a freshly minted credential written to both ends, under the variables the apps read (`VOLTRO_API_<BINDING>_TOKEN` / `_URL` on the caller, `VOLTRO_SERVICE_CALLER_<CALLER>_TOKEN` on the callee); `apps.token(caller, callee)` returns it so a test can call the callee as the caller. An app that exits or is not ready in time stops the others and the call rejects with its log. Processes rather than one process, because apps with their own tables cannot share one process's table registry.
88
+ - **makeTestApp requests carry the caller's address** — `@voltro/testing`
89
+
90
+ A route that reads `ctx.remoteAddr` (a per-client rate limit, an audit field) could not be tested per client: every `makeTestApp` request arrived without an address. A request's options now take `remoteAddr`, the address the connection comes from, defaulting to `127.0.0.1`. The route sees it as `ctx.remoteAddr`, resolved by the function the server's listener uses, and a `publicApi` procedure's plugin interceptors see the same address, as on the server. `makeTestApp({ security: { trustedProxies, clientAddressHeader } })` takes the api's own settings, so a forwarding header is believed in a test exactly when it would be in production.
91
+
92
+ `markRestInvocation` takes the request's address as an optional third argument, and `restInvocationOf` returns it beside the spec; both only gain an optional member, so existing calls and reads are unchanged.
93
+
94
+ ### Fixed
95
+
96
+ - **Codegen removes an rpc group the app no longer has procedures for** — `@voltro/cli`
97
+
98
+ An app whose last client-reachable procedure went away — deleted, or marked `internal: true` — kept its `rpcGroup.generated.ts`: codegen reported "no rpc primitives found — nothing to write" and left the old file, a typed contract for procedures the server no longer served. The next `voltro dev` or `voltro codegen` now removes it, as it already removed an empty `serviceGroup.generated.ts` and `apis.generated.ts`. A hand-written file of that name is left alone.
99
+ - **The auto-launched dashboard binds `VOLTRO_DASHBOARD_PORT`** — `@voltro/cli`
100
+
101
+ `VOLTRO_DASHBOARD_PORT` was read by `voltro dashboard` only. The dashboard that `voltro dev` and `voltro start` launch on their own — api and web app alike — always came up on `:5179`, and the launcher wrote `5179` into the child's `VOLTRO_DASHBOARD_PORT`, overriding the value the developer had set. The launcher and `voltro dashboard` now resolve the port through one reader: the variable, else `5179`. A value that is not a port (`1..65535`) is refused with a warning naming it instead of silently becoming `5179`.
102
+ - **The db pool boot line counts a connection outside the pool once** — `@voltro/cli`
103
+
104
+ With change data capture on, the line read `max=5 … + 1 = 6` and then, separately, `PLUS 1 outside the pool (CDC LISTEN consumer)`, which reads as 7 when the process holds 6. The outside connection is now named once, inside the sum it belongs to: `max=5 per replica (DB_MAX_CONNECTIONS) + 1 outside the pool (CDC LISTEN consumer) = 6 per process`.
105
+ - **A declared `utf8mb4` store reaches the workflow engine's tables** — `@voltro/database`, `@voltro/workflow`, `@voltro/cli`
106
+
107
+ `store: { charset: 'utf8mb4' }` converted every table the app declares and left the tables the framework creates without declaring them in the database default: the durable workflow engine's `cluster_*` storage, which holds every workflow call's input and result as JSON text, and the deploy runner's `_voltro_deploys` / `_voltro_migration_ops`. On a `utf8mb3` database a workflow whose input carried an emoji was refused there. Every table the framework creates is now created in the declared charset: the runner's ledgers directly, and the workflow engine's tables by the process that creates them on its first boot, while they are still empty. A workflow table a booting process finds is never rewritten at boot; tables from before the declaration are converted by `voltro db apply` (also when the plan has nothing to do, and with `--plan`) once the deploy has closed and by the `voltro dev` auto-migrate, keeping type, nullability and column CHECKs, and `voltro db plan` lists them beside the plan (under `--json` too, on the log, never in the plan file). The database's own default is left alone. The step only ever widens, and a deploy that fails and is undone never runs it.
108
+ - **The MySQL/MariaDB pool keeps its connections instead of reconnecting** — `@voltro/sql-mysql`
109
+
110
+ The driver pool was created with `maxIdle: 0`, so every connection released to it was closed within a second. A process reconnected after each second of quiet: a TCP handshake per burst, plus a TLS one where configured. Measured on MySQL and MariaDB: a query after two quiet seconds ran on a new connection. The pool now keeps the connections it opened, up to the limit.
111
+ - **Workflows started by a reaction or an incoming webhook start again** — `@voltro/cli`
112
+
113
+ A workflow started by a `*.reaction.ts` act or an incoming `*.webhook.tsx` was refused with `Workflow placement requires explicit caller identity`, under `voltro dev` and `voltro serve`: neither path named a caller, and placement refuses a start without one. Both now start as the framework itself, placed in the changed row's tenant when it has one.
114
+ - **The first boot on an empty database no longer warns about replica observations** — `@voltro/cli`
115
+
116
+ `voltro dev` started publishing this replica's observations before its auto-migrate, so the first write on an empty database hit a table that did not exist yet and printed `could not record this replica's observation — fleet views will report this replica as stale` on the first boot of every new project. The publisher now writes its first observation once the schema is applied — at once on `voltro serve`, whose fingerprint check runs before it — and never when the boot stops earlier.
117
+ - **A 405 on a shared REST path names every method the path serves** — `@voltro/protocol`
118
+
119
+ A `DELETE` to a path served by `GET` and `POST` answered `405` with `Allow: POST`: each route refused with its own method, and the last refusal won. `Allow` and the error body's `allow` now list every method declared on the path, `GET, POST`, as the REST documentation already promised.
120
+ - **A large REST surface no longer holds the boot for seconds registering routes** — `@voltro/runtime`, `@voltro/protocol`
121
+
122
+ Every REST route used to be mounted for all 34 HTTP methods the router knows, at its path and at its sub-path, and each insert compares itself against every entry already held. A public REST surface of about 1000 procedures therefore held about 34 000 router entries, and building them kept the thread busy for over a second after the port was bound — long enough for a liveness probe to time out. A path is now mounted for exactly the methods its routes declare: the same 1000 procedures hold 4 000 entries. Behaviour is unchanged. A method the path does not declare still reaches its routes and gets the precise `405` with `Allow`, a path nobody declared is still a `404`, and `HEAD` still answers from `GET`. For this, a REST route now declares its real `method` instead of `'*'` and sets the new `PluginHttpRoute.answersOtherMethods: true`. A plugin route that declares one method without that flag never sees another method, as before. A server of your own that mounts `restRoutesToHttpRoutes` output by method must hand the other methods to routes with `answersOtherMethods`, or they become `404` instead of `405`.
123
+ - **The testing guide's tsconfig example no longer sets baseUrl** — `@voltro/cli`
124
+
125
+ The `paths` alias example in the testing guide set `"baseUrl": "."`, an option TypeScript 7 removed. It now maps `paths` alone, which resolves against the tsconfig's own directory. `voltro test` still reads a `baseUrl` an older config sets.
126
+ - **voltro update lists a note's code and config lines before prose** — `@voltro/cli`
127
+
128
+ A manual note lists the lines it found in code and config first, under `── found in code or config (N lines)`. Lines in Markdown and text files follow in their own `── mentioned in prose (N lines)` block, and a note that matched only prose says `nothing in code or config matched`. Past the lines shown, the rest are named by file with a count instead of a bare `… and N more`. Before, the lines were sorted by path, and 40 lines of documentation hid a Helm env entry, the one line to change. The guide `voltro agents-md` copies into `agent-docs/` is no longer searched. It names every change of a release, so refreshing it made notes report "found" for changes the project never used.
129
+
130
+ ---
131
+
42
132
  ## [0.80.0] — 2026-09-24
43
133
 
44
134
  ### ⚠ BREAKING
package/dist/index.d.ts CHANGED
@@ -909,6 +909,12 @@ export declare const API_CSP = "default-src 'none'; frame-ancestors 'none'; base
909
909
 
910
910
  export declare const API_KEYS_TABLE = "_voltro_api_keys";
911
911
 
912
+ /** Caller side: the token this app presents to the api it binds as `name`. */
913
+ export declare const apiBindingTokenEnv: (name: string) => string;
914
+
915
+ /** Caller side: where the api it binds as `name` listens (production). */
916
+ export declare const apiBindingUrlEnv: (name: string) => string;
917
+
912
918
  /** What `ApiKeyStore.ensureActor` is handed for the row it plants. */
913
919
  export declare interface ApiKeyActor {
914
920
  readonly id: string;
@@ -1107,6 +1113,19 @@ export declare interface AppAccess {
1107
1113
  readonly scopes: ReadonlyArray<string>;
1108
1114
  }
1109
1115
 
1116
+ /**
1117
+ * The typed clients of the apps this app calls — augmented per app by the
1118
+ * codegen'd `apis.generated.ts`:
1119
+ *
1120
+ * declare module '@voltro/runtime' {
1121
+ * interface AppApis { readonly cloud: ServiceApi<CloudServiceProcedures> }
1122
+ * }
1123
+ *
1124
+ * Empty until an app declares `apis:`.
1125
+ */
1126
+ export declare interface AppApis {
1127
+ }
1128
+
1110
1129
  export declare interface AppContext {
1111
1130
  /** Run database work with a freshly transaction-bound context. Reuses an
1112
1131
  * existing mutation transaction (no savepoint). The callback may be retried;
@@ -1204,6 +1223,19 @@ export declare interface AppContext {
1204
1223
  * subject", by construction.
1205
1224
  */
1206
1225
  readonly connections?: ConnectionsFacade;
1226
+ /**
1227
+ * The other apps of the project this app calls (`ctx.apis.<name>`) — one
1228
+ * typed client per `apis:` binding in `app.config.ts`, typed from the
1229
+ * callee's `service:` procedures by the codegen'd `apis.generated.ts`.
1230
+ *
1231
+ * await ctx.apis.cloud.deployments.activate({ deploymentId, tenantId })
1232
+ * yield* ctx.apis.cloud.deployments.activate.effect(input) // in a workflow step
1233
+ *
1234
+ * Always present — an app with no bindings gets an empty object, so the field
1235
+ * never reads as "maybe wired". A call without an answer fails with
1236
+ * `ApiUnavailable`; every answer the callee gives arrives as itself.
1237
+ */
1238
+ readonly apis: AppApis;
1207
1239
  /**
1208
1240
  * Request-scoped batching (`ctx.load` / `ctx.loadMany`). Coalesces
1209
1241
  * same-tick reads of one table into a single `WHERE id IN (...)`.
@@ -1460,6 +1492,9 @@ export declare const assertCan: (subject: RebacSubject, action: string, resource
1460
1492
  */
1461
1493
  export declare const assertConnectionCipherConfigured: (definitions: ReadonlyArray<ConnectionDefinition>) => void;
1462
1494
 
1495
+ /** Refuse a caller map that could not be what the author meant. */
1496
+ export declare const assertServiceCallersConfig: (callers: ServiceCallersConfig) => void;
1497
+
1463
1498
  /**
1464
1499
  * Boot gate: every mounted incoming-webhook route must carry a verification
1465
1500
  * declaration. Throws `UnverifiedWebhookRoute` for the first that does not.
@@ -1640,6 +1675,19 @@ export declare interface AuditableQuery {
1640
1675
 
1641
1676
  export declare interface AuthMiddlewareLayerOptions {
1642
1677
  readonly resolveSubject: ResolveSubjectFn;
1678
+ /**
1679
+ * The apps allowed to call one rpc tag, or `undefined` when the tag is not a
1680
+ * `service:` procedure. Both boot paths pass the same lookup over the
1681
+ * procedures they registered.
1682
+ *
1683
+ * This is the OUTER wall for a service credential: the guard check refuses a
1684
+ * service subject at every procedure that does not name it, but framework
1685
+ * built-ins (`__voltro.*`) and plugin routes reach their handlers without a
1686
+ * descriptor guard. Here, before any handler, a service subject reaches only
1687
+ * the tags that name it — so a leaked service token opens exactly the doors
1688
+ * the callee declared and nothing the framework adds.
1689
+ */
1690
+ readonly serviceCallersFor?: (tag: string) => ReadonlyArray<string> | undefined;
1643
1691
  }
1644
1692
 
1645
1693
  /**
@@ -3303,6 +3351,10 @@ export declare const DEFAULT_HSTS = "max-age=15552000; includeSubDomains";
3303
3351
  */
3304
3352
  export declare const DEFAULT_KEEP_ALIVE_MS = 72000;
3305
3353
 
3354
+ export declare const DEFAULT_LEVEL_RESYNC_MS: number;
3355
+
3356
+ export declare const DEFAULT_LEVEL_STUCK_MS: number;
3357
+
3306
3358
  /** Ceiling the idle backoff climbs to. Deliberately short enough to be a
3307
3359
  * FLOOR under a missed wake rather than a substitute for one. */
3308
3360
  export declare const DEFAULT_MAX_IDLE_INTERVAL_MS = 30000;
@@ -3541,7 +3593,12 @@ export declare const defineOutboxHandler: <R = never>(definition: OutboxHandlerD
3541
3593
 
3542
3594
  /** Declare a reaction. Validates that the MANDATORY guard is present — an
3543
3595
  * ungated reaction is a spend-storm footgun, so this fails LOUD at boot. */
3544
- export declare const defineReaction: (def: ReactionDefinition) => ReactionDefinition;
3596
+ export declare function defineReaction(def: LevelReactionDefinition): LevelReactionDefinition;
3597
+
3598
+ export declare function defineReaction(def: EdgeReactionDefinition): EdgeReactionDefinition;
3599
+
3600
+ /** A value already typed as either form — narrowed at runtime by its `watch`. */
3601
+ export declare function defineReaction(def: ReactionDefinition): ReactionDefinition;
3545
3602
 
3546
3603
  /** Register a resource policy (the static, discoverable relationship registry
3547
3604
  * the capability map exposes). Validates + fails LOUD on a malformed or
@@ -4064,6 +4121,15 @@ export declare interface DrainResult {
4064
4121
  */
4065
4122
  export declare const dueWakeups: (store: DataStore, now: Date, limit?: number) => Promise<ReadonlyArray<Wakeup>>;
4066
4123
 
4124
+ /** A reaction to each CHANGE of a table (edge-triggered). */
4125
+ export declare interface EdgeReactionDefinition extends ReactionBase {
4126
+ /** The table whose changes trigger the reaction (+ optional op filter). */
4127
+ readonly watch: ReactionEdgeWatch;
4128
+ /** Optional predicate over the change — skip when it returns false. */
4129
+ readonly when?: (event: ReactionEvent) => boolean;
4130
+ readonly guards: ReactionGuards;
4131
+ }
4132
+
4067
4133
  /** The minimal Effect-store surface `insertIgnoreRowOutcomeEffect` needs. */
4068
4134
  export declare interface EffectInsertIgnoreOutcomeStore {
4069
4135
  readonly insertIgnoreWithOutcome: (table: string, row: Row, options: {
@@ -5577,6 +5643,8 @@ export declare interface GroupState {
5577
5643
  readonly extreme?: number;
5578
5644
  }
5579
5645
 
5646
+ export declare const guardUndeclaredTables: (underlying: DataStore, isKnownTable: (table: string) => boolean) => DataStore;
5647
+
5580
5648
  /** What a user handler may return. */
5581
5649
  export declare type HandlerBody<R = never> = void | Promise<unknown> | Effect.Effect<unknown, unknown, R>;
5582
5650
 
@@ -6016,6 +6084,12 @@ export declare const isIdleNow: (opts: IdleCheckOptions) => Promise<boolean>;
6016
6084
 
6017
6085
  export declare const isInfraError: (e: unknown) => boolean;
6018
6086
 
6087
+ /** Narrow a reaction definition to the level form. */
6088
+ export declare const isLevelReaction: (def: ReactionDefinition) => def is LevelReactionDefinition;
6089
+
6090
+ /** Narrow a reaction to the level form. */
6091
+ export declare const isLevelWatch: (watch: ReactionWatch) => watch is ReactionLevelWatch;
6092
+
6019
6093
  export declare const isObservingGraph: () => boolean;
6020
6094
 
6021
6095
  /**
@@ -6123,6 +6197,8 @@ export declare const isTransactionReadConflict: (err: unknown) => err is Transac
6123
6197
  /** Is `address` one of the peers we are willing to take a forwarded chain from? */
6124
6198
  export declare const isTrustedProxy: (address: string | undefined, config: TrustedProxyConfig) => boolean;
6125
6199
 
6200
+ export declare const isWellFormedServiceToken: (value: string) => boolean;
6201
+
6126
6202
  export declare const isWorkflowEventTriggerDefinition: (value: unknown) => value is WorkflowEventTriggerDefinition;
6127
6203
 
6128
6204
  /** A maintainable aggregate op. (countDistinct / window funcs are NOT here —
@@ -6246,6 +6322,34 @@ export declare const lastWriteProvenance: (row: Record<string, unknown> | null |
6246
6322
  readonly traceId?: string | null;
6247
6323
  }) => ProvenanceResult;
6248
6324
 
6325
+ /**
6326
+ * A reaction to every ROW of a live query's current result (level-triggered).
6327
+ *
6328
+ * The act is a workflow, and the workflow's `idempotencyKey` is what makes it
6329
+ * exactly-once across the fleet: every replica that sees the row may try to
6330
+ * start it, and the engine collapses them into one execution. So there is no
6331
+ * `dedupeKey` here, and the workflow's key must identify the unit of work — a
6332
+ * row that can come BACK into the set (an org suspended, unsuspended, suspended
6333
+ * again) needs a key carrying that generation (`suspend:${orgId}:${suspendedAt}`).
6334
+ *
6335
+ * The workflow must move the row OUT of the set (its first step flips a status,
6336
+ * its failure path records a terminal one). A row still in the set long after
6337
+ * it was started is reported as `stuck`, never silently restarted.
6338
+ */
6339
+ export declare interface LevelReactionDefinition extends ReactionBase {
6340
+ readonly watch: ReactionLevelWatch;
6341
+ /** The identity of a row in the set — what `stuck` reports and the runner tracks. */
6342
+ readonly key: (row: never) => string;
6343
+ /** Only rows it returns true for act. */
6344
+ readonly when?: (row: never) => boolean;
6345
+ /** Re-read the whole set this often even when nothing changed, so a start that
6346
+ * failed is retried. Default 5 minutes, minimum 30 seconds. */
6347
+ readonly resync?: number | string;
6348
+ /** Report a row still in the set this long after it was started. Default 15 minutes. */
6349
+ readonly stuckAfter?: number | string;
6350
+ readonly guards?: Omit<ReactionGuards, 'dedupeKey'>;
6351
+ }
6352
+
6249
6353
  export declare class LinearScanMatcher implements Matcher {
6250
6354
  private readonly byTable;
6251
6355
  register(sub: SubscriptionRecord): void;
@@ -7069,6 +7173,11 @@ export declare type MetricsMode = 'off' | 'console' | 'otlp' | 'memory';
7069
7173
  /** The lowest cadence any platform scheduler can express. */
7070
7174
  export declare const MIN_EXTERNALIZABLE_INTERVAL_MS = 60000;
7071
7175
 
7176
+ export declare const MIN_LEVEL_RESYNC_MS = 30000;
7177
+
7178
+ /** Mint a fresh service token. `voltro dev` and `voltro secret generate` call this. */
7179
+ export declare const mintServiceToken: () => string;
7180
+
7072
7181
  /**
7073
7182
  * Ordering token for one mirrored write.
7074
7183
  *
@@ -8282,17 +8391,23 @@ export declare type ReactionAct = {
8282
8391
  readonly payload?: (event: ReactionEvent) => unknown;
8283
8392
  };
8284
8393
 
8285
- export declare interface ReactionDefinition {
8394
+ declare interface ReactionBase {
8286
8395
  readonly name: string;
8287
- /** The table whose changes trigger the reaction (+ optional op filter). */
8288
- readonly watch: {
8289
- readonly table: string;
8290
- readonly on?: ReactionOp | ReadonlyArray<ReactionOp>;
8291
- };
8292
- /** Optional predicate over the change — skip when it returns false. */
8293
- readonly when?: (event: ReactionEvent) => boolean;
8294
8396
  readonly act: ReactionAct;
8295
- readonly guards: ReactionGuards;
8397
+ }
8398
+
8399
+ export declare type ReactionDefinition = EdgeReactionDefinition | LevelReactionDefinition;
8400
+
8401
+ /** `'5 minutes'` / `'30s'` / `'8 days'` / `300000` → milliseconds. */
8402
+ export declare const reactionDurationMs: (value: number | string, field: string, reaction: string) => number;
8403
+
8404
+ /**
8405
+ * The EDGE form: react to each change of one table, as it happens.
8406
+ * Best-effort — a change made while the reacting process is down is not seen.
8407
+ */
8408
+ export declare interface ReactionEdgeWatch {
8409
+ readonly table: string;
8410
+ readonly on?: ReactionOp | ReadonlyArray<ReactionOp>;
8296
8411
  }
8297
8412
 
8298
8413
  /** The change a reaction reacts to (the framework's CDC shape). */
@@ -8338,6 +8453,23 @@ export declare interface ReactionGuards {
8338
8453
  };
8339
8454
  }
8340
8455
 
8456
+ /**
8457
+ * The LEVEL form: react to every ROW of a live query's current result — the set
8458
+ * of open work — for as long as it is in the set.
8459
+ *
8460
+ * Each delivery of the query is the whole current set, so work that appeared
8461
+ * while the reacting process was down, restarting or deploying is seen on the
8462
+ * first delivery after it comes back: nothing is lost. `api` names a binding in
8463
+ * `apis:` (the query is another app's `service:` query); omit it to watch one
8464
+ * of this app's own queries.
8465
+ */
8466
+ export declare interface ReactionLevelWatch {
8467
+ readonly query: string;
8468
+ readonly api?: string;
8469
+ /** The query's input. Default `{}`. */
8470
+ readonly input?: unknown;
8471
+ }
8472
+
8341
8473
  export declare type ReactionOp = 'insert' | 'update' | 'delete';
8342
8474
 
8343
8475
  export declare type ReactionOutcome = 'acted' | 'skipped-op' | 'skipped-when' | 'skipped-dedupe' | 'skipped-ratelimit' | 'skipped-budget';
@@ -8396,6 +8528,8 @@ export declare interface ReactionRunDeps {
8396
8528
  readonly now?: () => number;
8397
8529
  }
8398
8530
 
8531
+ export declare type ReactionWatch = ReactionEdgeWatch | ReactionLevelWatch;
8532
+
8399
8533
  /** The reactive tunables a caller may declare. Every field optional; every
8400
8534
  * default above. */
8401
8535
  export declare interface ReactiveConfigInput {
@@ -9522,7 +9656,8 @@ export declare type ResolveSubjectFn = (input: {
9522
9656
  export declare const resolveTimelineMode: (env?: Record<string, string | undefined>) => TimelineMode;
9523
9657
 
9524
9658
  /** Select identity before opening a transaction, never while one owns writes.
9525
- * Only the framework's system subject may select a different tenant. */
9659
+ * Only a cross-tenant machine subject — the framework's system subject, or a
9660
+ * service calling a `service:` procedure — may select a different tenant. */
9526
9661
  export declare const resolveTransactionSubject: (subject: Subject, tenantId: string | undefined, transactionActive: boolean) => Subject;
9527
9662
 
9528
9663
  /**
@@ -10035,6 +10170,9 @@ export declare interface RpcServerOptions<Rpcs extends Rpc.Any> {
10035
10170
  * boot-path parity rule asks for.
10036
10171
  */
10037
10172
  readonly publishSurfaceDigest?: boolean;
10173
+ /** The bound tags that are `service:` procedures — published as
10174
+ * `serviceSurface`, kept out of the client `surface`. */
10175
+ readonly serviceSurfaceTags?: ReadonlySet<string>;
10038
10176
  /** Non-sensitive fleet identity written on the rpc WebSocket open/close
10039
10177
  * records. Defaults to the process identity resolver (POD_NAME/HOSTNAME).
10040
10178
  * Embedders may state it explicitly when they own process identity. */
@@ -10248,7 +10386,7 @@ export declare const runProvidedEffect: <A>(effect: Effect.Effect<A, unknown, ne
10248
10386
  * `agentActor(via:'agent')` scoped to the row's tenant. Idempotent: the same
10249
10387
  * dedupe key never acts twice.
10250
10388
  */
10251
- export declare const runReaction: (reaction: ReactionDefinition, event: ReactionEvent, deps: ReactionRunDeps) => Promise<ReactionOutcome>;
10389
+ export declare const runReaction: (reaction: EdgeReactionDefinition, event: ReactionEvent, deps: ReactionRunDeps) => Promise<ReactionOutcome>;
10252
10390
 
10253
10391
  /**
10254
10392
  * Start the full single-node orchestrator: bind the router on the public
@@ -11208,6 +11346,49 @@ export declare type ServeRpcInterceptor = (base: Effect.Effect<unknown, unknown,
11208
11346
  /** The published tracer, or `undefined` when tracing is off / no server booted. */
11209
11347
  export declare const serverTracer: () => Tracer.Tracer | undefined;
11210
11348
 
11349
+ /** The bearer prefix every service credential carries. */
11350
+ export declare const SERVICE_TOKEN_PREFIX = "vsvc_";
11351
+
11352
+ /**
11353
+ * One accepted caller. Declared in the callee's `app.config.ts`:
11354
+ *
11355
+ * serviceCallers: { worker: {} }
11356
+ *
11357
+ * The object is the per-caller configuration slot; today it carries nothing,
11358
+ * because the token comes from the environment by convention and the grant
11359
+ * comes from the procedures. It is an object rather than `true` so a later
11360
+ * per-caller option (a rate limit, an mTLS identity) is an addition, not a
11361
+ * change of shape.
11362
+ */
11363
+ export declare type ServiceCallerConfig = Readonly<Record<string, never>>;
11364
+
11365
+ /** Callee side: the second accepted value during a rotation. */
11366
+ export declare const serviceCallerNextTokenEnv: (caller: string) => string;
11367
+
11368
+ export declare type ServiceCallersConfig = Readonly<Record<string, ServiceCallerConfig>>;
11369
+
11370
+ /**
11371
+ * The strategy an app with `serviceCallers` puts FIRST in its chain.
11372
+ *
11373
+ * It claims only `Bearer vsvc_…`. Every other request is a `skip`, so an app's
11374
+ * users never pay more than a prefix check.
11375
+ */
11376
+ export declare const serviceCallerStrategy: (options: ServiceCallerStrategyOptions) => AuthStrategy;
11377
+
11378
+ export declare interface ServiceCallerStrategyOptions {
11379
+ readonly callers: ServiceCallersConfig;
11380
+ /** Read lazily — at request time and in `verifyConfig` — never while the
11381
+ * config is imported, so `voltro build` needs no production secret. */
11382
+ readonly env?: () => Readonly<Record<string, string | undefined>>;
11383
+ }
11384
+
11385
+ /** Callee side: the env var holding the token a caller presents. */
11386
+ export declare const serviceCallerTokenEnv: (caller: string) => string;
11387
+
11388
+ /** `worker` → `WORKER`, `build-runner` → `BUILD_RUNNER`, `voltroCloudWorker` →
11389
+ * `VOLTRO_CLOUD_WORKER`. The env-var spelling of an app name. */
11390
+ export declare const serviceEnvSegment: (name: string) => string;
11391
+
11211
11392
  export declare const setBufferedBytes: (label: string, bytes: number) => void;
11212
11393
 
11213
11394
  /** Register the process-wide connection resolver (or clear with `undefined`).
@@ -12365,6 +12546,13 @@ declare interface TypedUpsertOptions<T extends AnyWritableTable> {
12365
12546
  */
12366
12547
  export declare const unbindConnection: (clientId: number) => void;
12367
12548
 
12549
+ export declare class UndeclaredTableAccess extends Error {
12550
+ readonly table: string;
12551
+ readonly operation: string;
12552
+ readonly _tag = "UndeclaredTableAccess";
12553
+ constructor(table: string, operation: string);
12554
+ }
12555
+
12368
12556
  /** The store surface an inverse op needs (a subset of the framework DataStore). */
12369
12557
  export declare interface UndoApplyStore {
12370
12558
  insert(table: string, row: Record<string, unknown>): Promise<unknown> | unknown;