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
10 changes: 10 additions & 0 deletions .changeset/durable-next-event-notices.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@agent-bundle/runtime": minor
---

Add the optional recipient-scoped notice ledger behind the new `./notices`
subpath. It persists detached Agent Document snapshots through the existing
state kernel, exposes only the evidenced v1 states (`pending`, `attempted`,
`expired`, `unavailable`, `withdrawn`), performs publish- and delivery-time
authorization, and records next-event attempts with invocation receipts.
Stateless package-root and plugin consumers ship none of the ledger.
13 changes: 12 additions & 1 deletion docs/effect-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ public or MCP-facing contracts. Internals keep the existing classes.
| Success `A` | Promise resolves `A` |
| Fail `AgentRequestError` | rethrow (`invalid-invocation`, `outside-invocation`, `request-closed`, `store-version-conflict`) |
| Fail `AgentContractError` | rethrow (document / event / elapsed bounds) |
| Fail `AgentNoticeError` | rethrow (`aborted`, `invalid-input`, `request-closed`, `unauthorized`) |
| Fail `AgentStateError` | rethrow. Matched by `error.name` in the root boundary so `./state/contract` never enters the package-root graph. |
| Fail other `Error` | rethrow |
| Fail non-Error | `new Error(String(value))` |
Expand Down Expand Up @@ -157,6 +158,14 @@ Hurt / gotchas:
Construct `Semaphore`/`Deferred` with the boundary's `runSync` and keep the
admission bookkeeping synchronous.

## Stage 4 (#99 notice ledger) outcome

The optional notice ledger composes state-kernel reads and dispatches inside
Effect programs, runs publish- and delivery-time authorization on the typed
error channel, and crosses back to the public Promise API only through the
runtime boundary. It adopts no unstable Effect modules and starts no fibers,
timers, or workers between invocations.

## Banned modules and APIs

