Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b651d34
feat(events): add preflight gate contract
ScriptedAlchemy Sep 5, 2026
71b4f28
Merge remote-tracking branch 'origin/main' into arch/issue-595
ScriptedAlchemy Sep 5, 2026
f9f4945
feat(events): select declared providers lazily
ScriptedAlchemy Sep 5, 2026
eda787a
fix(events): satisfy preflight lint
ScriptedAlchemy Sep 5, 2026
eb2d00f
feat(events): abort stalled preflight gates
ScriptedAlchemy Sep 5, 2026
c01c8c7
Merge remote-tracking branch 'origin/main' into arch/issue-595
ScriptedAlchemy Sep 5, 2026
9a951cb
feat(events): emit cheap preflight hook shells
ScriptedAlchemy Sep 5, 2026
7cf375e
Merge remote-tracking branch 'origin/main' into arch/issue-595
ScriptedAlchemy Sep 5, 2026
7aa77c7
fix(events): defer gated route executors
ScriptedAlchemy Sep 5, 2026
5a8fe3d
fix(events): wire trace observer and execution proof
ScriptedAlchemy Sep 5, 2026
749660d
docs(events): clarify emitted trace phases
ScriptedAlchemy Sep 5, 2026
62476f2
docs(changeset): reference pull request 618
ScriptedAlchemy Sep 5, 2026
453b54d
Merge remote-tracking branch 'origin/main' into arch/issue-595
ScriptedAlchemy Sep 5, 2026
56586aa
refactor(events): simplify preflight implementation
ScriptedAlchemy Sep 5, 2026
94deab9
refactor(events): remove trivial preflight accessor and redundant wra…
ScriptedAlchemy Sep 5, 2026
e57a850
fix(events): scope deadlines to preflight
ScriptedAlchemy Sep 5, 2026
c1a299c
Merge remote-tracking branch 'origin/arch/issue-595' into arch/issue-595
ScriptedAlchemy Sep 5, 2026
4cd6f87
fix(events): forward hook termination to executor
ScriptedAlchemy Sep 5, 2026
9c089d8
fix(events): preserve deferred canonical identity
ScriptedAlchemy Sep 5, 2026
ce16f1f
fix(events): relay observations through IPC
ScriptedAlchemy Sep 5, 2026
3311f04
test(events): follow canonical input naming
ScriptedAlchemy Sep 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/595-event-preflight-gates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Allow an event route under `src/events/**` to declare a `preflight` gate — `export { default as preflight } from './<name>.js'`, a sync or async function that receives the frozen `{ canonical, host, signal, terminal }` context and returns `'execute'`, `{ outcome: 'continue' }`, or `{ outcome: 'deny', reason }` — which the generated hook entry runs on the canonical event before the rendered route runtime, React, or any application provider loads. `inspect`, `validate`, `build`, and `dev` report `AB4840` when `preflight` is declared inline, exported more than once, re-exported from a bare package or under a binding other than `default`, unresolvable, cyclic, or not a function, naming the route module and, once a re-export was found, its specifier. Allow an executed event route to declare the provider keys it requires (`config.providers: ['<key>', …]`) so only that subset resolves, in the existing deterministic key/source order with `processLifetime` seeded first; a route without a declaration still resolves every conventional provider, `[]` mounts `processLifetime` alone, and `AB4841` reports a malformed declaration, a duplicate key, the reserved `processLifetime`, or a key that matches no discovered `src/providers/*` module — unknown keys list the project's provider keys. Export `EventPreflight`, `EventPreflightContext`, `EventPreflightResult`, `validateEventPreflightResult`, and `eventFamilyAllowsPreflightDeny` from `agent-bundle`, `agent-bundle/api`, and `agent-bundle/routes`. Export the payload-free `EventTraceEvent` union, `createEventTracer`, and `installEventTraceObserver` for developer tooling. (#618)
54 changes: 53 additions & 1 deletion docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ even when no error diagnostic was reported.
| `AB4765`–`AB4766` | Artifact-hosted routed CLI: a target without the `cli` capability omits `bin/<name>.mjs`; a host-emitted file collides with it (see below). |
| `AB477x` | MCP App view compilation (`AB4770`: compile error with file, line, column and the bundler message; `AB4771`: compile warning; `AB4772`: emitted-size advisory; see below). |
| `AB490x`/`AB492x` | Conventional host components (#100 stage 2): rules `src/rules/*.mdc` (`AB4900`–`AB4908`) and commands `src/commands/*.md` (`AB4920`–`AB4928`), including per-host feature-set enforcement (`AB4907`/`AB4908`, `AB4927`/`AB4928`); see below. |
| `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), generated route declarations outside the TypeScript program (`AB4834`), route render budgets (`AB4835`), tool task support (`AB4836`), a route module that value-imports a compiler-carrying framework entry (`AB4837`), a CLI route `inputSchema` reference the static resolver cannot follow (`AB4838`) or that cycles (`AB4839`), and provider conventions (see below). |
| `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), generated route declarations outside the TypeScript program (`AB4834`), route render budgets (`AB4835`), tool task support (`AB4836`), a route module that value-imports a compiler-carrying framework entry (`AB4837`), a CLI route `inputSchema` reference the static resolver cannot follow (`AB4838`) or that cycles (`AB4839`), an event route's `preflight` gate export (`AB4840`), an event route's declared provider keys (`AB4841`), and provider conventions (see below). |
| `AB5000` | General CLI and adapter failures (see below). |
| `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6005`: an emitted JavaScript module — a host-pack module or a package build `dist` bundle (`dist/bin/*.js`, the Flight workers, the `lib` entry), prebuilt payloads excepted — has an import that is neither a Node built-in nor a relative or `file:` specifier resolving to a listed regular file inside its tree, or a non-literal dynamic import; a `dist` finding names `dist/<path>`; `AB6011`/`AB6012`: a target's required pinned-schema document is missing or invalid; `AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree; `AB6034`: emitted Skill Markdown has no instruction body; `AB6035`–`AB6038`: Agent Plugins portable validation, see below). |
| `AB6200`–`AB6202` | Workbench artifact inspection over published epochs: `AB6200` the epoch does not validate or its provenance is inconsistent, `AB6201` an epoch reference could not be released, `AB6202` unsafe runtime metadata (see below). |
Expand Down Expand Up @@ -1193,6 +1193,56 @@ graphs whose schemas are all inline keep their recorded digests.
Workbench route detail shows a route's contract origin and the other routes
sharing it.

