Skip to content

Commit 9d3ad4b

Browse files
fix(adapters): keep the notice delivery shape local to the compiler and gate the inbox route on its own advertisement
Codex P1/P2 on #412: - TargetAdapter.noticeDelivery and every build-side signature now use a local NoticeDeliveryAdvertisement (adapters/notice-delivery.ts) so no public declaration of agent-bundle resolves through the optional @agent-bundle/runtime peer; a test asserts mutual assignability with the runtime's AgentNoticeDeliveryAdvertisement and route list. - The agent-bundle://notices/inbox resource is registered in the server and mounted in its Flight worker only where the host advertises mcp-inbox; mcp-resource-updated additionally requires the inbox. The worker still mounts the ledger so routes can publish, and the reserved name stays reserved.
1 parent 7fc7d6c commit 9d3ad4b

13 files changed

Lines changed: 213 additions & 98 deletions

‎.changeset/notice-delivery-adapter-surface.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"agent-bundle": patch
33
---
44

5-
Expose each host's #99 notice delivery advertisement through the adapter API and consume it when generating MCP entries. `TargetAdapter` gains `noticeDelivery` (typed as the runtime's `AgentNoticeDeliveryAdvertisement`), `TargetRegistry` gains `noticeDelivery(target)`, and `adapters/capability-state` gains `noticeDeliveryAdvertisementFrom()` plus `intersectNoticeDeliveryAdvertisements()`. The built-in `claude`, `codex`, `cursor`, and `portable` adapters read the advertisement from their pinned capability tables and the unified `plugin` adapter advertises the three-host intersection; the registry re-validates JavaScript adapter declarations and fails closed on unknown route states or undated unavailability. `build` and `inspect --bundler` now pass the target's advertisement to the generated route MCP entry, which wires `resources/subscribe` and `notifications/resources/updated` for the notice inbox only where the host advertises `mcp-resource-updated` (in addition to requiring workspace-durable state); a target with no advertisement wires no cross-request route.
5+
Expose each host's #99 notice delivery advertisement through the adapter API and select cross-request notice routes from it when generating MCP entries. `TargetAdapter` gains `noticeDelivery` (a local `NoticeDeliveryAdvertisement` shape, structurally identical to the runtime's `AgentNoticeDeliveryAdvertisement` so public declarations never resolve through the optional `@agent-bundle/runtime` peer), `TargetRegistry` gains `noticeDelivery(target)`, and `adapters/capability-state` gains `noticeDeliveryAdvertisementFrom()` plus `intersectNoticeDeliveryAdvertisements()`. The built-in `claude`, `codex`, `cursor`, and `portable` adapters read the advertisement from their pinned capability tables and the unified `plugin` adapter advertises the three-host intersection; the registry re-validates JavaScript adapter declarations and fails closed on unknown route states or undated unavailability. `build` and `inspect --bundler` pass the target's advertisement to the generated route MCP entry and its Flight worker: the `agent-bundle://notices/inbox` resource is registered and mounted only where the host advertises `mcp-inbox`, and `resources/subscribe` plus `notifications/resources/updated` are wired only where the host additionally advertises `mcp-resource-updated` and the state is workspace-durable. A target with no advertisement wires no cross-request route; all built-in hosts advertise `mcp-inbox`, so their artifacts are unchanged.

‎docs/entry-conventions.md‎

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -126,20 +126,27 @@ directory. Routed CLI bins and rendered scripts use
126126
in generated mounting v1 (`authorized`); recipient/principal matching remains
127127
enforced by the ledger, while application authorization policy is deferred.
128128

