Skip to content

feat(notices): publisher-scoped notices.published() with per-notice state (#460) - #541

Merged
ScriptedAlchemy merged 5 commits into
mainfrom
feat/460-published-notices
Sep 4, 2026
Merged

feat(notices): publisher-scoped notices.published() with per-notice state (#460)#541
ScriptedAlchemy merged 5 commits into
mainfrom
feat/460-published-notices

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Closes #460. Was stacked on #539 (#458), which merged as a2d1795aa; this PR is now rebased onto main (own commits only: cfaf4c70b, 6a92e93d7, 61e7b403f) and retargeted to main.

Design question → the narrow answer (option A, #264-compatible)

A publisher gets exactly one new thing: its own notices back, with their state and receipts. No whole-ledger read reaches a route, inbox() and read() are unchanged, and cross-recipient reads stay structurally impossible.

  • AgentNotice.publisher?: AgentNoticePublisher (packages/rsc-runtime/src/notices/contract.ts) — recorded by publish() from the publishing principal's observed axes, spelled like a recipient: actor?, host?, session?, workspace?, and conversation? (request.lineage.conversation). A request that observed no identity records no publisher, so that notice belongs to no view rather than to every view. Never matched for delivery.
  • AgentNoticesHandle.published(): Promise<readonly AgentNotice[]> (ledger.ts publishedProgram) — the principal's own notices in every ledger state (pending | attempted | acknowledged | expired | unavailable | withdrawn) with attempts, acknowledgement, exposure, availability, withheld. Sorted by createdAt.
  • Who is "the publisher" (state.ts publisherMatchesPrincipal): when the recorded publisher has a conversation, the reader must resolve the same lineage conversation — that is the identity of an agent thread (feat(lineage): request.lineage is the only identity-adjacent surface; scoped, self-correcting Cursor child binding #444), and it is what makes the coordinator case work: the hook that published (host claude, hook session_id, worktree cwd) and the MCP tool call that asks (client name claude-code, MCP session id, server cwd) differ on every other axis but share the conversation. A publisher recorded without lineage (standalone hook, no shared runtime) is matched on every axis it did record; a reader missing any of them is not it. Unresolved lineage is nobody's publisher. This is the "session/workspace/plugin — the same axes the ledger already stores" scope from the brief, with the conversation taking precedence when both sides have it; had I required all recorded axes to match, coordinator/status could never see hook-published notices from an MCP call.
  • Authorization: new phase: 'published' on AgentNoticeAuthorizationRequest, judged once per matching notice (like read); a refused notice is omitted. The generated v1 authorizer admits it like every other phase.
  • Redaction (feat(notices): redaction contract and retention policy — close out #99 acceptance item 7 #437): published() is a read with no delivery route behind it, so it discloses under the default internal ceiling only — internal content secret-passed, public as authored, secret as the placeholder — never the host's wider advertisement. A notice deduplicated onto another author's is never returned to the second author.
  • It records nothing: no exposure/attempt receipts, revision unchanged. attempted stays the strongest cross-actor state.
  • Migration decision — no version bump. publisher is an additive optional field without default (same reasoning as retryBudget/sensitivity/Let notices target a lineage conversation: recipient.conversation / recipient.root matched against request.lineage #458's axes): pre-Design: publisher-scoped notice visibility — let a route read the state of notices it published #460 heads parse unchanged, replay cannot diverge, and no reducer transition reads it. AGENT_NOTICE_STATE_VERSION stays 2 (state.ts comment updated).
  • examples/worktree-proximity: coordinator/status gains notices: { state, total, pending, attempted, acknowledged, expired, unavailable, withdrawn, reason? } from (await agent()).notices.published() — the calling agent's own counts, never the ledger's total; a call whose lineage the runtime cannot resolve honestly counts zero. Markdown line added. README's "reports topology facts only" paragraph replaced.
  • Docs: generated notices reference page gains a "Publisher view" section (en + zh, website/plugins/generated-reference.ts); runtime README notices section. Changeset .changeset/460-published-notices.md (patch, additive; only @agent-bundle/runtime ships a change — agent-bundle has no source change in this PR).

Explicitly not done, as the issue asked: AgentNoticeLedger.read() is not exposed on the request handle; no aggregate summary() (option B) — it would depend on the lineage axis anyway and be strictly less informative than the publisher's own list.

Tests

  • packages/rsc-runtime/tests/notices-ledger.test.tspublisher-scoped visibility (#460): publisher recording (all observed axes / none for identity-less); conversation-first vs all-axes matching incl. four-axis principals; publisher sees pending → attempted (after recipient admission) → acknowledged (after recipient ack, read from an MCP-shaped principal with different host/session/workspace) while its own inbox never shows it, the recipient's published() is empty and a sibling sees neither; a read moves no revision and records no exposure; phase: 'published' judged per matching notice, refused notices omitted, identity-less publishes returned to nobody; disclosure (internal secret-passed, secret placeholder, public authored) and no read-back of another author's deduped notice.
  • packages/agent-bundle/tests/route-unit/published-notices.test.ts — the issue's acceptance scenario through the route-unit harness: A publishes to B, B's event admits, A's published() reads attempted (from a tool call with different host/session/cwd), B's inbox() is empty, A's inbox() never showed it, a sibling and an unresolved-lineage caller see nothing.
  • examples/worktree-proximity/tests/route-unit/routes.test.ts — coordinator status reports pending 1 → attempted 1 for the publishing agent (MCP-shaped principal, conversation-correlated), 0 for the recipient and for an identity-less caller; the same states through the raw handles.
  • packages/agent-bundle/tests/worktree-proximity-journeys.test.ts — status result gains the notices key (exact key-set assertion updated); the uncorrelated client call counts zero. Conversation-correlated _meta status calls are covered in the route-unit suite rather than cross-process.

Verification (on the rebased head)

  • pnpm typecheck clean; pnpm lint 0 errors / 0 warnings
  • pnpm test:unit 3264 passed; pnpm test:route-unit 65 passed
  • packages/rsc-runtime: rstest tests/notices-*.test.ts 104 passed
  • examples/worktree-proximity: pnpm typecheck, pnpm test:routes 13 passed
  • rstest --config rstest.integration.config.ts packages/agent-bundle/tests/worktree-proximity-journeys.test.ts passed
  • pnpm docs:site:build clean (dead-link, anchor, image, language-parity checks)

Review status

  • Codex reviewed b7e8b4709 — one P2 thread on ledger.ts (published() sorted createdAt lexicographically, which misorders valid ISO-8601 stamps with different offsets). Fixed in 4c104e11d: the comparator is Date.parse(left.createdAt) - Date.parse(right.createdAt), id as tie-break; a ledger test publishes a +02:00 stamp that sorts lexically after but chronologically before a Z stamp and asserts the chronological order. Answered here rather than on-thread by instruction.
  • Rebased onto main after feat(notices): address a notice to a lineage conversation or root (#458) #539 landed (61e7b403f, then gh pr update-branch merge commit 36367d82a); the rebase replayed the same three commits with no conflicts and no content change beyond what feat(notices): address a notice to a lineage conversation or root (#458) #539 already merged. No PR comments are posted by instruction, so no @codex review was requested on the rebased head; the last reviewed head is 4c104e11d (P2 addressed), unreviewed heads are 61e7b403f/36367d82a. Landing via --squash --auto on green required checks.

@changeset-bot

changeset-bot Bot commented Sep 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 36367d8

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@agent-bundle/runtime Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-04T07:14:38.798010Z b7e8b47 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@pkg-pr-new

pkg-pr-new Bot commented Sep 4, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle@541
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/create-agent-bundle@541
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@541

commit: 4b494b8

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b7e8b4709f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Sort published notices by parsed timestamp

When callers supply valid ISO-8601 startedAt values with different offsets or precision, lexicographic comparison does not preserve chronological order. For example, 2026-01-01T01:00:00+02:00 occurred before 2026-01-01T00:30:00Z but this comparator returns them in the opposite order, so published() violates its created-time ordering for supported invocation timestamps; compare Date.parse(createdAt) values or normalize timestamps before storing them.

Useful? React with 👍 / 👎.

@ScriptedAlchemy
ScriptedAlchemy force-pushed the feat/458-lineage-notices branch from f787c44 to 710fd25 Compare September 4, 2026 07:35
@ScriptedAlchemy
ScriptedAlchemy force-pushed the feat/460-published-notices branch from 4c104e1 to e682dcd Compare September 4, 2026 07:38
@ScriptedAlchemy
ScriptedAlchemy force-pushed the feat/458-lineage-notices branch from 710fd25 to 530997a Compare September 4, 2026 08:14
@ScriptedAlchemy
ScriptedAlchemy force-pushed the feat/460-published-notices branch from e682dcd to d877881 Compare September 4, 2026 08:14
@ScriptedAlchemy
ScriptedAlchemy force-pushed the feat/458-lineage-notices branch from 530997a to 06f7754 Compare September 4, 2026 08:45
@ScriptedAlchemy
ScriptedAlchemy force-pushed the feat/460-published-notices branch from d877881 to 7c9a58d Compare September 4, 2026 08:45
…tate (#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.
@ScriptedAlchemy
ScriptedAlchemy changed the base branch from feat/458-lineage-notices to main September 4, 2026 10:54
@ScriptedAlchemy
ScriptedAlchemy changed the base branch from feat/458-lineage-notices to main September 4, 2026 10:54
@ScriptedAlchemy
ScriptedAlchemy changed the base branch from feat/458-lineage-notices to main September 4, 2026 10:54
@ScriptedAlchemy
ScriptedAlchemy force-pushed the feat/460-published-notices branch from 7c9a58d to 61e7b40 Compare September 4, 2026 10:54
@ScriptedAlchemy
ScriptedAlchemy enabled auto-merge (squash) September 4, 2026 10:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Design: publisher-scoped notice visibility — let a route read the state of notices it published

1 participant