feat(notices): publisher-scoped notices.published() with per-notice state (#460) - #541
Conversation
🦋 Changeset detectedLatest commit: 36367d8 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
commit: |
There was a problem hiding this comment.
💡 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)) |
There was a problem hiding this comment.
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 👍 / 👎.
f787c44 to
710fd25
Compare
4c104e1 to
e682dcd
Compare
710fd25 to
530997a
Compare
e682dcd to
d877881
Compare
530997a to
06f7754
Compare
d877881 to
7c9a58d
Compare
…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.
7c9a58d to
61e7b40
Compare
Closes #460. Was stacked on #539 (#458), which merged as
a2d1795aa; this PR is now rebased ontomain(own commits only:cfaf4c70b,6a92e93d7,61e7b403f) and retargeted tomain.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()andread()are unchanged, and cross-recipient reads stay structurally impossible.AgentNotice.publisher?: AgentNoticePublisher(packages/rsc-runtime/src/notices/contract.ts) — recorded bypublish()from the publishing principal's observed axes, spelled like a recipient:actor?,host?,session?,workspace?, andconversation?(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.tspublishedProgram) — the principal's own notices in every ledger state (pending | attempted | acknowledged | expired | unavailable | withdrawn) withattempts,acknowledgement,exposure,availability,withheld. Sorted bycreatedAt.state.tspublisherMatchesPrincipal): when the recorded publisher has aconversation, 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 (hostclaude, hooksession_id, worktreecwd) and the MCP tool call that asks (client nameclaude-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/statuscould never see hook-published notices from an MCP call.phase: 'published'onAgentNoticeAuthorizationRequest, judged once per matching notice (likeread); a refused notice is omitted. The generated v1 authorizer admits it like every other phase.published()is a read with no delivery route behind it, so it discloses under the defaultinternalceiling only —internalcontent secret-passed,publicas authored,secretas the placeholder — never the host's wider advertisement. A notice deduplicated onto another author's is never returned to the second author.attemptedstays the strongest cross-actor state.publisheris an additive optional field without default (same reasoning asretryBudget/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_VERSIONstays 2 (state.tscomment updated).examples/worktree-proximity:coordinator/statusgainsnotices: { 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.website/plugins/generated-reference.ts); runtime README notices section. Changeset.changeset/460-published-notices.md(patch, additive; only@agent-bundle/runtimeships a change —agent-bundlehas no source change in this PR).Explicitly not done, as the issue asked:
AgentNoticeLedger.read()is not exposed on the request handle; no aggregatesummary()(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.ts—publisher-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'spublished()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 (internalsecret-passed,secretplaceholder,publicauthored) 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'spublished()readsattempted(from a tool call with different host/session/cwd), B'sinbox()is empty, A'sinbox()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 thenoticeskey (exact key-set assertion updated); the uncorrelated client call counts zero. Conversation-correlated_metastatus calls are covered in the route-unit suite rather than cross-process.Verification (on the rebased head)
pnpm typecheckclean;pnpm lint0 errors / 0 warningspnpm test:unit3264 passed;pnpm test:route-unit65 passedpackages/rsc-runtime:rstest tests/notices-*.test.ts104 passedexamples/worktree-proximity:pnpm typecheck,pnpm test:routes13 passedrstest --config rstest.integration.config.ts packages/agent-bundle/tests/worktree-proximity-journeys.test.tspassedpnpm docs:site:buildclean (dead-link, anchor, image, language-parity checks)Review status
b7e8b4709— one P2 thread onledger.ts(published()sortedcreatedAtlexicographically, which misorders valid ISO-8601 stamps with different offsets). Fixed in4c104e11d: the comparator isDate.parse(left.createdAt) - Date.parse(right.createdAt), id as tie-break; a ledger test publishes a+02:00stamp that sorts lexically after but chronologically before aZstamp and asserts the chronological order. Answered here rather than on-thread by instruction.mainafter feat(notices): address a notice to a lineage conversation or root (#458) #539 landed (61e7b403f, thengh pr update-branchmerge commit36367d82a); 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 reviewwas requested on the rebased head; the last reviewed head is4c104e11d(P2 addressed), unreviewed heads are61e7b403f/36367d82a. Landing via--squash --autoon green required checks.