From cfaf4c70bb422bdbfbe50584d6f98eace5064aac Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 07:09:35 +0000 Subject: [PATCH 1/3] feat(notices): publisher-scoped notices.published() with per-notice state (#460) publish() records the publishing principal's observed axes on the notice as publisher (actor/host/session/workspace + lineage conversation); published() returns the principal's own notices in every state with their receipts, matched by lineage conversation first (else every recorded axis), judged per notice under authorization phase 'published', disclosed under the default internal ceiling, recording nothing. Additive optional field, no definition version bump. examples/worktree-proximity coordinator/status reports the calling agent's published-notice counts by state. --- .changeset/460-published-notices.md | 5 + examples/worktree-proximity/README.md | 16 +- .../src/mcp/coordinator/tools/status.tsx | 53 +++- .../tests/route-unit/routes.test.ts | 63 +++- .../route-unit/published-notices.test.ts | 148 +++++++++ .../tests/worktree-proximity-journeys.test.ts | 20 ++ packages/rsc-runtime/README.md | 24 +- packages/rsc-runtime/src/mount/index.ts | 1 + packages/rsc-runtime/src/notices/contract.ts | 33 +- packages/rsc-runtime/src/notices/index.ts | 3 + packages/rsc-runtime/src/notices/ledger.ts | 44 +++ packages/rsc-runtime/src/notices/state.ts | 71 ++++- .../rsc-runtime/tests/notices-ledger.test.ts | 282 ++++++++++++++++++ website/plugins/generated-reference.ts | 9 + 14 files changed, 759 insertions(+), 13 deletions(-) create mode 100644 .changeset/460-published-notices.md create mode 100644 packages/agent-bundle/tests/route-unit/published-notices.test.ts diff --git a/.changeset/460-published-notices.md b/.changeset/460-published-notices.md new file mode 100644 index 000000000..029164998 --- /dev/null +++ b/.changeset/460-published-notices.md @@ -0,0 +1,5 @@ +--- +"@agent-bundle/runtime": patch +--- + +Let a publisher read what became of its own notices: `(await agent()).notices.published()` returns the notices the current principal published, in every ledger state (`pending`, `attempted`, `acknowledged`, `expired`, `unavailable`, `withdrawn`) with their receipts. `publish()` records the publishing request's observed identity on the notice as `AgentNotice.publisher` (`actor`, `host`, `session`, `workspace`, and `conversation` from `request.lineage`); a reader is the publisher when it resolves the same lineage conversation, or — for a publisher recorded without lineage — when every recorded axis matches. The view records nothing on the ledger, is judged per notice under the new authorization `phase: 'published'`, discloses content under the default `internal` ceiling, and never returns another publisher's or any recipient's notices. `publisher` is an additive optional field (no state-definition version bump). `examples/worktree-proximity`'s `coordinator/status` reports the calling agent's published-notice counts by state. (#460) diff --git a/examples/worktree-proximity/README.md b/examples/worktree-proximity/README.md index 4011b7aeb..873956c4f 100644 --- a/examples/worktree-proximity/README.md +++ b/examples/worktree-proximity/README.md @@ -139,10 +139,18 @@ addressed through `recipient.workspace.root`. `recipient.root` (every conversation under one root) is available but unused here: proximity is a message to one peer, not to the tree. `(await agent()).notices.read()` exposes only deliveries attempted for the -current invocation; publisher-scoped visibility is -[#460](https://github.com/scriptedalchemy/agent-bundle/issues/460). The -coordinator status therefore reports topology facts only and does not claim a -whole-ledger pending count. +current invocation, and `inbox()` only what is pending for the current +recipient. The coordinator status reports, beside the topology facts, what +became of the notices *the calling agent* published — `pending`, +`attempted`, `acknowledged`, and the other ledger states, counted from +`(await agent()).notices.published()` +([#460](https://github.com/scriptedalchemy/agent-bundle/issues/460)). That +view is scoped by the publisher identity the ledger recorded at publish, the +agent's lineage conversation, so a status call correlated to agent B's +conversation (Claude names the pre-tool hook's `tool_use_id` in the MCP call's +`_meta`) counts agent B's notices whatever the MCP client name, session id, or +server cwd; a call whose lineage the runtime cannot resolve counts zero. It is +never a whole-ledger count and never another agent's. ## Evidence boundary diff --git a/examples/worktree-proximity/src/mcp/coordinator/tools/status.tsx b/examples/worktree-proximity/src/mcp/coordinator/tools/status.tsx index cf1965e98..b698c55e8 100644 --- a/examples/worktree-proximity/src/mcp/coordinator/tools/status.tsx +++ b/examples/worktree-proximity/src/mcp/coordinator/tools/status.tsx @@ -1,14 +1,15 @@ -import { Agent, type JsonValue } from '@agent-bundle/runtime'; +import { Agent, type AgentNoticeState, type JsonValue } from '@agent-bundle/runtime'; +import { AGENT_NOTICE_STATES } from '@agent-bundle/runtime/notices'; import type { ToolConfig, ToolRouteProps } from 'agent-bundle'; import React from 'react'; import { z } from 'zod'; -import { withTopology } from '../../../coordination.js'; +import { withNotices, withTopology } from '../../../coordination.js'; import { ActorSchema } from '../../../state.js'; export const config = { annotations: { readOnlyHint: true }, - description: 'Show the mounted durable worktree topology, active intents, and refusals.', + description: 'Show the mounted durable worktree topology, active intents, refusals, and the state of the proximity notices this agent published.', } satisfies ToolConfig; export const inputSchema = z @@ -17,10 +18,33 @@ export const inputSchema = z }) .strict(); +const noticeCount = z.number().int().nonnegative(); + +/** + * What became of the notices the calling agent published, counted by ledger + * state. Scoped by the publisher identity the ledger recorded at publish — + * the caller's lineage conversation — so it is this agent's own notices, never + * the whole ledger and never another agent's ([#460](https://github.com/scriptedalchemy/agent-bundle/issues/460)). + */ +export const PublishedNoticesSchema = z + .object({ + acknowledged: noticeCount, + attempted: noticeCount, + expired: noticeCount, + pending: noticeCount, + reason: z.string().optional(), + state: z.enum(['available', 'unavailable']), + total: noticeCount, + unavailable: noticeCount, + withdrawn: noticeCount, + }) + .strict(); + export const resultSchema = z .object({ activeActivities: z.number().int().nonnegative(), actors: z.array(ActorSchema), + notices: PublishedNoticesSchema, reason: z.string().optional(), refusals: z.number().int().nonnegative(), revision: z.number().int().nonnegative(), @@ -29,16 +53,32 @@ export const resultSchema = z .strict(); type StatusResult = z.output; +type PublishedNotices = z.output; + +const emptyCounts = (): Record => + Object.fromEntries(AGENT_NOTICE_STATES.map((state) => [state, 0])) as Record; + +const publishedNotices = async (): Promise => { + const result = await withNotices(async (notices) => notices.published()); + if (result.state === 'unavailable') { + return { ...emptyCounts(), reason: result.reason, state: 'unavailable', total: 0 }; + } + const counts = emptyCounts(); + for (const notice of result.value) counts[notice.state] += 1; + return { ...counts, state: 'available', total: result.value.length }; +}; export default async function Status({ input, }: ToolRouteProps) { const topologyResult = await withTopology(async (store) => store.read()); + const notices = await publishedNotices(); let result: StatusResult; if (topologyResult.state === 'unavailable') { result = { activeActivities: 0, actors: [], + notices, reason: topologyResult.reason, refusals: 0, revision: 0, @@ -57,12 +97,16 @@ export default async function Status({ && (activity.paths.length > 0 || activity.dependencies.length > 0), ).length, actors, + notices, refusals: topology.refusals.length, revision, state: 'available', }; } + const noticeLine = notices.state === 'available' + ? `- Published notices: ${String(notices.total)} (pending ${String(notices.pending)}, attempted ${String(notices.attempted)}, acknowledged ${String(notices.acknowledged)})` + : `- Published notices unavailable: ${notices.reason ?? 'unknown reason'}`; const markdown = result.state === 'available' ? [ '# Worktree proximity status', @@ -70,8 +114,9 @@ export default async function Status({ `- Actors: ${String(result.actors.length)}`, `- Active activities: ${String(result.activeActivities)}`, `- Refused edges: ${String(result.refusals)}`, + noticeLine, ].join('\n') - : `# Worktree proximity status\n\nUnavailable: ${result.reason ?? 'unknown reason'}`; + : `# Worktree proximity status\n\nUnavailable: ${result.reason ?? 'unknown reason'}\n\n${noticeLine}`; return ( {markdown} diff --git a/examples/worktree-proximity/tests/route-unit/routes.test.ts b/examples/worktree-proximity/tests/route-unit/routes.test.ts index b38252eef..2d9972c36 100644 --- a/examples/worktree-proximity/tests/route-unit/routes.test.ts +++ b/examples/worktree-proximity/tests/route-unit/routes.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from '@rstest/core'; -import { available, type AgentLineage, type Observed } from '@agent-bundle/runtime'; +import { agent, available, runAgentRequest, type AgentLineage, type Observed } from '@agent-bundle/runtime'; import { expectDocument, mountTestState, renderRoute, testManifest, type MountedTestState } from 'agent-bundle/test'; import BeforeTool from '../../src/events/tool/before.js'; @@ -485,11 +485,72 @@ describe('worktree proximity journeys', () => { expect.objectContaining({ id: 'agent-a', worktreeRoot: worktrees.a }), expect.objectContaining({ id: 'agent-b', worktreeRoot: worktrees.b }), ]), + // A caller with no identity published nothing: the count is honestly + // zero, not the ledger's total. + notices: expect.objectContaining({ pending: 0, state: 'available', total: 0 }), refusals: 0, revision: expect.any(Number), }); }); + it('reports the publishing agent its own notice states through coordinator status (#460)', async () => { + await bindActors(); + await recordIntent('agent-a', worktrees.a, 'src/shared.ts', 'intent:a'); + // agent-b's intent publishes the proximity notice addressed to agent-a. + await recordIntent('agent-b', worktrees.b, 'src/shared.ts', 'intent:b'); + + // An MCP tool call from the same agent: the client name, MCP session id, + // and server cwd all differ from the hook that published — the lineage + // conversation is what identifies the publisher. + const statusFor = (conversation: string) => renderRoute('tool:coordinator/status', { + context: { + ...mounted.context(), + host: available({ name: 'claude-code' }, 'native'), + lineage: childLineage(conversation), + providers: providers(worktrees.root), + session: available({ sessionId: 'mcp-session' }, 'native'), + workspace: available({ root: worktrees.root }, 'derived'), + }, + input: {}, + }); + const noticesOf = (conversation: string, worktreeRoot: string) => runAgentRequest({ + ...mounted.context(), + host: available({ name: 'claude' }, 'native'), + invocation: { id: `invocation:notices:${conversation}:${String(sequence++)}`, kind: 'tool', startedAt: '2026-09-01T20:05:00.000Z' }, + lineage: childLineage(conversation), + providers: providers(worktreeRoot), + session: available({ sessionId: 'root-session' }, 'native'), + workspace: available({ root: worktreeRoot }, 'native'), + }, async () => { + const handle = (await agent()).notices!; + return { inbox: await handle.inbox(), published: await handle.published() }; + }); + + // Pending: the publisher sees it; its own inbox does not (it is not the recipient). + const pending = await statusFor('agent-b'); + expectDocument(pending).toContainMarkdown('Published notices: 1 (pending 1, attempted 0, acknowledged 0)'); + expect(pending.result).toMatchObject({ notices: { pending: 1, state: 'available', total: 1 } }); + const publisherBefore = await noticesOf('agent-b', worktrees.b); + expect(publisherBefore.inbox).toEqual([]); + expect(publisherBefore.published).toEqual([expect.objectContaining({ recipient: { conversation: 'agent-a' }, state: 'pending' })]); + // The recipient sees it in its inbox and published nothing. + const recipientBefore = await noticesOf('agent-a', worktrees.a); + expect(recipientBefore.inbox).toHaveLength(1); + expect(recipientBefore.published).toEqual([]); + expect((await statusFor('agent-a')).result).toMatchObject({ notices: { state: 'available', total: 0 } }); + + // Admitted on agent-a's next event: the publisher now reads `attempted`, + // and the recipient's inbox is empty again. + await completeIntent(worktrees.a, 'src/shared.ts', 'intent:a:after', 'agent-a', childLineage('agent-a')); + const attempted = await statusFor('agent-b'); + expectDocument(attempted).toContainMarkdown('Published notices: 1 (pending 0, attempted 1, acknowledged 0)'); + expect(attempted.result).toMatchObject({ notices: { attempted: 1, pending: 0, total: 1 } }); + const recipientAfter = await noticesOf('agent-a', worktrees.a); + expect(recipientAfter.inbox).toEqual([]); + expect(recipientAfter.published).toEqual([]); + expect((await noticesOf('agent-b', worktrees.b)).inbox).toEqual([]); + }); + it('renders state unavailability when an event module has no mounted handle', async () => { const rendered = await renderRoute({ default: BeforeTool }, { context: { diff --git a/packages/agent-bundle/tests/route-unit/published-notices.test.ts b/packages/agent-bundle/tests/route-unit/published-notices.test.ts new file mode 100644 index 000000000..60141ea84 --- /dev/null +++ b/packages/agent-bundle/tests/route-unit/published-notices.test.ts @@ -0,0 +1,148 @@ +import { Agent, agent, type AgentLineage, type Observed } from '@agent-bundle/runtime'; +import { + agentNoticeStateDefinition, + createAgentNoticeLedger, + type AgentNoticeLedger, +} from '@agent-bundle/runtime/notices'; +import { createMemoryStateDriver } from '@agent-bundle/runtime/state'; +import { describe, expect, it } from '@rstest/core'; +import { createElement } from 'react'; + +import { expectDocument } from '../../src/test/matchers.ts'; +import { renderRoute } from '../../src/test/render.ts'; + +/** Claude/Codex-shaped: every subagent under one root shares the root session id. */ +const lineageOf = (conversation: string): Observed => ({ + source: 'derived', + state: 'available', + value: { conversation, depth: 1, parent: 'root', resolution: 'registry', root: 'root', subagent: { id: conversation } }, +}); + +let sequence = 0; +const render = ( + module: () => Promise, + ledger: AgentNoticeLedger, + lineage: Observed, + kind: 'event' | 'tool', + identity: { readonly host: string; readonly sessionId: string; readonly workspace: string } = { + host: 'claude', + sessionId: 'root', + workspace: '/workspace', + }, +) => { + sequence += 1; + return renderRoute({ default: module as never }, { + context: { + host: { source: 'native', state: 'available', value: { name: identity.host } }, + lineage, + noticeLedger: ledger, + session: { source: 'native', state: 'available', value: { sessionId: identity.sessionId } }, + workspace: { source: 'native', state: 'available', value: { root: identity.workspace } }, + }, + ...(kind === 'event' + ? { + input: { + canonical: { + event: 'tool/after', + idempotencyKey: `published-notices:${String(sequence)}`, + observedAt: `2026-09-03T13:00:${String(sequence).padStart(2, '0')}.000Z`, + provenance: { host: 'claude', hostContractRevision: 'route-unit', nativeEvent: 'PostToolUse', source: 'native' }, + sequence, + }, + native: { hook_event_name: 'PostToolUse' }, + }, + kind: 'event-route' as const, + routeId: 'event:tool/after', + } + : { routeId: 'tool:coordinator/notices' }), + }); +}; + +const Publish = (conversation: string) => async (): Promise => { + const { notices } = await agent(); + const published = await notices!.publish({ + content: { root: { kind: 'text', text: `for ${conversation}` }, status: 'success', version: 1 }, + priority: 'high', + recipient: { conversation }, + }, { idempotencyKey: `publish:${conversation}` }); + return createElement(Agent.Result, { value: { noticeId: published.notice.id } }); +}; + +/** What a coordinator tool reads: its own publications by state, and its inbox. */ +const Overview = async (): Promise => { + const { notices } = await agent(); + const [published, inbox] = await Promise.all([notices!.published(), notices!.inbox()]); + return createElement(Agent.Result, { + value: { + inbox: inbox.map((notice) => notice.id), + published: published.map((notice) => ({ id: notice.id, state: notice.state })), + }, + }); +}; + +const Receive = async (): Promise => { + const { notices } = await agent(); + return createElement(Agent.Result, { value: { delivered: (await notices!.read()).map((delivery) => delivery.notice.id) } }); +}; + +const value = (rendered: Awaited>): T => rendered.document.value as T; + +describe('publisher-scoped notice visibility (#460)', () => { + it('shows publisher A its notice as attempted after B admitted it, while B\'s inbox empties and A\'s inbox never showed it', async () => { + const driver = createMemoryStateDriver({ lifetime: 'process' }); + const store = await driver.open(agentNoticeStateDefinition('process')); + const phases: string[] = []; + const ledger = createAgentNoticeLedger(store, { + authorize: (request) => { + phases.push(request.phase); + return { state: 'authorized' }; + }, + }); + try { + const published = await render(Publish('agent-b'), ledger, lineageOf('agent-a'), 'event'); + expectDocument(published).toHaveStatus('success'); + const { noticeId } = value<{ noticeId: string }>(published); + + // Before admission: A sees its publication pending and nothing in its inbox; B sees it only in its inbox. + expect(value(await render(Overview, ledger, lineageOf('agent-a'), 'tool'))).toEqual({ + inbox: [], + published: [{ id: noticeId, state: 'pending' }], + }); + expect(value(await render(Overview, ledger, lineageOf('agent-b'), 'tool'))).toEqual({ + inbox: [noticeId], + published: [], + }); + // A sibling under the same root, sharing host, session, and workspace, sees neither. + expect(value(await render(Overview, ledger, lineageOf('agent-c'), 'tool'))).toEqual({ inbox: [], published: [] }); + + // B's next event admits it. + expect(value(await render(Receive, ledger, lineageOf('agent-b'), 'event'))).toEqual({ delivered: [noticeId] }); + + // After admission: A reads `attempted` — here from a tool call whose host + // name, session id, and cwd differ from the publishing hook, because the + // lineage conversation identifies the publisher — and B's inbox is empty. + expect(value(await render(Overview, ledger, lineageOf('agent-a'), 'tool', { + host: 'claude-code', + sessionId: 'mcp-session-1', + workspace: '/server-cwd', + }))).toEqual({ + inbox: [], + published: [{ id: noticeId, state: 'attempted' }], + }); + expect(value(await render(Overview, ledger, lineageOf('agent-b'), 'tool'))).toEqual({ inbox: [], published: [] }); + // Unresolved lineage is nobody's publication. + expect(value(await render(Overview, ledger, { reason: 'no-shared-runtime', state: 'unavailable' }, 'tool'))) + .toEqual({ inbox: [], published: [] }); + + // `published` is judged per matching notice; publisher-scoped reads recorded no receipts. + expect(phases.filter((phase) => phase === 'published')).toHaveLength(2); + expect((await ledger.read()).notices[0]).toMatchObject({ + attempts: [expect.objectContaining({ channel: 'next-event' })], + publisher: { conversation: 'agent-a', host: { name: 'claude' }, session: { sessionId: 'root' }, workspace: { root: '/workspace' } }, + state: 'attempted', + }); + } finally { + await driver.close(); + } + }); +}); diff --git a/packages/agent-bundle/tests/worktree-proximity-journeys.test.ts b/packages/agent-bundle/tests/worktree-proximity-journeys.test.ts index ea73a5821..42a711bb3 100644 --- a/packages/agent-bundle/tests/worktree-proximity-journeys.test.ts +++ b/packages/agent-bundle/tests/worktree-proximity-journeys.test.ts @@ -31,6 +31,12 @@ interface ActorStatus { interface StatusResult { readonly activeActivities: number; readonly actors: readonly ActorStatus[]; + readonly notices: { + readonly pending: number; + readonly reason?: string; + readonly state: 'available' | 'unavailable'; + readonly total: number; + }; readonly reason?: string; readonly refusals: number; readonly revision: number; @@ -159,6 +165,7 @@ const callStatus = async (client: Client): Promise => { expect(Object.keys(result as Record).sort()).toEqual([ 'activeActivities', 'actors', + 'notices', 'refusals', 'revision', 'state', @@ -254,9 +261,22 @@ it('proves worktree proximity journeys across real processes and linked worktree liveSession = await startServer(); expect(liveSession.pid).toBeGreaterThan(0); await expect(stat(fixture.endpoint)).resolves.toMatchObject({ mode: expect.any(Number) }); + // This client call carries no `_meta` correlation, so its lineage is + // unresolved and it is nobody's publisher: the published-notice count is + // honestly zero for it, never the ledger's total. await expect(callStatus(liveSession.client)).resolves.toEqual({ activeActivities: 0, actors: [], + notices: { + acknowledged: 0, + attempted: 0, + expired: 0, + pending: 0, + state: 'available', + total: 0, + unavailable: 0, + withdrawn: 0, + }, refusals: 0, revision: 0, state: 'available', diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md index a33581ec8..739b6ca5f 100644 --- a/packages/rsc-runtime/README.md +++ b/packages/rsc-runtime/README.md @@ -244,7 +244,8 @@ workspace-durable SQLite driver and passes the resulting ledger as subpath and ship no state or notice implementation. Inside an authorized request, `(await agent()).notices` is a request-bound -handle with `publish()`, `read()`, `inbox()`, and `acknowledge()`. A +handle with `publish()`, `read()`, `inbox()`, `acknowledge()`, and +`published()`. A recipient is the conjunction of the observed axes it names — `actor`, `host`, `session`, `workspace`, plus the two lineage axes read from the admitting request's `lineage`: `conversation` (exactly one agent thread, @@ -259,7 +260,26 @@ just `{ conversation, root }`; both recipient fields and that scope are additive optional schema fields (no definition version bump), and an admission journaled before them matches exactly what it matched then. Publish authorization runs before persistence, and delivery authorization runs again -when a matching event is admitted. `read()` exposes notices selected for that event +when a matching event is admitted. + +`published()` is the publisher's own view (#460): the notices this request's +principal published, in every state, with their receipts — the answer to "was +my notice attempted or acknowledged?" that `read()` (this invocation's +deliveries) and `inbox()` (this recipient's pending notices) cannot give. +`publish()` records the publishing principal's observed axes on the notice as +`publisher` (`actor`, `host`, `session`, `workspace`, and `conversation` from +`request.lineage`; absent when the request observed none, so such a notice +belongs to no view). A reader is the publisher when it resolves the same +lineage conversation — the identity of an agent thread, whichever transport +observed it, so the hook that published and the MCP tool call that asks agree +even though host name, session id, and cwd differ — or, for a publisher +recorded without lineage, when every recorded axis matches. It is a read with +no route behind it: it records no receipt, is judged per notice under +authorization `phase: 'published'`, and discloses content under the default +`internal` ceiling only (`internal` secret-passed, `public` as authored, +`secret` as the placeholder). It never returns another publisher's notices, +never a notice deduplicated onto another author's, and is not a recipient +view — cross-recipient reads stay structurally impossible. `read()` exposes notices selected for that event while the ledger records a receipt containing the invocation id and state `attempted`. `acknowledge()` is recipient-matched and authorization-gated and produces the terminal `acknowledged` state — the strongest evidenced outcome. diff --git a/packages/rsc-runtime/src/mount/index.ts b/packages/rsc-runtime/src/mount/index.ts index 24a62b37d..446ea41c6 100644 --- a/packages/rsc-runtime/src/mount/index.ts +++ b/packages/rsc-runtime/src/mount/index.ts @@ -102,6 +102,7 @@ const failedLedger = (failure: AgentStateError): AgentNoticeLedger => { acknowledge: reject, inbox: reject, publish: reject, + published: reject, read: reject, }), }), diff --git a/packages/rsc-runtime/src/notices/contract.ts b/packages/rsc-runtime/src/notices/contract.ts index bf7f45585..5515f8597 100644 --- a/packages/rsc-runtime/src/notices/contract.ts +++ b/packages/rsc-runtime/src/notices/contract.ts @@ -70,6 +70,23 @@ export interface AgentNoticePrincipal { /** The lineage facts recipient matching reads: the request's own conversation and its root. */ export type AgentNoticeLineageScope = Pick; +/** + * The publishing request's identity as `publish()` recorded it: every axis the + * request could observe, in the same terms a recipient is spelled. It scopes + * `notices.published()` — the publisher's own view of what became of its + * notices (#460) — and nothing else: it is never matched for delivery, and it + * is absent on notices published before it existed or by a request that + * observed no identity at all, which therefore no `published()` view returns. + */ +export interface AgentNoticePublisher { + readonly actor?: AgentActorIdentity; + /** `request.lineage.conversation`: the agent thread that published, whichever transport observed it. */ + readonly conversation?: string; + readonly host?: AgentHostIdentity; + readonly session?: AgentSessionIdentity; + readonly workspace?: AgentWorkspaceIdentity; +} + /** * The principal as the ledger journals it on an admission: the identity axes * plus only the lineage scope, so a journaled admission never depends on the @@ -174,6 +191,8 @@ export interface AgentNotice { /** Admissions before this instant leave the notice pending (V1: evaluated only on admitted events, never by an implied timer). */ readonly nextAttemptAt?: string; readonly priority: AgentNoticePriority; + /** Who published it, for {@link AgentNoticesHandle.published}; absent on pre-#460 notices and identity-less publishes. */ + readonly publisher?: AgentNoticePublisher; readonly recipient: AgentRecipient; /** Maximum next-event attempt receipts before admission stops re-attempting; absent means 1. */ readonly retryBudget?: number; @@ -271,7 +290,8 @@ export type AgentNoticeAuthorizationDecision = export interface AgentNoticeAuthorizationRequest { readonly noticeId?: string; - readonly phase: 'acknowledge' | 'deliver' | 'publish' | 'read'; + /** `published` is the publisher-scoped read (`notices.published()`), judged once per notice like `read`. */ + readonly phase: 'acknowledge' | 'deliver' | 'publish' | 'published' | 'read'; readonly principal: AgentNoticePrincipal; readonly recipient: AgentRecipient; } @@ -300,6 +320,17 @@ export interface AgentNoticesHandle { /** Pending notices as the `mcp-inbox` route discloses them (`content` redacted per sensitivity; withheld ones omitted). */ inbox(): Promise; publish(input: AgentNoticePublishInput, options: AgentNoticePublishOptions): Promise; + /** + * The notices this principal published, in every state, with their + * receipts — the publisher's answer to "was my notice attempted or + * acknowledged?" (#460). Scoped by the publisher identity `publish()` + * recorded: the same lineage conversation when both sides have one, + * otherwise every recorded identity axis. Never another publisher's + * notices and never a recipient view; `content` is disclosed under the + * default `internal` ceiling (secret-passed; `secret` content is the + * placeholder), because this is not a delivery route. Records nothing. + */ + published(): Promise; read(): Promise; } diff --git a/packages/rsc-runtime/src/notices/index.ts b/packages/rsc-runtime/src/notices/index.ts index 21b52e58f..0b0c4c499 100644 --- a/packages/rsc-runtime/src/notices/index.ts +++ b/packages/rsc-runtime/src/notices/index.ts @@ -38,6 +38,7 @@ export type { AgentNoticePriority, AgentNoticePublishInput, AgentNoticePublishOptions, + AgentNoticePublisher, AgentNoticePublishResult, AgentNoticeRecordedPrincipal, AgentNoticeRequest, @@ -114,7 +115,9 @@ export { AGENT_NOTICE_STATE_VERSION, agentNoticeEventSchemas, agentNoticeStateDefinition, + noticePublisherOf, noticeSettledAt, + publisherMatchesPrincipal, recipientMatchesPrincipal, recordedNoticePrincipal, } from './state.js'; diff --git a/packages/rsc-runtime/src/notices/ledger.ts b/packages/rsc-runtime/src/notices/ledger.ts index 27a3d89e5..42c85bd81 100644 --- a/packages/rsc-runtime/src/notices/ledger.ts +++ b/packages/rsc-runtime/src/notices/ledger.ts @@ -65,6 +65,8 @@ import { import { agentNoticeEventSchemas, type AgentNoticeLedgerState, + noticePublisherOf, + publisherMatchesPrincipal, recipientMatchesPrincipal, recordedNoticePrincipal, } from './state.js'; @@ -353,6 +355,10 @@ const publishProgram = Effect.fnUntraced(function*( const id = `notice_${createHash('sha256') .update(canonicalJson({ idempotencyKey, recipient: target }), 'utf8') .digest('hex')}`; + // The publisher is identity the ledger already holds on the principal, + // recorded so the publishing agent can later read what became of its own + // notices; it is never matched for delivery. + const publisher = noticePublisherOf(request.principal); const notice: AgentNotice = Object.freeze({ attempts: Object.freeze([]), content: createAgentDocument(input.content as AgentDocument), @@ -362,6 +368,7 @@ const publishProgram = Effect.fnUntraced(function*( id, ...(nextAttemptAt === undefined ? {} : { nextAttemptAt }), priority: priority(input.priority), + ...(publisher === undefined ? {} : { publisher }), recipient: target, ...(retryBudget === undefined ? {} : { retryBudget }), // Persisted explicitly: only notices from before the redaction contract @@ -505,6 +512,37 @@ const inboxProgram = Effect.fnUntraced(function*( })); }); +/** + * The publisher's own view (#460): every notice whose recorded publisher this + * principal is, in whatever state it reached, with its receipts. It is a read + * with no route behind it, so it records nothing on the ledger and discloses + * content under the default `internal` ceiling only — the secret pass runs + * over `internal` text, `public` travels as authored, and `secret` content is + * the placeholder — never the host's wider advertisement, and never another + * publisher's or any recipient's notices. Authorization is judged once per + * notice under `phase: 'published'`; a refused notice is simply omitted. + */ +const publishedProgram = Effect.fnUntraced(function*( + store: NoticeStore, + authorize: AgentNoticeAuthorizer, + request: AgentNoticeRequest, +): Effect.fn.Return { + const snapshot = yield* storeEffect(() => store.read({ signal: request.signal })); + const own = snapshot.state.notices.filter((notice) => publisherMatchesPrincipal(notice.publisher, request.principal)); + const decisions = yield* Effect.forEach(own, (notice) => + authorizeEffect(authorize, { + noticeId: notice.id, + phase: 'published', + principal: request.principal, + recipient: notice.recipient, + }).pipe(Effect.map((decision) => ({ decision, notice })))); + return Object.freeze(decisions + .filter(({ decision }) => decision.state === 'authorized') + .map(({ notice }) => notice) + .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id)) + .map((notice) => currentlyDisclosedNotice(notice, 'mcp-inbox', undefined).notice)); +}); + const stateCounts = (notices: readonly AgentNotice[]): AgentNoticeLedgerInspection['counts'] => { const byState = Object.fromEntries(AGENT_NOTICE_STATES.map((state) => [state, 0])) as Record; let terminal = 0; @@ -749,6 +787,12 @@ export const createAgentNoticeLedger = ( return yield* publishProgram(store, options.authorize, request, input, publishOptions); })); }, + published() { + return runPromise(Effect.gen(function*() { + yield* noticeEffect(() => assertOpen(closed, request.signal)); + return yield* publishedProgram(store, options.authorize, request); + })); + }, read() { return runPromise(noticeEffect(() => { assertOpen(closed, request.signal); diff --git a/packages/rsc-runtime/src/notices/state.ts b/packages/rsc-runtime/src/notices/state.ts index 445f2b187..2fbdb588a 100644 --- a/packages/rsc-runtime/src/notices/state.ts +++ b/packages/rsc-runtime/src/notices/state.ts @@ -15,6 +15,7 @@ import { AGENT_NOTICE_AVAILABILITY_RESERVATION_TTL_MS, type AgentNotice, type AgentNoticePrincipal, + type AgentNoticePublisher, type AgentNoticeRecordedPrincipal, type AgentNoticeRetentionSummary, type AgentNoticeWithheldEntry, @@ -60,6 +61,20 @@ const recipientSchema = z.object({ 'A notice recipient requires at least one identity axis', ); +// Additive optional field on the notice: absent on notices published before +// it existed and on identity-less publishes; a recorded publisher names at +// least one axis, so an empty record can never match every reader. +const publisherSchema = z.object({ + actor: z.object({ id: z.string().min(1) }).strict().optional(), + conversation: z.string().min(1).optional(), + host: z.object({ name: z.string().min(1) }).strict().optional(), + session: z.object({ sessionId: z.string().min(1) }).strict().optional(), + workspace: z.object({ root: z.string().min(1) }).strict().optional(), +}).strict().refine( + (publisher) => Object.values(publisher).some((value) => value !== undefined), + 'A notice publisher requires at least one identity axis', +); + const lineageScopeSchema = z.object({ conversation: z.string().min(1), root: z.string().min(1), @@ -145,6 +160,7 @@ const noticeSchema = z.object({ id: z.string().min(1), nextAttemptAt: z.string().min(1).optional(), priority: z.enum(['low', 'normal', 'high']), + publisher: publisherSchema.optional(), recipient: recipientSchema, // Optional (not defaulted): parse must never materialize fields absent from // stored heads or the journal head-vs-replay consistency check would diverge @@ -271,6 +287,58 @@ export const recordedNoticePrincipal = (principal: AgentNoticePrincipal): AgentN workspace: principal.workspace, }); +/** + * The publisher `publish()` records: every identity axis the publishing + * request observed, in recipient terms. `undefined` when the request observed + * none, so the notice belongs to no `published()` view rather than to all. + */ +export const noticePublisherOf = (principal: AgentNoticePrincipal): AgentNoticePublisher | undefined => { + const publisher: AgentNoticePublisher = Object.freeze({ + ...(principal.actor.state === 'available' ? { actor: Object.freeze({ id: principal.actor.value.id }) } : {}), + ...(principal.lineage.state === 'available' ? { conversation: principal.lineage.value.conversation } : {}), + ...(principal.host.state === 'available' ? { host: Object.freeze({ name: principal.host.value.name }) } : {}), + ...(principal.session.state === 'available' + ? { session: Object.freeze({ sessionId: principal.session.value.sessionId }) } + : {}), + ...(principal.workspace.state === 'available' + ? { workspace: Object.freeze({ root: principal.workspace.value.root }) } + : {}), + }); + return Object.keys(publisher).length === 0 ? undefined : publisher; +}; + +/** + * Whether `principal` is the publisher a notice recorded. The lineage + * conversation is the identity of an agent thread (#444), so when the + * publisher recorded one the reader must resolve the same conversation — + * whatever transport observed it: a hook and an MCP tool call from the same + * agent differ in host name and session id but share the conversation. A + * publisher recorded without lineage is matched on every axis it did record; + * a reader missing any of them is not it. No publisher, no match. + */ +export const publisherMatchesPrincipal = ( + publisher: AgentNoticePublisher | undefined, + principal: AgentNoticePrincipal, +): boolean => { + if (publisher === undefined) return false; + if (publisher.conversation !== undefined) { + return principal.lineage.state === 'available' && principal.lineage.value.conversation === publisher.conversation; + } + if (publisher.actor !== undefined) { + if (principal.actor.state !== 'available' || principal.actor.value.id !== publisher.actor.id) return false; + } + if (publisher.host !== undefined) { + if (principal.host.state !== 'available' || principal.host.value.name !== publisher.host.name) return false; + } + if (publisher.session !== undefined) { + if (principal.session.state !== 'available' || principal.session.value.sessionId !== publisher.session.sessionId) return false; + } + if (publisher.workspace !== undefined) { + if (principal.workspace.state !== 'available' || principal.workspace.value.root !== publisher.workspace.root) return false; + } + return true; +}; + /** * Every axis the recipient names must match an available axis of the * principal; an unavailable axis — including lineage the runtime could not @@ -640,7 +708,8 @@ export const agentNoticeStateDefinition = ( // version: a journal without them replays to the same state, because an // admission that recorded no lineage matches exactly the recipients it // matched when it was written, and no persisted notice names an axis it - // did not have. + // did not have. `publisher` (#460) is additive in the same way: it scopes + // only the publisher's own `published()` read and never a transition. migrations: { 2: (persisted) => persisted }, reduce: (state, event) => { switch (event.name) { diff --git a/packages/rsc-runtime/tests/notices-ledger.test.ts b/packages/rsc-runtime/tests/notices-ledger.test.ts index 36e595985..3b98bbe1d 100644 --- a/packages/rsc-runtime/tests/notices-ledger.test.ts +++ b/packages/rsc-runtime/tests/notices-ledger.test.ts @@ -12,10 +12,14 @@ import { selectNoticeDeliveryRoutes, agentNoticeStateDefinition, createAgentNoticeLedger, + NOTICE_REDACTION_MARK, + noticePublisherOf, + publisherMatchesPrincipal, recipientMatchesPrincipal, recordedNoticePrincipal, type AgentNoticeAuthorizationRequest, type AgentNoticePrincipal, + type AgentNoticePublishInput, type AgentNoticeState, type AgentRecipient, } from '../src/notices/index.js'; @@ -1727,3 +1731,281 @@ describe('lineage-addressed recipients (#458)', () => { await driver.close(); }); }); + +describe('publisher-scoped visibility (#460)', () => { + const principalOf = (overrides: Partial): AgentNoticePrincipal => ({ + actor: unavailable(), + host, + lineage: unavailable('not-provided'), + session, + workspace, + ...overrides, + }); + /** A request from a different transport for the same agent: MCP client name and session id, server cwd. */ + const mcpCallOf = (conversation: string): Partial => ({ + host: available({ name: 'claude-code' }, 'native'), + lineage: lineageOf(conversation), + session: available({ sessionId: 'mcp-session-9' }, 'native'), + workspace: available({ root: '/server-cwd' }, 'derived'), + }); + + it('records every observed identity axis of the publisher, or nothing for an identity-less request', () => { + expect(noticePublisherOf(principalOf({ actor: actor('a1'), lineage: lineageOf('agent-a') }))).toEqual({ + actor: { id: 'a1' }, + conversation: 'agent-a', + host: { name: 'claude' }, + session: { sessionId: 'session-1' }, + workspace: { root: '/workspace' }, + }); + expect(noticePublisherOf(principalOf({ host: unavailable(), session: unavailable(), workspace: unavailable() }))) + .toBeUndefined(); + expect(publisherMatchesPrincipal(undefined, principalOf({}))).toBe(false); + }); + + it('matches the publisher by lineage conversation first, else by every recorded axis', () => { + const withLineage = noticePublisherOf(principalOf({ lineage: lineageOf('agent-a') })); + // Same agent thread observed by another transport: host, session, and workspace all differ. + expect(publisherMatchesPrincipal(withLineage, principalOf(mcpCallOf('agent-a')))).toBe(true); + // Same host/session/workspace, different agent thread (a sibling under the same root). + expect(publisherMatchesPrincipal(withLineage, principalOf({ lineage: lineageOf('agent-b') }))).toBe(false); + // Lineage the reader could not resolve is never the publisher. + expect(publisherMatchesPrincipal(withLineage, principalOf({}))).toBe(false); + + const withoutLineage = noticePublisherOf(principalOf({ actor: actor('a1') })); + expect(publisherMatchesPrincipal(withoutLineage, principalOf({ actor: actor('a1') }))).toBe(true); + expect(publisherMatchesPrincipal(withoutLineage, principalOf({ actor: actor('a1'), lineage: lineageOf('agent-a') }))).toBe(true); + expect(publisherMatchesPrincipal(withoutLineage, principalOf({ actor: actor('a2') }))).toBe(false); + expect(publisherMatchesPrincipal(withoutLineage, principalOf({}))).toBe(false); + expect(publisherMatchesPrincipal(withoutLineage, principalOf({ actor: actor('a1'), workspace: unavailable() }))).toBe(false); + expect(publisherMatchesPrincipal(withoutLineage, principalOf({ + actor: actor('a1'), + session: available({ sessionId: 'session-2' }, 'native'), + }))).toBe(false); + }); + + it('shows the publisher its own notice through every state while recipients and bystanders see nothing of it', async () => { + const phases: string[] = []; + const { driver, ledger } = await openLedger((request) => { + phases.push(request.phase); + return { state: 'authorized' }; + }); + const published = await run(ledger, { + actorId: 'agent-b', + id: 'publish-own', + kind: 'tool', + lineage: lineageOf('agent-b'), + startedAt: '2026-09-01T19:00:00.000Z', + }, async () => (await agent()).notices!.publish({ + content: document('for agent-a'), + priority: 'high', + recipient: { conversation: 'agent-a' }, + }, { idempotencyKey: 'publish:own' })); + expect(published.notice.publisher).toEqual({ + actor: { id: 'agent-b' }, + conversation: 'agent-b', + host: { name: 'claude' }, + session: { sessionId: 'session-1' }, + workspace: { root: '/workspace' }, + }); + const revisionAfterPublish = (await ledger.read()).revision; + + // The publisher: pending, and its own inbox never shows it (it is not the recipient). + const seenPending = await run(ledger, { + actorId: 'agent-b', + id: 'published-1', + kind: 'tool', + lineage: lineageOf('agent-b'), + startedAt: '2026-09-01T19:00:30.000Z', + }, async () => ({ + inbox: await (await agent()).notices!.inbox(), + published: await (await agent()).notices!.published(), + })); + expect(seenPending.inbox).toEqual([]); + expect(seenPending.published).toEqual([expect.objectContaining({ id: published.notice.id, state: 'pending' })]); + expect(seenPending.published[0]?.content.root).toEqual({ kind: 'text', text: 'for agent-a' }); + // A read, not a receipt: nothing moved on the ledger. + expect((await ledger.read()).revision).toBe(revisionAfterPublish); + expect((await ledger.read()).notices[0]).not.toHaveProperty('exposure'); + + // The recipient sees it in its inbox, but not as something it published; + // a sibling under the same root sees neither. + const recipientView = await run(ledger, { + actorId: 'agent-a', + id: 'recipient-view', + kind: 'tool', + lineage: lineageOf('agent-a'), + startedAt: '2026-09-01T19:01:00.000Z', + }, async () => ({ + inbox: await (await agent()).notices!.inbox(), + published: await (await agent()).notices!.published(), + })); + expect(recipientView.inbox.map((notice) => notice.id)).toEqual([published.notice.id]); + expect(recipientView.published).toEqual([]); + expect(await run(ledger, { + actorId: 'agent-c', + id: 'bystander-view', + kind: 'tool', + lineage: lineageOf('agent-c'), + startedAt: '2026-09-01T19:01:10.000Z', + }, async () => (await agent()).notices!.published())).toEqual([]); + + // Admission on the recipient's next event: the publisher now sees `attempted`. + await run(ledger, { + actorId: 'agent-a', + id: 'event-agent-a', + kind: 'event', + lineage: lineageOf('agent-a'), + startedAt: '2026-09-01T19:02:00.000Z', + }, async () => (await agent()).notices!.read()); + const seenAttempted = await run(ledger, { + actorId: 'agent-b', + id: 'published-2', + kind: 'tool', + lineage: lineageOf('agent-b'), + startedAt: '2026-09-01T19:02:30.000Z', + }, async () => (await agent()).notices!.published()); + expect(seenAttempted).toEqual([expect.objectContaining({ + attempts: [expect.objectContaining({ invocationId: 'event-agent-a' })], + id: published.notice.id, + state: 'attempted', + })]); + + // The recipient acknowledges; the publisher sees `acknowledged` — from an + // MCP tool call whose host name, session id, and cwd all differ from the + // hook that published, because the conversation is the identity. + await run(ledger, { + actorId: 'agent-a', + id: 'ack-agent-a', + kind: 'tool', + lineage: lineageOf('agent-a'), + startedAt: '2026-09-01T19:03:00.000Z', + }, async () => (await agent()).notices!.acknowledge(published.notice.id)); + const seenAcknowledged = await runAgentRequest({ + ...mcpCallOf('agent-b'), + actor: unavailable(), + invocation: { id: 'published-3', kind: 'tool', startedAt: '2026-09-01T19:03:30.000Z' }, + noticeLedger: ledger, + }, async () => (await agent()).notices!.published()); + expect(seenAcknowledged).toEqual([expect.objectContaining({ + acknowledgement: expect.objectContaining({ invocationId: 'ack-agent-a' }), + id: published.notice.id, + state: 'acknowledged', + })]); + // Judged once per matching notice; the recipient's and bystander's reads + // matched nothing, so nothing was put to the authorizer for them. + expect(phases.filter((phase) => phase === 'published')).toHaveLength(3); + await driver.close(); + }); + + it('omits notices the authorizer refuses under phase published, and never returns identity-less publishes', async () => { + const { driver, ledger } = await openLedger((request) => + request.phase === 'published' && request.recipient.session?.sessionId === 'refused' + ? { state: 'unavailable' } + : { state: 'authorized' }); + await run(ledger, { + actorId: 'publisher', + id: 'publish-allowed', + kind: 'tool', + startedAt: '2026-09-01T19:00:00.000Z', + }, async () => (await agent()).notices!.publish({ + content: document('allowed'), + priority: 'normal', + recipient: { session: { sessionId: 'allowed' } }, + }, { idempotencyKey: 'publish:allowed' })); + await run(ledger, { + actorId: 'publisher', + id: 'publish-refused', + kind: 'tool', + startedAt: '2026-09-01T19:00:01.000Z', + }, async () => (await agent()).notices!.publish({ + content: document('refused'), + priority: 'normal', + recipient: { session: { sessionId: 'refused' } }, + }, { idempotencyKey: 'publish:refused' })); + // An identity-less publisher records no publisher at all. + const anonymous = await runAgentRequest({ + invocation: { id: 'publish-anonymous', kind: 'tool', startedAt: '2026-09-01T19:00:02.000Z' }, + noticeLedger: ledger, + }, async () => (await agent()).notices!.publish({ + content: document('anonymous'), + priority: 'normal', + recipient: { session: { sessionId: 'allowed' } }, + }, { idempotencyKey: 'publish:anonymous' })); + expect(anonymous.notice).not.toHaveProperty('publisher'); + + const own = await run(ledger, { + actorId: 'publisher', + id: 'published-filtered', + kind: 'tool', + startedAt: '2026-09-01T19:01:00.000Z', + }, async () => (await agent()).notices!.published()); + expect(own.map((notice) => notice.recipient)).toEqual([{ session: { sessionId: 'allowed' } }]); + expect(await runAgentRequest({ + invocation: { id: 'published-anonymous', kind: 'tool', startedAt: '2026-09-01T19:01:01.000Z' }, + noticeLedger: ledger, + }, async () => (await agent()).notices!.published())).toEqual([]); + await driver.close(); + }); + + it('discloses published content under the default internal ceiling and never another author\'s deduped content', async () => { + const { driver, ledger } = await openLedger(); + const publishAs = (actorId: string, id: string, input: AgentNoticePublishInput) => + run(ledger, { actorId, id, kind: 'tool', startedAt: `2026-09-01T19:00:0${id.length % 10}.000Z` }, + async () => (await agent()).notices!.publish(input, { idempotencyKey: `publish:${id}` })); + + const secretText = await publishAs('author', 'internal', { + content: document('deploy with token=abcdef0123456789 tonight'), + priority: 'normal', + recipient: { actor: { id: 'recipient' } }, + }); + const classified = await publishAs('author', 'secret', { + content: document('the whole document is secret'), + priority: 'normal', + recipient: { actor: { id: 'recipient' } }, + sensitivity: 'secret', + }); + const open = await publishAs('author', 'public', { + content: document('public token=abcdef0123456789 stays as authored'), + priority: 'normal', + recipient: { actor: { id: 'recipient' } }, + sensitivity: 'public', + }); + // Another author publishing the same dedupe key for the same recipient + // lands on the first author's notice; it is not theirs to read back. + await publishAs('author', 'shared', { + content: document('first author wrote this'), + dedupeKey: 'shared-key', + priority: 'normal', + recipient: { actor: { id: 'recipient' } }, + }); + const deduped = await publishAs('other-author', 'shared-again', { + content: document('second author'), + dedupeKey: 'shared-key', + priority: 'normal', + recipient: { actor: { id: 'recipient' } }, + }); + expect(deduped.deduped).toBe(true); + + const own = await run(ledger, { + actorId: 'author', + id: 'published-disclosure', + kind: 'tool', + startedAt: '2026-09-01T19:05:00.000Z', + }, async () => (await agent()).notices!.published()); + const text = (id: string) => { + const found = own.find((notice) => notice.id === id); + return found?.content.root.kind === 'text' ? found.content.root.text : undefined; + }; + expect(text(secretText.notice.id)).toBe(`deploy with ${NOTICE_REDACTION_MARK} tonight`); + expect(text(classified.notice.id)).toBe(NOTICE_REDACTION_MARK); + expect(text(open.notice.id)).toBe('public token=abcdef0123456789 stays as authored'); + expect(own).toHaveLength(4); + expect(await run(ledger, { + actorId: 'other-author', + id: 'published-other', + kind: 'tool', + startedAt: '2026-09-01T19:05:01.000Z', + }, async () => (await agent()).notices!.published())).toEqual([]); + await driver.close(); + }); +}); diff --git a/website/plugins/generated-reference.ts b/website/plugins/generated-reference.ts index a24b93562..be1051865 100644 --- a/website/plugins/generated-reference.ts +++ b/website/plugins/generated-reference.ts @@ -209,6 +209,9 @@ const messages = { ['`root`', '`request.lineage.root`', 'The root conversation and every subagent whose lineage root it is — the publisher included when it is under that root.'], ], recipientAxisHeaders: ['Axis', 'Matched against', 'Reaches'], + publisherView: 'Publisher view', + publisherViewIntro: + 'A recipient never sees another recipient\'s notices, and a publisher does not see a recipient view either. What a publisher gets is `notices.published()`: the notices its own principal published, in every state, with their receipts. `publish()` records the publishing request\'s observed axes on the notice (`publisher`: `actor`, `host`, `session`, `workspace`, and `conversation` from `request.lineage`); a reader is the publisher when it resolves the same lineage conversation — so the hook that published and the MCP tool call that asks agree even though host name, session id, and cwd differ — or, for a publisher recorded without lineage, when every recorded axis matches. The view records nothing on the ledger, is judged per notice under authorization phase `published`, and discloses content under the default `internal` ceiling (`internal` secret-passed, `public` as authored, `secret` as the placeholder). A notice published by a request that observed no identity belongs to no view.', noticeChannels: 'Delivery channels', unavailableChannels: 'Why a channel is unavailable', sensitivityCeilings: 'Sensitivity ceilings', @@ -302,6 +305,9 @@ const messages = { ['`root`', '`request.lineage.root`', '根会话以及谱系根为它的每个子代理——发布者若位于该根之下,也包括在内。'], ], recipientAxisHeaders: ['轴', '匹配对象', '到达范围'], + publisherView: '发布者视图', + publisherViewIntro: + '接收者永远看不到其他接收者的通知,发布者也不会获得接收者视图。发布者得到的是 `notices.published()`:其自身主体发布的通知,涵盖所有状态并附带回执。`publish()` 会把发布请求观测到的身份轴记录在通知上(`publisher`:`actor`、`host`、`session`、`workspace`,以及来自 `request.lineage` 的 `conversation`);当读取方解析出相同的谱系会话时即为发布者——因此发布通知的钩子与发起查询的 MCP 工具调用即便宿主名、会话 id 与 cwd 各不相同也能对上——若发布者记录时没有谱系,则要求记录的每个轴都匹配。该视图不会在账本上记录任何内容,按通知逐条经授权阶段 `published` 判定,并且只在默认的 `internal` 上限下披露内容(`internal` 经过密钥脱敏,`public` 按原文,`secret` 为占位符)。由未观测到任何身份的请求发布的通知不属于任何视图。', noticeChannels: '投递通道', unavailableChannels: '通道不可用的原因', sensitivityCeilings: '敏感度上限', @@ -687,6 +693,9 @@ function renderNotices(hosts: readonly HostCapabilityTable[], m: Messages): stri sections.push(m.recipientAxesIntro); sections.push(table(m.recipientAxisHeaders, m.recipientAxisRows)); + sections.push(`## ${m.publisherView}\n`); + sections.push(m.publisherViewIntro); + sections.push(`## ${m.noticeChannels}\n`); const channels = unionKeys(hosts, data => asObject(data.noticeDelivery)); sections.push( From 6a92e93d79e1285d3c625743e978034dca7e2f5d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 07:11:21 +0000 Subject: [PATCH 2/3] fix(notices): tolerate four-axis principals in publisher recording and matching --- packages/rsc-runtime/src/notices/state.ts | 4 ++-- packages/rsc-runtime/tests/notices-ledger.test.ts | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/rsc-runtime/src/notices/state.ts b/packages/rsc-runtime/src/notices/state.ts index 2fbdb588a..4e27d3719 100644 --- a/packages/rsc-runtime/src/notices/state.ts +++ b/packages/rsc-runtime/src/notices/state.ts @@ -295,7 +295,7 @@ export const recordedNoticePrincipal = (principal: AgentNoticePrincipal): AgentN export const noticePublisherOf = (principal: AgentNoticePrincipal): AgentNoticePublisher | undefined => { const publisher: AgentNoticePublisher = Object.freeze({ ...(principal.actor.state === 'available' ? { actor: Object.freeze({ id: principal.actor.value.id }) } : {}), - ...(principal.lineage.state === 'available' ? { conversation: principal.lineage.value.conversation } : {}), + ...(principal.lineage?.state === 'available' ? { conversation: principal.lineage.value.conversation } : {}), ...(principal.host.state === 'available' ? { host: Object.freeze({ name: principal.host.value.name }) } : {}), ...(principal.session.state === 'available' ? { session: Object.freeze({ sessionId: principal.session.value.sessionId }) } @@ -322,7 +322,7 @@ export const publisherMatchesPrincipal = ( ): boolean => { if (publisher === undefined) return false; if (publisher.conversation !== undefined) { - return principal.lineage.state === 'available' && principal.lineage.value.conversation === publisher.conversation; + return principal.lineage?.state === 'available' && principal.lineage.value.conversation === publisher.conversation; } if (publisher.actor !== undefined) { if (principal.actor.state !== 'available' || principal.actor.value.id !== publisher.actor.id) return false; diff --git a/packages/rsc-runtime/tests/notices-ledger.test.ts b/packages/rsc-runtime/tests/notices-ledger.test.ts index 3b98bbe1d..8d70e40f5 100644 --- a/packages/rsc-runtime/tests/notices-ledger.test.ts +++ b/packages/rsc-runtime/tests/notices-ledger.test.ts @@ -1773,6 +1773,10 @@ describe('publisher-scoped visibility (#460)', () => { const withoutLineage = noticePublisherOf(principalOf({ actor: actor('a1') })); expect(publisherMatchesPrincipal(withoutLineage, principalOf({ actor: actor('a1') }))).toBe(true); + // A four-axis principal (no `lineage` key at all) records and matches by its axes. + expect(noticePublisherOf({ actor: actor('a1'), host, session, workspace })).toEqual(withoutLineage); + expect(publisherMatchesPrincipal(withoutLineage, { actor: actor('a1'), host, session, workspace })).toBe(true); + expect(publisherMatchesPrincipal(withLineage, { actor: actor('a1'), host, session, workspace })).toBe(false); expect(publisherMatchesPrincipal(withoutLineage, principalOf({ actor: actor('a1'), lineage: lineageOf('agent-a') }))).toBe(true); expect(publisherMatchesPrincipal(withoutLineage, principalOf({ actor: actor('a2') }))).toBe(false); expect(publisherMatchesPrincipal(withoutLineage, principalOf({}))).toBe(false); From 61e7b403f9a20a8b9d570b12adee3c9ec27a9c25 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 07:28:42 +0000 Subject: [PATCH 3/3] fix(notices): order published() by parsed createdAt instant --- packages/rsc-runtime/src/notices/ledger.ts | 5 ++++- .../rsc-runtime/tests/notices-ledger.test.ts | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/rsc-runtime/src/notices/ledger.ts b/packages/rsc-runtime/src/notices/ledger.ts index 42c85bd81..179528a34 100644 --- a/packages/rsc-runtime/src/notices/ledger.ts +++ b/packages/rsc-runtime/src/notices/ledger.ts @@ -536,10 +536,13 @@ const publishedProgram = Effect.fnUntraced(function*( principal: request.principal, recipient: notice.recipient, }).pipe(Effect.map((decision) => ({ decision, notice })))); + // Chronological by instant, not by string: `createdAt` is whatever valid + // ISO-8601 the publishing invocation started with, offsets included. return Object.freeze(decisions .filter(({ decision }) => decision.state === 'authorized') .map(({ notice }) => notice) - .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id)) + .toSorted((left, right) => + Date.parse(left.createdAt) - Date.parse(right.createdAt) || left.id.localeCompare(right.id)) .map((notice) => currentlyDisclosedNotice(notice, 'mcp-inbox', undefined).notice)); }); diff --git a/packages/rsc-runtime/tests/notices-ledger.test.ts b/packages/rsc-runtime/tests/notices-ledger.test.ts index 8d70e40f5..2a0510e68 100644 --- a/packages/rsc-runtime/tests/notices-ledger.test.ts +++ b/packages/rsc-runtime/tests/notices-ledger.test.ts @@ -1951,6 +1951,27 @@ describe('publisher-scoped visibility (#460)', () => { await driver.close(); }); + it('orders published notices by the instant they were created, whatever offset the invocation spelled', async () => { + const { driver, ledger } = await openLedger(); + const publishAt = (id: string, startedAt: string) => run(ledger, { actorId: 'author', id, kind: 'tool', startedAt }, + async () => (await agent()).notices!.publish({ + content: document(id), + priority: 'normal', + recipient: { actor: { id: 'recipient' } }, + }, { idempotencyKey: `publish:${id}` })); + // Lexically the +02:00 stamp sorts after the Z stamp; chronologically it is 90 minutes earlier. + const later = await publishAt('later', '2026-01-01T00:30:00.000Z'); + const earlier = await publishAt('earlier', '2026-01-01T01:00:00.000+02:00'); + const own = await run(ledger, { + actorId: 'author', + id: 'published-order', + kind: 'tool', + startedAt: '2026-01-01T03:00:00.000Z', + }, async () => (await agent()).notices!.published()); + expect(own.map((notice) => notice.id)).toEqual([earlier.notice.id, later.notice.id]); + await driver.close(); + }); + it('discloses published content under the default internal ceiling and never another author\'s deduped content', async () => { const { driver, ledger } = await openLedger(); const publishAs = (actorId: string, id: string, input: AgentNoticePublishInput) =>