- `Effect.runPromise` / `runSync` / `runFork` / `runCallback` (and `*With` /
Expand All @@ -166,14 +175,16 @@ Hurt / gotchas:
- `@effect/vitest` — this repo uses rstest.
- `NodeRuntime.runMain` / `BunRuntime` as a substitute for the boundary.
- Ad-hoc `ManagedRuntime` outside a boundary module.
- `effect/unstable/*` until listed below (Stages 2 and 3 listed none).
- `effect/unstable/*` until listed below (Stages 2, 3, and the #99 notice ledger listed none).

## Unstable-module adoptions

Re-pin chores re-verify every row. Stage 2 adopts none: Flight is a React
binary stream, not Ndjson/SchemaBinary, and no other `effect/unstable/*`
module fits the dispatcher rewrite. Stage 3 also adopts none: the dev seam
needed only stable `Semaphore`, `Deferred`, `Scope`, and `Exit`.
The #99 notice ledger also adopts none: it needs only stable `Effect` and
`forEach` over the existing Promise-returning state authority.

| Module | Adopted in | Re-verify |
| --- | --- | --- |
Expand Down
22 changes: 22 additions & 0 deletions packages/rsc-runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,3 +206,25 @@ the test stand-in; it is never durable. The workspace-durable driver ships on
including external ones — must pass the exported conformance suite
(`stateDriverConformanceCases`); a disconnected adapter is not a completed
integration.

## Notices (optional)

`@agent-bundle/runtime/notices` is the narrow recipient-scoped notice core.
It stores detached, finite `AgentDocumentSnapshot` content in one ordinary
state-kernel definition; host wiring opens that definition with the
workspace-durable SQLite driver and passes the resulting ledger as
`runAgentRequest({ noticeLedger })`. Stateless projects import neither
subpath and ship no state or notice implementation.

Inside an authorized request, `(await agent()).notices` is a request-bound
handle with `publish()` and `read()`. Recipients use only observed
host/session/actor/workspace axes. Publish authorization runs before
persistence, and delivery authorization runs again when a matching event is
admitted. `read()` exposes notices selected for that event while the ledger
records a receipt containing the invocation id and state `attempted`.

V1 deliberately exposes only `pending | attempted | expired | unavailable |
withdrawn`. It does not claim `delivered`, `read`, or `acknowledged`: observing
the recipient process is not evidence that the agent saw the content. There
is no router, MCP inbox, timer, retry worker, or autonomous work between
invocations in this subpath.
4 changes: 4 additions & 0 deletions packages/rsc-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@
"./state/sqlite": {
"types": "./dist/state/sqlite.d.ts",
"import": "./dist/state/sqlite.js"
},
"./notices": {
"types": "./dist/notices/index.d.ts",
"import": "./dist/notices.js"
}
},
"scripts": {
Expand Down
13 changes: 13 additions & 0 deletions packages/rsc-runtime/rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ export default defineConfig({
},
},
},
{
...sharedLib,
// Notices are optional and reuse the state entry's kernel runtime.
// Keeping this entry separate means stateless package-root consumers
// receive no ledger code and the notice entry never loads node:sqlite.
output: {
cleanDistPath: false,
externals: { '../state/index.js': './state.js' },
},
source: {
entry: { notices: './src/notices/index.ts' },
},
},
{
...sharedLib,
// The sqlite driver is its own entry so `node:sqlite` (and its
Expand Down
47 changes: 36 additions & 11 deletions packages/rsc-runtime/src/agent-request.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { AsyncLocalStorage } from 'node:async_hooks';

import type { JsonValue } from './lower-mcp.js';
import type {
AgentNoticeLedger,
AgentNoticeRequestLease,
AgentNoticesHandle,
} from './notices/contract.js';
import type { AgentStateHandle } from './state/contract.js';

export const AGENT_REQUEST_STORE_VERSION = 1;
export const AGENT_REQUEST_STORE_VERSION = 2;

const STORE_SYMBOL = Symbol.for('@agent-bundle/runtime/request-store');

Expand Down Expand Up @@ -136,15 +141,21 @@ export interface AgentRequestContext {
* `runAgentRequest({ state })`; undefined for stateless projects.
*/
readonly state: AgentStateHandle | undefined;
/** Reserved for recipient-aware notices (#99). Wave 1 leaves this undefined. */
readonly notices: undefined;
/**
* Request-bound recipient notice handle (#99 narrow core). `read()` exposes
* notices attempted on this admitted event; `publish()` persists a detached
* Agent Document snapshot after publish-time authorization.
*/
readonly notices: AgentNoticesHandle | undefined;
}

export interface AgentRequestInit {
readonly actor?: Observed<AgentActorIdentity>;
readonly capabilities?: AgentRequestCapabilities;
readonly host?: Observed<AgentHostIdentity>;
readonly invocation: AgentInvocationInput;
/** Optional durable notice authority; omitted projects load no notice code. */
readonly noticeLedger?: AgentNoticeLedger;
readonly progress?: AgentProgressReporter;
readonly providers?: AgentProviderValues;
readonly services?: AgentServiceRegistry;
Expand Down Expand Up @@ -240,7 +251,7 @@ interface FrozenValues {
readonly capabilities: AgentRequestCapabilities;
readonly host: Observed<AgentHostIdentity>;
readonly invocation: AgentInvocation;
readonly notices: undefined;
readonly notices: AgentNoticesHandle | undefined;
readonly progress: AgentProgressReporter;
readonly providers: AgentProviderValues;
readonly services: AgentServiceRegistry;
Expand Down Expand Up @@ -348,19 +359,32 @@ export const runAgentRequest = async <T>(
init: AgentRequestInit,
operation: () => T | Promise<T>,
): Promise<T> => {
const actor = snapshotObserved(init.actor ?? unavailable<AgentActorIdentity>());
const host = snapshotObserved(init.host ?? unavailable<AgentHostIdentity>());
const invocation = invocationFrom(init.invocation);
const session = snapshotObserved(init.session ?? unavailable<AgentSessionIdentity>());
const signal = init.signal ?? new AbortController().signal;
const workspace = snapshotObserved(init.workspace ?? unavailable<AgentWorkspaceIdentity>());
const noticeLease: AgentNoticeRequestLease | undefined = init.noticeLedger === undefined
? undefined
: await init.noticeLedger.openRequest({
invocation,
principal: Object.freeze({ actor, host, session, workspace }),
signal,
});
const values: FrozenValues = Object.freeze({
actor: snapshotObserved(init.actor ?? unavailable<AgentActorIdentity>()),
actor,
capabilities: snapshotCapabilities(init.capabilities ?? emptyCapabilities()),
host: snapshotObserved(init.host ?? unavailable<AgentHostIdentity>()),
invocation: invocationFrom(init.invocation),
notices: undefined,
host,
invocation,
notices: noticeLease?.handle,
progress: init.progress ?? silentProgress,
providers: Object.freeze({ ...(init.providers ?? {}) }),
services: Object.freeze({ ...(init.services ?? {}) }),
session: snapshotObserved(init.session ?? unavailable<AgentSessionIdentity>()),
signal: init.signal ?? new AbortController().signal,
session,
signal,
state: init.state,
workspace: snapshotObserved(init.workspace ?? unavailable<AgentWorkspaceIdentity>()),
workspace,
});
const lease: Lease = {
closed: false,
Expand All @@ -373,5 +397,6 @@ export const runAgentRequest = async <T>(
return await getStore().storage.run(lease, operation);
} finally {
lease.closed = true;
noticeLease?.close();
}
};
1 change: 1 addition & 0 deletions packages/rsc-runtime/src/effect/boundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface RunPromiseOptions {

const TYPED_ERROR_NAMES = new Set([
'AgentContractError',
'AgentNoticeError',
'AgentRequestError',
'AgentRuntimeError',
'AgentStateError',
Expand Down
9 changes: 9 additions & 0 deletions packages/rsc-runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,5 +88,14 @@ export { createRscRequestContext } from './request-context.js';
// Type-only: the state kernel itself ships behind the './state' subpath so
// stateless artifacts include none of it (#98).
export type { AgentStateHandle, AgentStateLifetime } from './state/contract.js';
// Type-only: the notice ledger itself ships behind the './notices' subpath.
export type {
AgentNotice,
AgentNoticeDelivery,
AgentNoticeLedger,
AgentNoticeState,
AgentNoticesHandle,
AgentRecipient,
} from './notices/contract.js';
export type { RscRequestContext } from './request-context.js';
export * from './plugin.js';
154 changes: 154 additions & 0 deletions packages/rsc-runtime/src/notices/contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import type { AgentDocumentSnapshot } from '../agent-document.js';
import type {
AgentActorIdentity,
AgentHostIdentity,
AgentInvocation,
AgentSessionIdentity,
AgentWorkspaceIdentity,
Observed,
} from '../agent-request.js';

export const AGENT_NOTICE_STATES = Object.freeze([
'pending',
'attempted',
'expired',
'unavailable',
'withdrawn',
] as const);

/** V1 contains only states the framework can evidence without host claims. */
export type AgentNoticeState = (typeof AGENT_NOTICE_STATES)[number];

export type AgentNoticePriority = 'low' | 'normal' | 'high';

/** A recipient is the conjunction of the observed identity axes it specifies. */
export interface AgentRecipient {
readonly actor?: AgentActorIdentity;
readonly host?: AgentHostIdentity;
readonly session?: AgentSessionIdentity;
readonly workspace?: AgentWorkspaceIdentity;
}

export interface AgentNoticePrincipal {
readonly actor: Observed<AgentActorIdentity>;
readonly host: Observed<AgentHostIdentity>;
readonly session: Observed<AgentSessionIdentity>;
readonly workspace: Observed<AgentWorkspaceIdentity>;
}

export interface AgentNoticeAttemptReceipt {
readonly attemptedAt: string;
readonly channel: 'next-event';
readonly invocationId: string;
}

export type AgentNoticeUnavailableReason = 'delivery-authorization-unavailable';

export interface AgentNotice {
readonly attempts: readonly AgentNoticeAttemptReceipt[];
readonly content: AgentDocumentSnapshot;
readonly createdAt: string;
readonly dedupeKey?: string;
readonly expiredAt?: string;
readonly expiresAt?: string;
readonly id: string;
readonly priority: AgentNoticePriority;
readonly recipient: AgentRecipient;
readonly state: AgentNoticeState;
readonly unavailableAt?: string;
readonly unavailableReason?: AgentNoticeUnavailableReason;
readonly withdrawnAt?: string;
}

export interface AgentNoticeLedgerSnapshot {
readonly notices: readonly AgentNotice[];
readonly revision: number;
}

export interface AgentNoticePublishInput {
readonly content: AgentDocumentSnapshot;
readonly dedupeKey?: string;
readonly expiresAt?: string;
readonly priority: AgentNoticePriority;
readonly recipient: AgentRecipient;
}

export interface AgentNoticePublishOptions {
readonly idempotencyKey: string;
}

export interface AgentNoticePublishResult {
readonly deduped: boolean;
readonly notice: AgentNotice;
readonly replayed: boolean;
readonly revision: number;
}

export interface AgentNoticeExpiryOptions {
readonly at: string;
readonly idempotencyKey: string;
}

export interface AgentNoticeWithdrawOptions {
readonly at: string;
readonly idempotencyKey: string;
}

export type AgentNoticeAuthorizationDecision =
| { readonly state: 'authorized' }
| { readonly state: 'unavailable' };

export interface AgentNoticeAuthorizationRequest {
readonly noticeId?: string;
readonly phase: 'deliver' | 'publish';
readonly principal: AgentNoticePrincipal;
readonly recipient: AgentRecipient;
}

export type AgentNoticeAuthorizer = (
request: AgentNoticeAuthorizationRequest,
) => AgentNoticeAuthorizationDecision | Promise<AgentNoticeAuthorizationDecision>;

export interface AgentNoticeDelivery {
readonly notice: AgentNotice;
readonly receipt: AgentNoticeAttemptReceipt;
}

export interface AgentNoticesHandle {
publish(input: AgentNoticePublishInput, options: AgentNoticePublishOptions): Promise<AgentNoticePublishResult>;
read(): Promise<readonly AgentNoticeDelivery[]>;
}

export interface AgentNoticeRequest {
readonly invocation: AgentInvocation;
readonly principal: AgentNoticePrincipal;
readonly signal: AbortSignal;
}

export interface AgentNoticeRequestLease {
readonly handle: AgentNoticesHandle;
close(): void;
}

export interface AgentNoticeLedger {
expire(options: AgentNoticeExpiryOptions): Promise<AgentNoticeLedgerSnapshot>;
openRequest(request: AgentNoticeRequest): Promise<AgentNoticeRequestLease>;
read(): Promise<AgentNoticeLedgerSnapshot>;
withdraw(id: string, options: AgentNoticeWithdrawOptions): Promise<AgentNoticeLedgerSnapshot>;
}

export type AgentNoticeErrorCode =
| 'aborted'
| 'invalid-input'
| 'request-closed'
| 'unauthorized';

export class AgentNoticeError extends Error {
readonly code: AgentNoticeErrorCode;

constructor(code: AgentNoticeErrorCode, message: string, options?: ErrorOptions) {
super(message, options);
this.code = code;
this.name = 'AgentNoticeError';
}
}
Loading
Loading