An event route (`src/events/<family>/*`) may add a **preflight gate** (#595):
a named `preflight` export the generated hook entry runs after envelope
decoding, host validation, and canonical event construction, and before any
of the rendered route runtime — React, the RSC renderer, layouts, providers,
state, notices — is loaded. The gate is sync or async, receives a frozen
context of the `canonical` identity and payload the route would receive, the
compiled host identity and native event name, the request `signal` owned by
the hook deadline, and translated `terminal` capability metadata (never
`native`, state, notices, lineage, providers, or
the request context), and returns exactly one of `'execute'` (load the route
runtime, resolve its declared providers, render), `{ outcome: 'continue' }`
(pass through with no host decision), or `{ outcome: 'deny', reason }` (a
denial projected through the family's canonical outcome rules;
observation-only families cannot deny). `undefined`, an unknown outcome, an
extra field, or an empty reason fails closed at hook time. A gate is only
cheap when the compiler can bundle it on its own, so exactly one authoring
form is accepted: a single `export { default as preflight } from './<name>.js'`
in the route module, whose relative target (a `.js` specifier resolves to the
`.ts`/`.tsx` source, as route imports do) is a readable module whose default
export is a function — followed, like a route's default re-export, through an
acyclic chain of relative default re-exports. The compiler records that module
on the route's own graph node (`preflight` on the compiled route, part of the
graph digest) and keeps it out of route discovery, so
`src/events/tool/before.preflight.ts` beside `before.tsx` is application code
the route names, never a second event route. A `preflight` declared inline in
the route module (`export const preflight = …`, `export function preflight`)
is rejected too: evaluating the route module evaluates its rendering and
provider imports, the very cost the gate exists to avoid. Every rejected form
is `AB4840`, once per route on the route module; the route compiles without a
gate beside the error, and because the diagnostic is an error the build fails
instead of silently taking the expensive path.