129-
For workspace-durable state only, and only when the target host advertises the
130-
`mcp-resource-updated` route, the generated MCP server process also opens its
131-
own SQLite handle on the notice ledger (`createGeneratedNoticeRuntime` over
132-
the same anchor) and advertises `resources.subscribe`: a client that
133-
subscribes to `agent-bundle://notices/inbox` receives one
134-
`notifications/resources/updated` after a render leaves it newly eligible
135-
pending notices, recorded on the ledger as an availability receipt. The
136-
advertisement is the host's pinned `noticeDelivery` table, exposed as
137-
`TargetAdapter.noticeDelivery` / `TargetRegistry.noticeDelivery(target)` and
138-
typed for `selectNoticeDeliveryRoutes`; the unified `plugin` target advertises
139-
the intersection of its three hosts. Volatile lifetimes keep the store in the
140-
worker's heap, and a host whose table marks the route unavailable has no
141-
consumer for the signal, so those servers register no subscription handlers
142-
and advertise no subscribe capability.
129+
Each cross-request notice route is selected from the target host's pinned
130+
`noticeDelivery` table, exposed as `TargetAdapter.noticeDelivery` /
131+
`TargetRegistry.noticeDelivery(target)` (a local `NoticeDeliveryAdvertisement`
132+
shape, structurally identical to the runtime's so it types for
133+
`selectNoticeDeliveryRoutes` without making the optional `@agent-bundle/runtime`
134+
peer a declaration dependency); the unified `plugin` target advertises the
135+
intersection of its three hosts, and a target with no advertisement wires no
136+
cross-request route. The `agent-bundle://notices/inbox` resource is registered
137+
in the server and mounted in its worker only for stateful projects whose host
138+
advertises `mcp-inbox` (the worker still mounts the ledger so routes can
139+
publish; only the unadvertised read surface is withheld, and the reserved name
140+
stays reserved). For workspace-durable state only, and only when the host also
141+
advertises `mcp-resource-updated`, the server process opens its own SQLite
142+
handle on the notice ledger (`createGeneratedNoticeRuntime` over the same
143+
anchor) and advertises `resources.subscribe`: a client that subscribes to the
144+
inbox receives one `notifications/resources/updated` after a render leaves it
145+
newly eligible pending notices, recorded on the ledger as an availability
146+
receipt. Volatile lifetimes keep the store in the worker's heap, and a host
147+
whose table marks the route unavailable has no consumer for the signal, so
148+
those servers register no subscription handlers and advertise no subscribe
149+
capability.
143150

144151
#### State mutation budgets
145152

‎packages/agent-bundle/src/adapters/capability-state.ts‎

Lines changed: 14 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import type {
2-
AgentNoticeDeliveryAdvertisement,
3-
AgentNoticeDeliveryRoute,
4-
AgentNoticeDeliveryRouteState,
5-
} from '@agent-bundle/runtime/notices';
6-
71
import { stableJson } from '../core/digest.ts';
82
import { CapabilityStateError, unknownCapabilityStateError } from '../core/capabilities.ts';
93
import type { CapabilityEvidence, CapabilityState } from '../core/capabilities.ts';
4+
import {
5+
NOTICE_DELIVERY_ROUTES,
6+
type NoticeDeliveryAdvertisement,
7+
type NoticeDeliveryRoute,
8+
type NoticeDeliveryRouteState,
9+
} from './notice-delivery.ts';
1010
import type { TargetAdapterMetadata } from './types.ts';
1111

1212
/** Builds immutable evidence from a target's pinned capability-table metadata. */
@@ -72,25 +72,6 @@ export interface NoticeDeliveryCapabilityTableEntry {
7272
readonly state: string;
7373
}
7474

