Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/notice-delivery-adapter-surface.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Gate the generated MCP server's notice routes on the target host's delivery advertisement. `TargetAdapter` gains the optional `noticeDelivery` field (typed by the new `NoticeDeliveryAdvertisement`, `NoticeDeliveryRoute`, and `NoticeDeliveryRouteState` exports, which resolve without the optional `@agent-bundle/runtime` peer), and `TargetRegistry` gains `noticeDelivery(target)`. The built-in `claude`, `codex`, `cursor`, and `portable` adapters advertise from their pinned capability tables and the `plugin` adapter advertises the three-host intersection; a JavaScript adapter declaring an unknown route state or an `unavailable` route whose reason carries no ISO survey date (`YYYY-MM-DD`) is rejected at registration with a `CapabilityStateError` (a thrown registration error, not a build diagnostic; no diagnostic codes are added or changed). `agent-bundle build` and `agent-bundle inspect --bundler` register the `agent-bundle://notices/inbox` resource only for hosts advertising `mcp-inbox`, and wire `resources/subscribe` plus `notifications/resources/updated` only where the host additionally advertises `mcp-resource-updated` and the state lifetime is workspace-durable. Built-in hosts all advertise `mcp-inbox`, so their artifacts are unchanged. (#412)
29 changes: 21 additions & 8 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,14 +160,27 @@ own `bin/` directory instead, like the MCP worker. Notice authorization is delib
in generated mounting v1 (`authorized`); recipient/principal matching remains
enforced by the ledger, while application authorization policy is deferred.

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

#### State mutation budgets

Expand Down
75 changes: 75 additions & 0 deletions packages/agent-bundle/src/adapters/capability-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import { stableJson } from '../core/digest.ts';
import { CapabilityStateError, unknownCapabilityStateError } from '../core/capabilities.ts';
import type { CapabilityEvidence, CapabilityState } from '../core/capabilities.ts';
import { featureCapabilityName } from '../core/components.ts';
import {
NOTICE_DELIVERY_ROUTES,
type NoticeDeliveryAdvertisement,
type NoticeDeliveryRoute,
type NoticeDeliveryRouteState,
} from './notice-delivery.ts';
import type { TargetAdapterMetadata } from './types.ts';

export { featureCapabilityName } from '../core/components.ts';
Expand Down Expand Up @@ -80,6 +86,19 @@ export interface CapabilityTableRow {
readonly state: string;
}

export interface NoticeDeliveryCapabilityTableEntry {
readonly reason?: string;
/** JSON imports widen literals; unknown table states fail closed below. */
readonly state: string;
}

/**
* An `unavailable` notice route must say when the host was surveyed: the
* reason carries an ISO calendar date (`YYYY-MM-DD`), as every pinned table
* does, so the advertisement's evidence can be re-checked against a later pin.
*/
const DATED_REASON = /(?<!\d)\d{4}-\d{2}-\d{2}(?!\d)/u;

/**
* Converts one pinned table row into the shared capability-state namespace.
* `supported` and `degraded` carry the adapter's pinned evidence identity;
Expand Down Expand Up @@ -134,6 +153,62 @@ export const frontmatterFeatureCapabilitiesFrom = (
));
};

/**
* Converts a pinned host table's `noticeDelivery` rows into the typed
* advertisement the notice router consumes (#99 stage 4). Every route in the
* taxonomy must be present and `unavailable` rows must carry their dated
* reason; a row the table does not know how to describe fails closed rather
* than becoming a fabricated channel.
*/
export const noticeDeliveryAdvertisementFrom = (
target: string,
rows: Readonly<Record<string, NoticeDeliveryCapabilityTableEntry>>,
): NoticeDeliveryAdvertisement => {
const entries = NOTICE_DELIVERY_ROUTES.map((route): [NoticeDeliveryRoute, NoticeDeliveryRouteState] => {
const row = rows[route];
if (row === undefined) {
throw new CapabilityStateError(`The pinned ${target} table advertises no notice delivery route ${route}.`);
}
switch (row.state) {
case 'supported':
return [route, Object.freeze({ state: 'supported' })];
case 'unavailable':
if (typeof row.reason !== 'string' || !DATED_REASON.test(row.reason)) {
throw new CapabilityStateError(
`The pinned ${target} table marks notice delivery route ${route} unavailable without a dated reason (an ISO date such as 2026-09-02 naming when the host was surveyed).`,
);
}
return [route, Object.freeze({ reason: row.reason, state: 'unavailable' })];
default:
throw new CapabilityStateError(
`Unsupported notice delivery route state ${JSON.stringify(row.state)} for ${route} in the pinned ${target} table.`,
);
}
});
return Object.freeze(Object.fromEntries(entries)) as NoticeDeliveryAdvertisement;
};

/**
* Intersects host advertisements for a composite adapter: a route is
* supported only where every host supports it, and the dated reasons of the
* hosts that do not are kept so the composite stays as honest as its parts.
*/
export const intersectNoticeDeliveryAdvertisements = (
left: NoticeDeliveryAdvertisement,
right: NoticeDeliveryAdvertisement,
): NoticeDeliveryAdvertisement => Object.freeze(Object.fromEntries(
NOTICE_DELIVERY_ROUTES.map((route): [NoticeDeliveryRoute, NoticeDeliveryRouteState] => {
const reasons = [left[route], right[route]]
.flatMap((entry) => (entry.state === 'unavailable' ? [entry.reason] : []));
return reasons.length === 0
? [route, Object.freeze({ state: 'supported' })]
: [route, Object.freeze({
reason: [...new Set(reasons)].sort((first, second) => first.localeCompare(second)).join('; '),
state: 'unavailable',
})];
}),
)) as NoticeDeliveryAdvertisement;

export const capabilityStateFromSupport = (
supported: boolean,
evidence: CapabilityEvidence,
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-bundle/src/adapters/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
eventRouteCapabilitiesFrom,
featureCapabilitiesFrom,
frontmatterFeatureCapabilitiesFrom,
noticeDeliveryAdvertisementFrom,
supportedEventRouteNamesFrom,
cliBinCapability,
supportedCapability,
Expand Down Expand Up @@ -3485,6 +3486,7 @@ export const claudeAdapter: TargetAdapter = Object.freeze({
metadata,
mcpRuntime,
name: claudeName,
noticeDelivery: noticeDeliveryAdvertisementFrom(claudeName, capabilityTable.noticeDelivery),
binSource: (config: Readonly<AgentBundleConfig>) => config.claude?.bin,
nativeHookSource: (config: Readonly<AgentBundleConfig>) => config.claude?.nativeHooks,
outputStylesSource: (config: Readonly<AgentBundleConfig>) => config.claude?.outputStyles,
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-bundle/src/adapters/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
capabilityStateFromSupport,
eventRouteCapabilitiesFrom,
featureCapabilitiesFrom,
noticeDeliveryAdvertisementFrom,
supportedEventRouteNamesFrom,
cliBinCapability,
supportedCapability,
Expand Down Expand Up @@ -1415,6 +1416,7 @@ export const codexAdapter: TargetAdapter = Object.freeze({
metadata,
mcpRuntime,
name: codexName,
noticeDelivery: noticeDeliveryAdvertisementFrom(codexName, capabilityTable.noticeDelivery),
nativeHookSource: (config: Readonly<AgentBundleConfig>) => config.codex?.nativeHooks,
plan: planCodexArtifacts,
});
2 changes: 2 additions & 0 deletions packages/agent-bundle/src/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
cliBinCapability,
featureCapabilitiesFrom,
frontmatterFeatureCapabilitiesFrom,
noticeDeliveryAdvertisementFrom,
supportedEventRouteNamesFrom,
supportedCapability,
type CapabilityTableRow,
Expand Down Expand Up @@ -725,5 +726,6 @@ export const cursorAdapter: TargetAdapter = Object.freeze({
metadata,
mcpRuntime,
name: cursorName,
noticeDelivery: noticeDeliveryAdvertisementFrom(cursorName, capabilityTable.noticeDelivery),
plan: planCursorArtifacts,
});
26 changes: 26 additions & 0 deletions packages/agent-bundle/src/adapters/notice-delivery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* The #99 notice delivery route taxonomy, spelled in the compiler package so
* that public declarations such as `TargetAdapter` never resolve through
* `@agent-bundle/runtime`, which is an optional peer of `agent-bundle`. The
* shape is structurally identical to the runtime's
* `AgentNoticeDeliveryAdvertisement`; `adapter-capability-states.test.ts`
* asserts the two are mutually assignable so a vocabulary change on either
* side fails the build.
*/
export const NOTICE_DELIVERY_ROUTES = Object.freeze([
'current-response',
'next-event',
'mcp-inbox',
'mcp-resource-updated',
'directed-push',
'host-toast',
] as const);

export type NoticeDeliveryRoute = (typeof NOTICE_DELIVERY_ROUTES)[number];

export type NoticeDeliveryRouteState =
| { readonly state: 'supported' }
| { readonly reason: string; readonly state: 'unavailable' };

/** A host's honest, dated advertisement of which notice delivery routes it can carry. */
export type NoticeDeliveryAdvertisement = Readonly<Record<NoticeDeliveryRoute, NoticeDeliveryRouteState>>;
7 changes: 7 additions & 0 deletions packages/agent-bundle/src/adapters/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { createTargetMcpRuntime } from '../services/mcp-runtime.ts';
import {
cliBinCapability,
intersectCapabilityStates,
intersectNoticeDeliveryAdvertisements,
supportedEventRouteNamesFrom,
unavailableCapability,
unionCapabilityStates,
Expand Down Expand Up @@ -1126,6 +1127,12 @@ export const pluginAdapter: TargetAdapter = Object.freeze({
metadata,
mcpRuntime,
name: pluginName,
// A unified bundle's generated MCP entry serves all three hosts, so it may
// only wire the cross-request routes every pinned host advertises.
noticeDelivery: intersectNoticeDeliveryAdvertisements(
intersectNoticeDeliveryAdvertisements(claudeAdapter.noticeDelivery!, codexAdapter.noticeDelivery!),
cursorAdapter.noticeDelivery!,
),
binSource: (config: Readonly<AgentBundleConfig>) => config.claude?.bin,
outputStylesSource: (config: Readonly<AgentBundleConfig>) => config.claude?.outputStyles,
plan,
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-bundle/src/adapters/portable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
capabilityStateFromSupport,
cliBinCapability,
eventRouteCapabilitiesFrom,
noticeDeliveryAdvertisementFrom,
supportedCapability,
featureCapabilitiesFrom,
unavailableCapability,
Expand Down Expand Up @@ -669,5 +670,6 @@ export const portableAdapter: TargetAdapter = Object.freeze({
metadata,
mcpRuntime,
name: portableName,
noticeDelivery: noticeDeliveryAdvertisementFrom(portableName, capabilityTable.noticeDelivery),
plan,
});
47 changes: 46 additions & 1 deletion packages/agent-bundle/src/adapters/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ import type {
NormalizationNativeHookSource,
NormalizationTargetRegistry,
} from '../core/types.ts';
import { capabilityIsSupported, cliBinCapability } from './capability-state.ts';
import {
capabilityIsSupported,
cliBinCapability,
noticeDeliveryAdvertisementFrom,
type NoticeDeliveryCapabilityTableEntry,
} from './capability-state.ts';
import { claudeAdapter } from './claude.ts';
import { codexAdapter } from './codex.ts';
import { cursorAdapter } from './cursor.ts';
Expand All @@ -30,6 +35,7 @@ import {
} from './types.ts';
import type { TargetMcpRuntimeContract } from '../services/mcp-runtime.ts';
import { deepFreeze } from '../core/freeze.ts';
import type { NoticeDeliveryAdvertisement } from './notice-delivery.ts';


const sha256Pattern = /^[0-9a-f]{64}$/;
Expand Down Expand Up @@ -445,6 +451,32 @@ const snapshotMcpRuntime = (adapter: TargetAdapter): TargetMcpRuntimeContract |
});
};

/**
* Re-validates a declared notice delivery advertisement at the registry
* boundary so a JavaScript adapter cannot smuggle an unknown route state into
* the generated MCP entry's route selection.
*/
const snapshotNoticeDelivery = (adapter: TargetAdapter): NoticeDeliveryAdvertisement | undefined => {
const declared = adapter.noticeDelivery;
if (declared === undefined) return undefined;
const rows = record(declared);
if (rows === undefined) {
throw new CapabilityStateError(
`Target adapter "${adapter.name}" must declare notice delivery advertisements as a record.`,
);
}
const entries = Object.fromEntries(Object.entries(rows).map(([route, entry]): [string, NoticeDeliveryCapabilityTableEntry] => {
const row = record(entry);
if (row === undefined || typeof row.state !== 'string') {
throw new CapabilityStateError(
`Target adapter "${adapter.name}" notice delivery route "${route}" must declare a state.`,
);
}
return [route, { ...(typeof row.reason === 'string' ? { reason: row.reason } : {}), state: row.state }];
}));
return noticeDeliveryAdvertisementFrom(adapter.name, entries);
};

/**
* The registry is a runtime boundary for third-party and JavaScript adapters,
* whose declarations the compiler never checked. Rejecting a malformed state
Expand Down Expand Up @@ -491,6 +523,7 @@ export class TargetRegistry implements NormalizationTargetRegistry {
readonly #metadata = new Map<string, TargetAdapterMetadata>();
readonly #mcpRuntimes = new Map<string, TargetMcpRuntimeContract>();
readonly #nativeHookSources = new Map<string, NativeHookSource>();
readonly #noticeDeliveries = new Map<string, NoticeDeliveryAdvertisement>();
readonly #outputStylesSources = new Map<string, OutputStylesSource>();
readonly #workflowsSources = new Map<string, WorkflowsSource>();

Expand All @@ -513,6 +546,7 @@ export class TargetRegistry implements NormalizationTargetRegistry {
const mcpRuntime = snapshotMcpRuntime(adapter);
const artifactLayout = snapshotArtifactLayout(adapter, hookContract, mcpRuntime);
const lowersConfigExtensions = snapshotLowersConfigExtensions(adapter);
const noticeDelivery = snapshotNoticeDelivery(adapter);

this.#adapters.set(adapter.name, adapter);
this.#lowersConfigExtensions.set(adapter.name, lowersConfigExtensions);
Expand Down Expand Up @@ -543,6 +577,9 @@ export class TargetRegistry implements NormalizationTargetRegistry {
if (mcpRuntime !== undefined) {
this.#mcpRuntimes.set(adapter.name, mcpRuntime);
}
if (noticeDelivery !== undefined) {
this.#noticeDeliveries.set(adapter.name, noticeDelivery);
}
if (options.default === true) {
this.#defaults.push(adapter.name);
}
Expand Down Expand Up @@ -601,6 +638,14 @@ export class TargetRegistry implements NormalizationTargetRegistry {
return this.#mcpRuntimes.get(name);
}

/** The validated notice delivery advertisement, or undefined for a host that declares none. */
noticeDelivery(name: string): NoticeDeliveryAdvertisement | undefined {
if (!this.#adapters.has(name)) {
throw new Error(`Unknown target adapter "${name}".`);
}
return this.#noticeDeliveries.get(name);
}

configExtensions(): readonly NormalizationConfigExtension[] {
return Object.freeze([...this.#extensions.values()]);
}
Expand Down
13 changes: 13 additions & 0 deletions packages/agent-bundle/src/adapters/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,17 @@ import {
type NormalizedPlugin,
} from '../core/types.ts';
import type { TargetHookContract, TargetHookEntry } from './hook-contract.ts';
import type { NoticeDeliveryAdvertisement } from './notice-delivery.ts';
import type { TargetMcpRuntimeContract } from '../services/mcp-runtime.ts';
import { deepFreeze } from '../core/freeze.ts';


export type { TargetHookEntry, TargetHookWrapper } from './hook-contract.ts';
export type {
NoticeDeliveryAdvertisement,
NoticeDeliveryRoute,
NoticeDeliveryRouteState,
} from './notice-delivery.ts';

export interface TargetArtifactWrite {
readonly content: string;
Expand Down Expand Up @@ -537,6 +543,13 @@ export interface TargetAdapter {
readonly metadata: TargetAdapterMetadata;
readonly mcpRuntime?: TargetMcpRuntimeContract;
readonly name: string;
/**
* The host's per-route notice delivery advertisement (#99 stage 4), read
* from its pinned capability table. The generated MCP entry selects its
* cross-request delivery routes from this; an adapter that declares none
* advertises no cross-request route and its artifacts wire none.
*/
readonly noticeDelivery?: NoticeDeliveryAdvertisement;
binSource?(config: Readonly<AgentBundleConfig>): string | undefined;
nativeHookSource?(config: Readonly<AgentBundleConfig>): string | undefined;
outputStylesSource?(config: Readonly<AgentBundleConfig>): string | undefined;
Expand Down
Loading
Loading