Provider laziness is declaration-driven (#595). Preflight materializes no
application providers. An executed event route with no provider declaration
resolves every conventional provider, as before; a route that declares the
provider keys it requires — `config.providers: ['<key>', …]`, string literals
inside the static config grammar — loads and resolves only that subset, still
once per request, sequentially in the deterministic key-then-source order
(never declaration order), fail-closed, with the framework-owned
`processLifetime` seeded first. `[]` is a valid declaration that mounts
`processLifetime` alone. Keys are the camel-cased `src/providers/<name>.*`
stems the graph derives (`retry-policy.ts` is `retryPolicy`), the same keys
the generated `AgentBundleProviders` declares; `processLifetime` is not one of
them and must not be declared. The declaration is judged when the route graph
compiles: a declaration that is not an array of string literals, a key listed
twice, the reserved `processLifetime`, or a key naming no discovered provider
module is `AB4841`, once per route with every defect in one message; a
declaration with any defect selects nothing, so the build fails rather than
resolving a provider set the author did not write.

| Code | Severity | Trigger |
| --- | --- | --- |
| `AB4800` | error | An MCP server has both discovered route modules under `src/mcp/<id>/` and an existing entry claim (the conventional `src/mcp/<id>.ts` module, or a declared `entry`/`command`/`url`) without an explicit `routes.servers.<id>` mode. |
Expand Down Expand Up @@ -1235,6 +1285,8 @@ sharing it.
| `AB4837` | error | A route module of any kind except an App — a `src/cli/**` command, a `src/scripts/**` script, a tool, resource, or prompt route of a generated server, an event route — a layout, or a provider, or a module one of them reaches through relative value imports, imports `agent-bundle`, `agent-bundle/api`, `agent-bundle/config`, `agent-bundle/eval`, `agent-bundle/rstest`, `agent-bundle/test`, or `agent-bundle/test/browser` as a value (a static import whose binding is read at run time, `import 'agent-bundle/api'`, `import('agent-bundle/api')` with a literal specifier, or a non-type re-export). Those entries carry the compiler, and the generated executable is self-contained (#387): the bundler would inline the compiler and fail on the framework's runtime-relative module references (`Module not found: Can't resolve '../events'`), or the artifact validator would reject the inlined compiler's non-literal dynamic imports with `AB6005` — either way naming a generated file instead of the route (#558). Judged statically when the route graph compiles, so `inspect`, `validate`, `build`, and `dev` all report it, once per module, naming the route and the helper the import lives in. `import type`, `type`-qualified specifiers, and imports used only in type positions are elided by the bundler and never reported; routes of a server that is not generated (`custom`/`command`/`remote`, or an `AB4800` conflict) or of a CLI that is not generated (`conventional`, or an `AB4801` conflict) are never bundled, so they are not judged; likewise a layout that no bundled rendered route composes through (a worker imports only the layouts its routes reach: the tool, resource, and prompt routes of a generated server, the rendered `.tsx` commands of a generated CLI, and rendered `.tsx` scripts), and a provider in a project whose only executables are plain `.ts` scripts, which are bundled from their own source and mount none. Spawn the framework instead of importing it: serve an MCP App from a routed command with `spawnServeApp` from `agent-bundle/serve-app-command`, which runs `agent-bundle serve-app` as a child process; keep other framework calls in host processes (`package.json` scripts, a hand-written `.mjs` run from the checkout). The bundle-safe entries stay allowed: `agent-bundle/app` (the browser MCP App client, a leaf with no Zod, Node, or compiler import), `agent-bundle/routes`, `agent-bundle/launch-env`, `agent-bundle/meta`, `agent-bundle/mcp-apps`, `agent-bundle/mcp-entry`, `agent-bundle/cli-entry`, `agent-bundle/terminal-capability`, and `agent-bundle/serve-app-command`. |
| `AB4838` | error | A CLI route's `inputSchema` references a binding the static resolver cannot follow. The message is `CLI route <path> inputSchema: <chain> <reason>.` — the chain is the reference path from `inputSchema`, each step `<binding>`, or `<binding> (<module>)` when it crosses into another module (`inputSchema -> statusInputSchema (src/lib/protocol-schemas.ts) -> requestStatusSchema -> requestStatuses`), and the reason names the boundary: a specifier that `is not a relative module path`, one that `resolves outside the project` or `does not resolve to a module inside the project` (missing or unreadable), a target module that does not declare a top-level `export const <name>`, a binding that is not a top-level `const` (`let`/`var`, destructuring, a function, a class, a default or namespace import — the message says what it is), an identifier that `is neither a top-level const in this module nor a named import from a relative module`, or a dynamic initializer — one that is neither a method chain, an object or array literal, nor a static literal (`whose initializer is a call expression`, `a function expression`, `a template literal with substitutions`). Reported on the route module; the recovery names the supported forms — relative imports inside the project, `export const`, alias chains — then says to inspect again. Only CLI routes raise it, because only there the static contract is load-bearing: an MCP, script, or event route whose schema the resolver cannot follow compiles without a static contract, as an out-of-grammar inline schema does, and the runtime derives its MCP JSON Schema from the real zod object. A reference that resolves but whose schema leaves the grammar is `AB4814`. |
| `AB4839` | error | A CLI route's `inputSchema` reference chain is cyclic — `a` → `b` → `a`, within one module or across several: every visited `<module>#<binding>` is recorded and revisiting one stops the walk. The message is `CLI route <path> inputSchema: <chain> is a reference cycle.` and prints the cycle; it is reported on the route module, with the same recovery and the same CLI-only rule as `AB4838`. |
| `AB4840` | error | An event route's `preflight` gate (#595) is not the one physically cheap form the compiler can bundle on its own. Rejected: `preflight` declared inline in the route module (`export const preflight = …`, `export function preflight`) or exported more than once; re-exported under a binding other than `default` (`export { gate as preflight } from './gate.js'`, `export { preflight } from './gate.js'`); re-exported from a non-relative specifier (a bare package such as `'@scope/gate'`); a relative target that is missing, unreadable, or part of a re-export cycle; a target default export that cannot be followed through an acyclic chain of relative default re-exports; or a target default export that is not a function the scan can see. The message names the route module and, once a re-export was found, its specifier. Judged statically when the route graph compiles, so `inspect`, `validate`, `build`, and `dev` all report it, once per route with the route module as `sourcePath`; the route compiles without a gate beside the error, and the build fails rather than silently taking the expensive rendered path. Write exactly `export { default as preflight } from './<name>.js'` in the route module, and make that module default-export one sync or async function receiving `{ canonical, host, signal, terminal }` and returning `'execute'`, `{ outcome: 'continue' }`, or `{ outcome: 'deny', reason }`. |
| `AB4841` | error | An event route's static required-provider declaration (#595) does not select a known set of conventional providers: `config.providers` is not an array of provider-key strings; a key is declared more than once; a key is the reserved `processLifetime`; or a key matches no conventional provider the route graph discovered under `src/providers/`. The message names the route and every offending key. Declare each key exactly once, spelled as the camel-cased stem of its `src/providers/<name>.*` module, drop `processLifetime`, declare `[]` to mount `processLifetime` alone, or omit `config.providers` to preserve the all-provider compatibility default. |
| `AB4940` | error | A conventional provider module has no default export or its default export is not a function. Default-export a factory receiving `{ invocation, plugin, signal }`. |
| `AB4941` | error | Two provider filenames derive the same camel-cased provider key. Rename one file so every provider key is unique. |
| `AB4942` | error | A provider filename derives the reserved `processLifetime` key. Rename the file so its camel-cased key does not collide with the framework-owned provider. |
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"test:watch": "rstest --config rstest.config.ts --watch",
"lint": "rslint .",
"bench:hook-cold-start": "node scripts/measure-hook-cold-start.mjs",
"bench:preflight-cold-start": "node scripts/measure-preflight-cold-start.mjs",
"typecheck": "node scripts/check-dist-fresh.mjs && tsc --noEmit && tsc --project packages/workbench/tsconfig.json && tsc --project packages/create-agent-bundle/tsconfig.json && tsc --project packages/rsc-markdown-stream/tsconfig.json && pnpm --filter @agent-bundle/docs typecheck",
"check": "pnpm build && pnpm test:unit && pnpm test:route-unit && pnpm test:projection && pnpm test:integration:run && pnpm lint && pnpm typecheck",
"check:local-ci": "node scripts/local-ci.mjs",
Expand Down
Loading
Loading