75-
/**
76-
* The #99 route taxonomy, spelled locally because `@agent-bundle/runtime` is
77-
* an optional peer of the compiler core and may only be imported for types
78-
* here. The `satisfies` clause plus the exhaustiveness check below fail the
79-
* build if the runtime's vocabulary ever diverges from this list.
80-
*/
81-
const noticeDeliveryRoutes = Object.freeze([
82-
'current-response',
83-
'next-event',
84-
'mcp-inbox',
85-
'mcp-resource-updated',
86-
'directed-push',
87-
'host-toast',
88-
] as const satisfies readonly AgentNoticeDeliveryRoute[]);
89-
90-
type MissingNoticeDeliveryRoute = Exclude<AgentNoticeDeliveryRoute, (typeof noticeDeliveryRoutes)[number]>;
91-
const noticeDeliveryRoutesAreExhaustive: MissingNoticeDeliveryRoute extends never ? true : never = true;
92-
void noticeDeliveryRoutesAreExhaustive;
93-
9475
/**
9576
* Converts a pinned host table's `noticeDelivery` rows into the typed
9677
* advertisement the notice router consumes (#99 stage 4). Every route in the
@@ -101,8 +82,8 @@ void noticeDeliveryRoutesAreExhaustive;
10182
export const noticeDeliveryAdvertisementFrom = (
10283
target: string,
10384
rows: Readonly<Record<string, NoticeDeliveryCapabilityTableEntry>>,
104-
): AgentNoticeDeliveryAdvertisement => {
105-
const entries = noticeDeliveryRoutes.map((route): [AgentNoticeDeliveryRoute, AgentNoticeDeliveryRouteState] => {
85+
): NoticeDeliveryAdvertisement => {
86+
const entries = NOTICE_DELIVERY_ROUTES.map((route): [NoticeDeliveryRoute, NoticeDeliveryRouteState] => {
10687
const row = rows[route];
10788
if (row === undefined) {
10889
throw new CapabilityStateError(`The pinned ${target} table advertises no notice delivery route ${route}.`);
@@ -123,7 +104,7 @@ export const noticeDeliveryAdvertisementFrom = (
123104
);
124105
}
125106
});
126-
return Object.freeze(Object.fromEntries(entries)) as AgentNoticeDeliveryAdvertisement;
107+
return Object.freeze(Object.fromEntries(entries)) as NoticeDeliveryAdvertisement;
127108
};
128109

129110
/**
@@ -132,10 +113,10 @@ export const noticeDeliveryAdvertisementFrom = (
132113
* hosts that do not are kept so the composite stays as honest as its parts.
133114
*/
134115
export const intersectNoticeDeliveryAdvertisements = (
135-
left: AgentNoticeDeliveryAdvertisement,
136-
right: AgentNoticeDeliveryAdvertisement,
137-
): AgentNoticeDeliveryAdvertisement => Object.freeze(Object.fromEntries(
138-
noticeDeliveryRoutes.map((route): [AgentNoticeDeliveryRoute, AgentNoticeDeliveryRouteState] => {
116+
left: NoticeDeliveryAdvertisement,
117+
right: NoticeDeliveryAdvertisement,
118+
): NoticeDeliveryAdvertisement => Object.freeze(Object.fromEntries(
119+
NOTICE_DELIVERY_ROUTES.map((route): [NoticeDeliveryRoute, NoticeDeliveryRouteState] => {
139120
const reasons = [left[route], right[route]]
140121
.flatMap((entry) => (entry.state === 'unavailable' ? [entry.reason] : []));
141122
return reasons.length === 0
@@ -145,7 +126,7 @@ export const intersectNoticeDeliveryAdvertisements = (
145126
state: 'unavailable',
146127
})];
147128
}),
148-
)) as AgentNoticeDeliveryAdvertisement;
129+
)) as NoticeDeliveryAdvertisement;
149130

150131
export const capabilityStateFromSupport = (
151132
supported: boolean,
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* The #99 notice delivery route taxonomy, spelled in the compiler package so
3+
* that public declarations such as `TargetAdapter` never resolve through
4+
* `@agent-bundle/runtime`, which is an optional peer of `agent-bundle`. The
5+
* shape is structurally identical to the runtime's
6+
* `AgentNoticeDeliveryAdvertisement`; `adapter-capability-states.test.ts`
7+
* asserts the two are mutually assignable so a vocabulary change on either
8+
* side fails the build.
9+
*/
10+
export const NOTICE_DELIVERY_ROUTES = Object.freeze([
11+
'current-response',
12+
'next-event',
13+
'mcp-inbox',
14+
'mcp-resource-updated',
15+
'directed-push',
16+
'host-toast',
17+
] as const);
18+
19+
export type NoticeDeliveryRoute = (typeof NOTICE_DELIVERY_ROUTES)[number];
20+
21+
export type NoticeDeliveryRouteState =
22+
| { readonly state: 'supported' }
23+
| { readonly reason: string; readonly state: 'unavailable' };
24+
25+
/** A host's honest, dated advertisement of which notice delivery routes it can carry. */
26+
export type NoticeDeliveryAdvertisement = Readonly<Record<NoticeDeliveryRoute, NoticeDeliveryRouteState>>;

‎packages/agent-bundle/src/adapters/registry.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import type {
3333
} from './types.ts';
3434
import type { TargetMcpRuntimeContract } from '../services/mcp-runtime.ts';
3535
import { deepFreeze } from '../core/freeze.ts';
36-
import type { AgentNoticeDeliveryAdvertisement } from '@agent-bundle/runtime/notices';
36+
import type { NoticeDeliveryAdvertisement } from './notice-delivery.ts';
3737

3838

3939
const sha256Pattern = /^[0-9a-f]{64}$/;
@@ -412,7 +412,7 @@ const snapshotMcpRuntime = (adapter: TargetAdapter): TargetMcpRuntimeContract |
412412
* boundary so a JavaScript adapter cannot smuggle an unknown route state into
413413
* the generated MCP entry's route selection.
414414
*/
415-
const snapshotNoticeDelivery = (adapter: TargetAdapter): AgentNoticeDeliveryAdvertisement | undefined => {
415+
const snapshotNoticeDelivery = (adapter: TargetAdapter): NoticeDeliveryAdvertisement | undefined => {
416416
const declared = adapter.noticeDelivery;
417417
if (declared === undefined) return undefined;
418418
const rows = record(declared);
@@ -478,7 +478,7 @@ export class TargetRegistry implements NormalizationTargetRegistry {
478478
readonly #metadata = new Map<string, TargetAdapterMetadata>();
479479
readonly #mcpRuntimes = new Map<string, TargetMcpRuntimeContract>();
480480
readonly #nativeHookSources = new Map<string, NativeHookSource>();
481-
readonly #noticeDeliveries = new Map<string, AgentNoticeDeliveryAdvertisement>();
481+
readonly #noticeDeliveries = new Map<string, NoticeDeliveryAdvertisement>();
482482
readonly #outputStylesSources = new Map<string, OutputStylesSource>();
483483
readonly #workflowsSources = new Map<string, WorkflowsSource>();
484484

@@ -592,7 +592,7 @@ export class TargetRegistry implements NormalizationTargetRegistry {
592592
}
593593

594594
/** The validated notice delivery advertisement, or undefined for a host that declares none. */
595-
noticeDelivery(name: string): AgentNoticeDeliveryAdvertisement | undefined {
595+
noticeDelivery(name: string): NoticeDeliveryAdvertisement | undefined {
596596
if (!this.#adapters.has(name)) {
597597
throw new Error(`Unknown target adapter "${name}".`);
598598
}

‎packages/agent-bundle/src/adapters/types.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ import { Ajv } from 'ajv/dist/ajv.js';
22
import { Ajv2020 } from 'ajv/dist/2020.js';
33
import addFormats from 'ajv-formats';
44

5-
import type { AgentNoticeDeliveryAdvertisement } from '@agent-bundle/runtime/notices';
6-
75
import type { CapabilityState } from '../core/capabilities.ts';
86
import type { Diagnostic } from '../core/diagnostics.ts';
97
import { stableJson } from '../core/digest.ts';
@@ -16,11 +14,17 @@ import {
1614
type NormalizedPlugin,
1715
} from '../core/types.ts';
1816
import type { TargetHookContract, TargetHookEntry } from './hook-contract.ts';
17+
import type { NoticeDeliveryAdvertisement } from './notice-delivery.ts';
1918
import type { TargetMcpRuntimeContract } from '../services/mcp-runtime.ts';
2019
import { deepFreeze } from '../core/freeze.ts';
2120

2221

2322
export type { TargetHookEntry, TargetHookWrapper } from './hook-contract.ts';
23+
export type {
24+
NoticeDeliveryAdvertisement,
25+
NoticeDeliveryRoute,
26+
NoticeDeliveryRouteState,
27+
} from './notice-delivery.ts';
2428

2529
export interface TargetArtifactWrite {
2630
readonly content: string;
@@ -516,7 +520,7 @@ export interface TargetAdapter {
516520
* cross-request delivery routes from this; an adapter that declares none
517521
* advertises no cross-request route and its artifacts wire none.
518522
*/
519-
readonly noticeDelivery?: AgentNoticeDeliveryAdvertisement;
523+
readonly noticeDelivery?: NoticeDeliveryAdvertisement;
520524
binSource?(config: Readonly<AgentBundleConfig>): string | undefined;
521525
nativeHookSource?(config: Readonly<AgentBundleConfig>): string | undefined;
522526
outputStylesSource?(config: Readonly<AgentBundleConfig>): string | undefined;

‎packages/agent-bundle/src/api.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,9 @@ export type { HookListOptions, HookSimulationOptions } from './services/hook-ser
191191
export { createDefaultRegistry, TargetRegistry } from './adapters/registry.ts';
192192
export { CapabilityStateError, capabilityStateNames, isCapabilityState } from './core/capabilities.ts';
193193
export type {
194+
NoticeDeliveryAdvertisement,
195+
NoticeDeliveryRoute,
196+
NoticeDeliveryRouteState,
194197
TargetAdapter,
195198
TargetAdapterMetadata,
196199
TargetArtifactCopy,

‎packages/agent-bundle/src/build/build.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,17 +377,18 @@ export const build = async (options: BuildOptions): Promise<BuildResult> => {
377377
},
378378
)),
379379
);
380+
const noticeDelivery = options.registry.noticeDelivery(target.name);
380381
compiledHooks.push(...(await compileHooks(target.hookEntries, {
381382
artifactEpoch: options.projectContext.revision,
382383
cwd: options.projectRoot,
383384
meta,
385+
...(noticeDelivery === undefined ? {} : { noticeDelivery }),
384386
outDir: target.root,
385387
plugin: { name: options.model.metadata.name, version: options.model.metadata.version },
386388
providers: options.model.providers ?? [],
387389
...(options.model.state === undefined ? {} : { state: options.model.state }),
388390
...tools,
389391
})));
390-
const noticeDelivery = options.registry.noticeDelivery(target.name);
391392
compiledMcpEntries.push(...(await compileMcpEntries(options.model.mcpServers, {
392393
apps: targetMcpApps,
393394
artifactEpoch: options.projectContext.revision,

‎packages/agent-bundle/src/build/entries.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@ import { readFile, stat } from 'node:fs/promises';
33
import { basename, dirname, extname, relative, resolve } from 'node:path';
44
import { fileURLToPath } from 'node:url';
55

6-
import type { AgentNoticeDeliveryAdvertisement } from '@agent-bundle/runtime/notices';
7-
6+
import type { NoticeDeliveryAdvertisement } from '../adapters/notice-delivery.ts';
87
import {
98
eventArtifactEpochToken,
109
eventFlightArtifactEpochToken,
@@ -321,7 +320,7 @@ export const compileMcpEntries = async (
321320
readonly eventHooks: readonly NormalizedHook[];
322321
readonly meta: AgentBundleMeta;
323322
/** The target adapter's notice delivery advertisement; absent wires no cross-request route. */
324-
readonly noticeDelivery?: AgentNoticeDeliveryAdvertisement;
323+
readonly noticeDelivery?: NoticeDeliveryAdvertisement;
325324
readonly outDir: string;
326325
readonly plugin: { readonly name: string; readonly version: string };
327326
readonly providers?: readonly CompiledProvider[];
@@ -374,6 +373,7 @@ export const compileMcpEntries = async (
374373
: generatedRouteFlightWorkerSource({
375374
artifactEpoch: generatedRouteArtifactEpoch(options.plugin),
376375
eventRoutes: entry.id === eventHostId ? options.eventHooks : [],
376+
...(options.noticeDelivery === undefined ? {} : { noticeDelivery: options.noticeDelivery }),
377377
providers: options.providers ?? [],
378378
routes: server.generatedRoutes,
379379
serverName: server.name,
@@ -516,6 +516,7 @@ export const compileHooks = async (
516516
readonly artifactEpoch: string;
517517
readonly cwd: string;
518518
readonly meta: AgentBundleMeta;
519+
readonly noticeDelivery?: NoticeDeliveryAdvertisement;
519520
readonly outDir: string;
520521
readonly plugin: { readonly name: string; readonly version: string };
521522
readonly providers?: readonly CompiledProvider[];
@@ -550,6 +551,7 @@ export const compileHooks = async (
550551
virtualSource: generatedRouteFlightWorkerSource({
551552
artifactEpoch: workerArtifactEpoch,
552553
eventRoutes: standaloneEventRoutes,
554+
...(options.noticeDelivery === undefined ? {} : { noticeDelivery: options.noticeDelivery }),
553555
providers: options.providers ?? [],
554556
routes: [],
555557
serverName: 'hooks',

0 commit comments

Comments
 (0)