Skip to content

One declaration per bus read: UI, MCP and owner-socket reads derive one operation - #3538

Closed
trissim wants to merge 39 commits into
mainfrom
refactor/one-declaration-per-bus-read
Closed

trissim wants to merge 39 commits into
mainfrom
refactor/one-declaration-per-bus-read

Conversation

@trissim

@trissim trissim commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Closes #3537.

One declaration per bus read, so a UI read, an MCP tool and an owner-socket request can never restate the same operation or answer it differently. Owner: claude-nra; shape decided by codex-greentext at the review checkpoint (map: bus add32451fa15).

Summary in numbers (source phases built; root qualification, paired integration and installed GC/frame gates open)

  • Commits on this branch: plan cfad6769e, notifications c57a7476b, ratchet 3d3a357bb, goal history 732605601, input delivery 36611e2b6 (+149/−62, 8 files), tests c0821f571 (+65/−20, 2 files), native owner check and held identity a4702b1f7, lifetime controls 08263a4c0, transport relocation 5c262628f (+89/−97, 6 files), fixture corrections 9bb8b2e38 (tests only, +111/−128, 6 files), held ordering 8d21db48c, notification reads 21b07552a (+257/−96, 13 files), sparse activity and producer types 7bcb1ca43 (3 files, +77/−14), recipient-fixture correction 5bcc78d9b (tests only, +8/−6), viewer-scoped evidence and published-only presentations 7b5c78a0e + 2e8db8229 (14 files, +449/−143), typed construction and forced-batch/null-key controls 5126bc6c6 + ef1ae76d4, typed accumulator and summary owner ed6ff38d2, final read surface ffe8bd27c 780b9957e 1c8b7e8eb d92c06392 d5b7376f8, plus main merges (latest: main 3adf7912e into ed6ff38d2 as 7fff19b7b, no conflicts). Overall origin/main...HEAD: 52 files, +1906/−628.
  • Owner native-transport checks: 3 → 1 (OwnerReading.require_native_owner, called from OwnerReplyConnection.held; the per-class copies are deleted). The goal read for an external owner no longer waits forever for a socket that never comes.
  • Function-local transport imports in the read capability: 2 → 0. Foreign declared_name reads: 1 → 0. Any results on the runtime envelope: 1 → 0.
  • Incarnation reads that held never compared: 1 → 0 (OwnerConnection.__init__ recaptured the registry identity after held checked it).
  • Answers to "which inputs did this unit lose": 2 → 1. MCP answered from the connector process, which never holds the owner's queue, so it was always UNOBSERVED. The UI asked the owner. Now both send the same InputDeliveryRead to the live owner.
  • Input-delivery declarations: 3 → 1 (CommsInputDeliveryTool, ReadDeliveryOverview, InputDispositionsRuntimeRequest → InputDeliveryRead).
  • Owner-socket connection factories: 2 → 1 (OwnerRead.connect and the delivery path → OwnerReplyConnection.held/answer).
  • subject/own_thread/refuse_thread copies: 3 → 1 (ToolCaller, NoThreadCaller → BusReader).
  • Caller-choosable authority fields on reads: 1 → 0 (ReadNotifications.viewer).

Mechanism: BusRead[Result]

BusRead is an abstract capability inside the existing ToolDeclaration family, with no DeclaredFamily root of its own. Members own their arguments and one read, read_as(comms, reader: BusReader) -> Result. The boundary supplies the reader; it is never an argument.

  • BusReader(ABC) declares:
    • outcome_viewer(comms);
    • subject(named), which is named or own_thread();
    • own_thread(), which by default refuses;
    • refuse_thread(), which raises CallingThreadUnavailable("This tool requires a thread caller."). The text and type are unchanged.
  • OperatorReader(OperatorOutcomes, BusReader): the owner's UI, which sees everything.
  • ToolCaller(BusReader): ThreadCaller reads its own outcomes and thread. NoThreadCaller and DirectorCaller read as operator.
  • UiRead[Result](BusRead[Result]): derives the UI's read(comms). The envelope stays on ReadRequest. BusRead.members_with(UiRead) = (ReadNotifications, ReadRecordedNotifications). The UI selector is now tool (the ToolDeclaration discriminator; it was kind). No persisted or wire producer exists, because observation requests travel by pickle; pickle and hash equality were checked.
  • Transport lives in owner_connection.py, beside OwnerConnection, RuntimeRequest and ResultRuntimeRequest:
    • OwnerReading[Result](ABC) is one exchange with one held owner incarnation. read(owner: OwnerConnection) is abstract. require_native_owner(owner) is owner.execution.require_native(); ThreadExecution owns native versus external, with no isinstance or roster. An external bridge (live PID, no RuntimeServer socket) is refused before _connect_current starts its unsent missing-socket wait. RuntimeRequest.require_owner(entry) is a different authority and is untouched.
    • OwnerReplyConnection(OwnerConnection): held requires the selected thread, refuses thread.incarnation != owner with OwnerIdentityChangedError, calls reading.require_native_owner(thread), then derives the socket from require_process, constructs the connection and checks require_identity(owner); answer reads once and closes in finally. REPLY_TIMEOUT is 3 s. It derives OwnerConnection directly, because owner_read and goal_snapshot use the base proxy_payload; only PromptRuntimeRequest needs the ACP controller token, so wire payloads are byte-identical.
  • OwnerLiveRead[Result: ToolResult = ToolResult](BusRead[Result]) is the domain capability. It does no transport. owned(ctx) and decode_reply(raw) are its only answer and decoder. MCP apply/read_as resolves caller.subject(thread), builds OwnerReadRuntimeRequest(thread=owner.name, operation=self), sends it with asyncio.run(OwnerReplyConnection.answer(...)) at the synchronous tool boundary (connector to_thread, sync CLI, native CoordinationCall), and decodes. Operation fields stay annotated with the bare OwnerLiveRead family class.
  • OwnerReadRuntimeRequest(OwnerReading[object], ResultRuntimeRequest) in bus_read.py, next to the capability it carries: action owner_read, field operation: OwnerLiveRead. read(owner) is the single socket exchange, owner.request(self.declared_name, operation=self.operation). result(ctx) -> ToolResult runs operation.owned(ctx) inside the owner. Runtime membership is still derived from the declaration: importing only agent_comms.runtime resolves owner_read to bus_read.
  • UI: OwnerRead(OwnerReading[object], AwaitedRead) asks through OwnerReplyConnection.answer. ReadOwnerGoal.read and ReadOwnerOperation.read take OwnerConnection. ReadOwnerOperation builds the envelope with owner.session_id and decodes through its operation.

Before / After (input delivery)

Before                                              After
CommsInputDeliveryTool(ThreadReadTool, ToolRequest) InputDeliveryRead(OwnerLiveRead[DeliveryOverview], ThreadReadTool)
  apply: goals.input_delivery(..., include_history=   thread, include_history=True (tool field)
         True) in the CONNECTOR process: always        owned(ctx): scope = agent.inputs.delivery_scope(ctx.session_id)
         UNOBSERVED_DELIVERY_SCOPE                              goals.input_delivery(ctx.name, include_history, scope)
ReadDeliveryOverview(OwnerRead)  (UI)                 ReadOwnerOperation(OwnerRead): operation: OwnerLiveRead
  include_history; connect() own socket factory         read builds the envelope, decodes via operation
InputDispositionsRuntimeRequest  (owner socket)       OwnerReadRuntimeRequest(action="owner_read", operation)  [bus_read.py]
  include_history again                                 result = await operation.owned(ctx)
OwnerRead.connect()                                   OwnerReplyConnection.held(comms, owner, reading) / answer(...)  [owner_connection.py]
ToolCaller/NoThreadCaller: subject, own_thread,       BusReader: subject, own_thread, refuse_thread (once)
  refuse_thread (copies)

Deleted: CommsInputDeliveryTool, ReadDeliveryOverview, InputDispositionsRuntimeRequest, the input_dispositions request action, OwnerRead.connect(), and the own_thread/subject/refuse_thread copies on ToolCaller and NoThreadCaller. Census at 08263a4c0 and Toad 3772cf0, src/tests/tools: the three deleted names and the old input_dispositions socket action have 0 callers in Core and in Toad. The same search finds the anchor InputDeliveryRead in 4 Core files and 1 Toad file, so the zero isn't a broken search. The remaining input_dispositions hits are the SQLite filename (input_disposition.py:247 and five test assertions), not socket callers. Toad used ReadDeliveryOverview only at src/toad/acp/agent_controller.py:26,434, which OpenHCSDev/toad#1302 migrates.

Wire (fresh-process declaration check):

  • operation default {"tool":"comms_input_delivery","thread":"","include_history":true};
  • runtime {"action":"owner_read","thread":"worker","operation":{"tool":"comms_input_delivery","thread":"worker","include_history":false}};
  • the round trip is equal through OwnerLiveRead, ToolRequest, ToolDeclaration and RuntimeRequest;
  • a non-owner-live operation (comms_goal_history) as operation refuses with "is not a member of OwnerLiveRead".

InputDeliveryRead is in the ToolRequest and native catalogs by capability, and no UiRead is in them. The schema keeps thread with its description.

Intentional public change: none to the MCP default. InputDeliveryRead.include_history defaults to True, as comms_input_delivery did before. Toad's AgentController.input_delivery keeps its own default, False, and always passes it explicitly. For equal explicit booleans, MCP and the UI return the same owner scope, current inputs and counts; only historical_inputs changes with the boolean. Behaviour change: a delivery read for a thread with no live native owner now refuses with the owner error (RelationViolationError, OwnerIdentityChangedError, StaleRevision) instead of returning an UNOBSERVED-looking view. The connector encodes Refused, ConnectionError and TimeoutError as a failed tool call instead of dropping the HTTP call. tests/verify_connector_tools.py:244 still calls comms_input_delivery for worker, which is declared StoppedThreadStatus with no process, so it now gets that clean refusal.

Held identity (commit a4702b1)

Before: OwnerReplyConnection.held compared registry.require(name).incarnation with the supplied witness. Then OwnerConnection.__init__ read the registry again into _identity, and nothing compared that second read with the witness. A replacement incarnation registered between the two reads was adopted silently.

After (8d21db48c): held makes two identity comparisons, one for each registry read:

  • The selected thread must be the held incarnation before its process is asked for. A stale witness therefore refuses as OwnerIdentityChangedError, not as "no owner process".
  • After construction, OwnerConnection.require_identity(owner) compares the connection's captured _identity with the witness, which closes the gap between the two reads.

a4702b1f7 had dropped the first comparison, and root's rerun caught the wrong refusal order. _identity is still the connection's only identity field: there's no expected-owner cache and no overwrite. Later rechecks in _owner_entry (root stat, name, incarnation) are unchanged.

Also found, not changed: two unrelated classes are named BusReader (bus.py:146, bus_read.py:22).

Goal history: three result classes → one (commit 7326056)

Before: RecordedGoalHistory(DeclaredReply) held history and failures. GoalHistory(ToolResult, RecordedGoalHistory) (tools.py) and GoalHistoryReply(RecordedGoalHistory) (runtime_requests.py) were empty subclasses, and GoalManagement.recorded_history(reply: type[HistoryReply], name, scope) built whichever one the caller passed in. Two boundaries, two empty names, one record, plus a TypeVar to keep them apart.

After: RecordedGoalHistory(ToolResult) (ToolResult is already a DeclaredReply and adds no fields). recorded_history(name, scope) -> RecordedGoalHistory. CommsGoalHistoryTool.apply and GoalHistoryRuntimeRequest.result both return it. The tool name, declared runtime name, subject resolution, scope decoding and worker-thread execution are unchanged.

Deleted: GoalHistory, GoalHistoryReply, the reply parameter, the HistoryReply TypeVar and its import (+12/−35 across 5 files).

Encoding is unchanged. One populated history/failure payload decodes and re-encodes byte-identically through all three old classes before, and through RecordedGoalHistory after. empty() is {"failures": [], "history": []} on both sides. This is a source serialization check, not an application run.

Checks (run by root on Core 7326056 + Toad caf4953):

  • Real managed native-owner socket vs MCP comms_goal_history: identical populated and empty-scope history, 1 passed in 19.35 s.
  • Core ledger failure/history case: 1 passed in 1.81 s.
  • Toad goal-history screen against installed Core: 1 passed in 9.93 s; Toad P321: let the turn handle unmatched Pi replies #1301 merged as 5fa29f3.
  • Head verdict OK on 7326056 for this step only. The overhaul is not approved for merge; the remaining plan below is open.

Census: 0 hits for the deleted names in Core src/tests/tools and Toad main src/tests/tools; the same search finds 10 Core and 18 Toad RecordedGoalHistory sites. tests/test_commander_latency.py:34 recorded_history is an unrelated helper that only shares the name.

Ratchet repair: type counts are measured each comparison (commit 3d3a357)

The hosted debt-src failure on this PR wasn't a source difference. The ratchet stored mypy counts in ContentCache under a key of committed content and checker versions, but mypy also reads the installed packages, and no key names them.

CI evidence (Codex read it at the CI boundary):

  • The PR job, 40656356183, restored debt-ratchet-src-71453f18249492f07957174bb0a13be6a90b7e9b.
  • Main cache job 40654054315 saved that key. Its install ran Core-only uv sync --locked --all-extras, with Pydantic 2.14.1.
  • The PR job installed the full stack, with Pydantic 2.13.5.
  • A specific memo hit is not logged, so this doesn't claim that precise hit was observed.

Local evidence (Codex's fresh comparison against Toad 2bbca26a7, using the ratchet's own StackClient.imports selection): 287 client files on Core 71453f1 and on this PR's b97957a give 533 diagnostic lines on both sides, byte-identical. No Toad patch is justified.

Source change (tools/debt_ratchet.py):

Measure Before After
CheckedRevision.findings (inherited by TypeErrors) count memoized by tree + head config checker runs each comparison; mypy keeps its own dependency-aware --cache-dir
LintFindings.findings inherited memo same memo body, moved unchanged into an override (Ruff reads only tree + config)
Unformatted.findings per-file blob cache unchanged
ClientTypeErrors.findings memo keyed by client commit + src tree calls checked directly; the commit/tree lookups computed only for the key are deleted

No new cache policy, dependency fingerprint, package roster or wrapper was added. ClientTypeErrors.clients discovery caching, import selection, checker command and counting are unchanged.

Regression (tests/guards/test_debt_ratchet.py): test_type_counts_follow_the_installed_dependency_not_a_stored_count, parameterized over TypeErrors and ClientTypeErrors. A PEP 561 package extdep outside every committed root is switched between an accepting (VALUE: int) and a rejecting (VALUE: str) sys.path environment. The committed source, client checkout and configuration don't change, and the count-cache directory stays the same. The real findings path runs twice, and the count must go 0 → 1. Nothing of ours is mocked, and no mypy output is faked.

Checks (run by root on 3d3a357):

  • Focused selection: 10 cases pass in 8.59 s.
  • Negative control: the same 4 external-dependency regressions (TypeErrors and ClientTypeErrors) all fail against the parent ratchet module, counting a stale 0 instead of 1 (3.72 s).
  • Hosted run 40661728165: debt-src and debt-tools are SUCCESS.

Proof limits:

  • The hosted pass is for this step only. The whole PR is still a draft: the six-step shared-read plan above is partial.

Input delivery tests (commit c0821f5)

  • tests/test_observer_death.py: unavailable and stale owner reads are parametrized from ReadOwnerGoal plus ReadOwnerOperation(op()) for each op in BusRead.members_with(OwnerLiveRead) (ids goal, comms_input_delivery). The hand-listed OwnerRead.__subclasses__() roster is gone. The refusal and observer-alive assertions are kept.
  • tests/test_input_delivery_history.py: the CLI test asserting that a declared owner with no process answers delivery state is deleted, because that answer was the defect. The replacement uses canonical_delivery_owner, a real owner socket. It runs the owner, holds a turn, queues one prompt and records an off-queue notice (bus:91). Then:
    • the owner's own delivery_scope is OwnerQueueInputDeliveryScope;
    • the old unobserved projection lists bus:91 as current;
    • for include_history False and True, MCP InputDeliveryRead.invoke equals FieldCodec.encode of the owner's projection, and the UI ReadOwnerOperation(...).answer(comms).value equals it;
    • bus:91 is historical, and only historical_inputs differs between the two settings;
    • a stopped owner (NoProcess, StoppedThreadStatus) refuses the MCP read.

Lifetime controls (commit 08263a4)

tests/owner_socket_fixture.py, registered in pytest_plugins, is the one fixture owner:

  • runtime_processes, moved from test_runtime.py, now yields the spawned children so a test can stop one through stop_sync. A second stop is a no-op: signal_group skips gone members, Popen.wait returns the saved code, and NO_CHILD_REGISTRATION.finished does nothing.
  • declare_owner registers a native or external owner.
  • silent_socket is the real Unix-socket peer from test_dispatched_request_without_a_reply_remains_unknown_and_is_not_replayed: it receives one request and closes without replying. That test is refactored onto it.

Nothing of ours is patched, and no reply is fabricated:

Control Lifecycle event Expected
reply lost, include_history False and True peer reads one request, hangs up OwnerReplyLostError; exactly one request, decoding to OwnerReadRuntimeRequest(thread, InputDeliveryRead(thread, include_history)); durable inputs unchanged. The peer runs no domain body.
root replaced held connection, then root renamed to a sibling and a new directory made at the old path OwnerIdentityChangedError from the root-stat comparison; 0 requests; root restored
same-name recreation held connection, then unregister, begin_delete, remove, declare the held read and a new held() with the old witness both raise OwnerIdentityChangedError; old and new incarnations recorded and different; 0 requests
process death held connection on a runtime_processes child, then stop_sync ConnectionError before any request; 0 requests; inputs unchanged
external owner ExternalThreadExecution with a live child, no socket MCP delivery: RelationViolationError, inputs unchanged. Observer path: ReadOwnerGoal and the delivery envelope (from OwnerLiveRead membership) both raise RelationViolationError, and the observer stays alive

The canonical native-owner journey in test_input_delivery_history.py stays the domain proof (MCP == UI == the owner's projection). The silent socket peer only proves dispatch and then reply loss.

  • Not run here (order: root runs one combined selection): tests/test_runtime.py, tests/test_observer_death.py, tests/test_input_delivery_history.py, tests/test_command_families.py, and the stopped-worker case in tests/verify_connector_tools.py. That file is unchanged from main (last commit 11ad0e348), and its worker (StoppedThreadStatus, no process) now gets a refusal at require_process instead of a live overview.

Root qualification and fixture corrections (commit 9bb8b2e)

Root's selection on 5c262628f was not a clean suite: 60 cases ran in 3.99 s, 49 passed and 11 failed.

  • 1 was a wrong deletion setup: remove() after begin_delete correctly refuses Deleting status.
  • 10 were observer fixtures still using the removed synchronous observer surface (with, readable()/results()/fileno(), sync close, NotificationWindow == {}).

Root's rerun of these corrections on 9bb8b2e38: 22 cases in 70.37 s, 20 passed and 2 failed, both other_root=False stale-identity cases (goal and input delivery) refused as no-process. 8d21db48c restores the identity comparison before process lookup. Toad 0be58d8 (Toad's 5 checks, the new native acquisition test among them) passed in 67.48 s. The earlier 49 passing cases and these results are evidence for their exact heads, not a whole-suite pass.

8d21db48c also bounds ObservedQueue.until by one 30 s timeout for the whole match, where before every unrelated result restarted it, and restores one 30 s bound around the deleted-thread wait for both observations.

Root's final targeted selection on 8d21db48c: 19 passed in 36.68 s:

  • the two stale cases;
  • the controls that share held's ordering (unavailable owner, other root, external owner, same-name recreation, process death, reply loss, root replacement);
  • the two bounded-queue integrations.

Head verdict OK on 8d21db48c for the owner-live input-delivery stage only. The PR stays a draft and isn't armed.

Evidence, by exact head (separate selections, not one suite count):

Head Selection Result
Core 5c262628f unchanged Core cases 49 passed (the 11 failures were fixtures, corrected below)
Core 9bb8b2e38 repaired fixtures 20 passed, 2 failed (stale-identity order), 70.37 s
Core 8d21db48c ordering/lifetime targets 19 passed, 36.68 s
Toad 0be58d8 paired Toad checks incl. native acquisition 5 passed, 67.48 s
  • test_runtime.py same-name control: deletion completes with edit.document.remove_originals((old,)) inside registry.store.editing(). That's the path production uses (thread_management.py:537), because Registration doesn't forward remove_originals. The captured connection, the old witness, the zero-dispatch peer and both OwnerIdentityChangedError refusals are unchanged. No production deletion change.
  • test_observer_death.py: every observer is async with, and empty-window checks compare .outcomes.
    • Killed observer: one read proves startup, then SIGSTOP (confirmed with waitpid(WUNTRACED)), the next read shown pending, then SIGKILL. The ObserverDied carries the child pid, -SIGKILL and the signal text, and StopSidebar and a later read raise the same object. A finally kills a stopped child and cancels a pending task.
    • Failed observer: one real read on the not-a-wire route must report exit status 1 with the original traceback.
    • readable() is deleted.
  • test_busy_history_loading.py: three async with sites, plus the close-once test's async with process and its awaited repeated close, and the .outcomes comparisons. The Connection.close endpoint observation is unchanged.
  • test_deleted_thread_observation.py and test_unreadable_history_observation.py (the two readable() importers) start the receiver once with start_receiving and wait on tests/observed_queue.py. That's a test-local queue of delivered results that raises a receiver failure as its original exception; there's no manual pumping.

Observer-caller census (AST over src/tests/tools; it tracks names bound from ObservationProcess(...)/.awaited(...) and ignores multiprocessing handles):

  • 11 async with constructions and 1 async with on a bound observer.
  • 0 synchronous with, 0 readable imports, 0 results()/fileno().
  • 2 .close() calls on observers, both awaited (test_busy_history_loading.py:118 and test_transcript_observation_lifetime.py:108).

Codex's check (rg 'test_observer_death import readable|process\.(results|fileno)\(' over the three files) gives 0.

Found and left alone:

  • test_transcript_observation_lifetime.py's bare-process pending-read test never starts a receiver. Its pending read settles only because request() hits the broken pipe and ended() abandons the read. That's a separate pre-existing contract.
  • Toad's own observer, comms_root.py:392, already uses start_receiving (:244) and closes as a task (:260).

Root rerun nodes:

  • tests/test_runtime.py::test_a_same_name_recreation_refuses_the_held_read_and_the_old_witness
  • tests/test_observer_death.py, all six functions with their parametrizations: test_a_killed_observer_is_reported_once_with_its_signal_and_fails_awaited_reads, test_an_invalid_notification_reference_does_not_kill_the_observer, test_unavailable_owner_reads_preserve_the_observer, test_stale_owner_reads_preserve_the_observer, test_external_owner_reads_refuse_before_waiting_for_a_socket, test_an_observer_that_failed_is_reported_with_its_own_traceback_and_exit_status
  • tests/test_busy_history_loading.py: test_busy_source_keeps_original_waiter_and_other_reads_move, test_cancelled_busy_read_is_removed_without_resubmission, test_awaited_scope_preserves_exit_and_closes_once, test_awaited_notifications_then_immediate_close_retires_successfully
  • tests/test_deleted_thread_observation.py::test_observer_process_survives_deleted_interest_and_keeps_serving
  • tests/test_unreadable_history_observation.py::test_unknown_saved_member_does_not_kill_the_real_observation_process
  • Toad tests/native/test_native_goal_owner.py::test_input_delivery_is_read_from_the_live_native_owner_queue (new; not yet run by root)

Notification reads: root-free operations, one UI envelope (commit 21b0755)

Before:

  • ReadNotifications(UiRead[NotificationWindow], ReadRequest) held the UI's root and request_id beside its references.
  • ReadRecordedNotifications subclassed it to add source.
  • CommsMessageOutcomesTool.apply built that UI envelope with str(comms.root) purely to call read_as. MCP was carrying a UI root it never validated, to reach a read that doesn't need one.

After:

Owner What it holds
notification_reads.NotificationRead(UiRead[NotificationWindow]) abstract changed_since(comms, revision, previous)
LiveNotificationRead(references) read_as with the reader's outcome_viewer via message_notifications_for_references; stale when previous.live_changed (activity and evidence)
RecordedNotificationRead(references, source) read_as through HistorySource.require(sources, source).notification_references; stale only when revision.history changes
notification_read.NotificationsRead / UnreadableNotificationsRead moved unchanged beside CapturedNotificationWindow
ui_model.observation.ReadNotifications(ReadRequest) root, request_id, operation: NotificationRead; read delegates to the operation; no longer a UiRead
  • Two members, not one with a flag: archive identity and the change rule differ.
  • Why the module sits apart: the operations live in notification_reads.py, separate from notification_read.py, because presentation imports notification_read and bus_read imports presentation.
  • Deleted: ReadRecordedNotifications, with no alias.
  • Subscriptions: NotificationReading asks request.operation.changed_since. Read states stay keyed by the whole request, so a different root, request id, references or archive is a different key.

MCP is unchanged in contract. comms_message_outcomes calls LiveNotificationRead((reference,)).read_as(comms, caller). Its positive-limit refusal, exact reference lookup, outcome_viewer.sees refusal before rows, recipient-cursor paging and remaining count all stay. Its public schema is byte-identical to 8d21db48c (1317 bytes of sorted JSON). The 120-reference window chunk stays a per-acquisition chunk, not a cap, and MCP's limit (default 100) is still a page size.

Exposure follows capability. BusRead.members_with(NotificationRead) is (Live, Recorded). Neither is in the ToolRequest or NativeRequestTools catalogs, and CommsMessageOutcomesTool is in both. The envelope decodes operation as the bare NotificationRead family and refuses an input-delivery operation ("is not a member of NotificationRead").

Toad (#1302 a9a330b):

  • HistoryNotifications.notification_request (a class used as a root+references constructor) becomes notification_operation, a root-free operation constructor: LiveNotificationRead by default, partial(RecordedNotificationRead, source=source) in RecordedContents.
  • notification_read binds the ProjectPathOwner root: ReadNotifications(root, operation(refs)).
  • comms_root still keys views and outcomes by the full request.

Controls added (not run here):

  • declaration and identity: root-free family codec for both operations; the envelope's codec, pickle, hash and dict lookups; root, request id, references and archive each distinguishing a request; an equal request keeping its read state while a changed archive drops it; one operation in two root envelopes keeping root validation; capability discovery; a foreign operation refused.
  • (a) a 125-message live batch across two window chunks keeps every message, matching single-message reads.
  • (b) the existing MCP paging and visibility cases in test_tools are unchanged.
  • (c) RecordedNotificationRead returns the archive's "Sent" while the live read is "Consumed", and a detached key refuses with HistoricalSourceChanged.
  • (d) the existing activity, evidence and expiry tests are unchanged in meaning.

Fresh-process checks (executed):

  • Four import orders (cli-first, tools-first, notification_reads-first, presentation-first) all import with identical family, codec and discovery results.
  • The comms_message_outcomes schema is byte-equal to 8d.
  • py_compile, undefined-name and unused-import checks are clean on every changed file.

Found, not changed:

  • ObserveNotifications multi-request FieldCodec encoding fails, because the frozenset serializer sorts unorderable requests. It's the same on 8d; the production path is pickle.
  • Toad's tests/textual/test_observation_shutdown.py calls process.fileno() and loop.remove_reader(process.fileno()). ObservationProcess has no fileno() on Core main or on this branch, so that test is already broken on main, independently of this phase. Only its request construction changed here.

Root selection still to run:

  • Core: test_notification_read_state, test_message_notifications::test_a_live_batch_beyond_one_window_chunk_keeps_every_message, test_historical_notifications::test_live_cli_ack_does_not_become_archived_ack, test_tools message-outcomes cases, test_thread_read_results attribution, and the observer fixtures touched by the envelope change.
  • Toad: test_historical_notification_source, notification paint / hidden late result / markdown archived-empty, test_observation_shutdown.

Notification correction: activity holds only projected participants (commit 7bcb1ca)

Why: hosted debt-src on 21b07552a counted NoneAbsence +1 at notification_read.py:40 and TypeErrors +1 at notification_reads.py:57. The second is the return of an unannotated HistorySource.notification_references reaching RecordedNotificationRead.read_as.

Activity:

  • observed_activity (registry_document.py:1139) projects visible executors only. A registered human or an archived row is omitted, and a deleted name answers DeletedThread. The window's activity_names() already names every watched participant.
  • The None in NotificationsRead.activity was therefore a second spelling of "absent". It is gone: activity: Mapping[str, ObservedActivity | DeletedThread] keeps only names the projection returned.
  • live_changed re-projects the window's full activity_names() when ActivityView moved and compares the sparse maps. omitted→observed, observed→omitted and observed→DeletedThread still invalidate. With no ActivityView change it projects nothing.

Types:

  • HistorySource.notification_references(sources, references: Sequence[MessageReference], viewer) -> NotificationWindow, the type MessageNotification.read_references already returns. The body is unchanged.

Tests:

  • The {absent-participant: None} fixture is replaced by a real #team read. Its participants are an agent, a human, an archived agent and a deleted agent.
  • New controls:
    • an omitted human and an archived agent;
    • an archived agent declared again, which invalidates the read;
    • a participant deleted after the read, which invalidates it;
    • zero projections on a reads-only revision and one on an activity revision.

Root run on 7bcb1ca43 + Toad 54db5d1 (seven heads): 31 collected, 29 passed and 2 failed, in 16.64 s. Both failures were in my fixture: it expected the agent sender and the human in activity_names(), but the window names only native recipients (here worker, archived, gone). 5bcc78d9b corrects those two assertions and changes nothing in production.

Root rerun on 5bcc78d9b: the two corrected nodes, 2 passed in 1.64 s (exit 0). The matched Toad 54db5d1 shutdown, paint and archive selection: 7 passed in 76.19 s (exit 0). Source-stage verdict: OK on 5bcc78d9b, for the notification stage only. The PR stays a draft and unarmed. Still open: the paired-client CI mismatch and the remaining plan phases.

Remaining client counts: hosted ClientTypeErrors +1 on Toad-main acp/agent_controller.py (imports ReadDeliveryOverview, line 26), screens/historical_sessions.py and tests/textual/test_historical_notification_source.py (import ReadRecordedNotifications). Toad #1302 changes all three, so they clear only when the pair lands. Not run here.

Notification evidence stays with its reader (commits 7b5c78a, 2e8db82)

Why: on the saved six-tab desktop, two windows of five incoming broadcasts (1767/1768 frozen recipients each) allocated 17,681 claims to show 15 rows. NotificationAssignment.capture selected claims by wire_seq alone, and only after decoding did it let OwnerOutcomes drop other recipients. The same acquired graph then reached the UI twice:

  • ThreadPresentation.notification_window;
  • each notification's message, which was the Delivered record itself, carrying the frozen audience and decisions.

Codex's offline replay of the saved values: 8.10 MB → 136,914 B and 375,706 → 2,638 graph nodes, with consumer parity. That is a payload result, not a frame-time result.

Viewer captured once:

Owner Change
NotificationAssignmentRead required keyword viewer: OutcomeViewer; empty() deleted (no callers)
NotificationAssignment.capture/read/missing_store/invalid_store take the viewer. capture builds visible (recipient_lookup, wire_seq, message_id) keys before SQL; claims are joined through the recipient/seq index, and HarnessDelivery.for_recipients gets the same keys
TypedTable.requested_keys → RequestedKeys(values, join, bindings) factored out of select_keys, which consumes it; one batched, null-safe, declaration-encoded key producer
delivery_window / project_delivery_window / for_sources read assignment_read.viewer; no second viewer parameter
for_deliveries(…, viewer) an invisible recipient allocates no UnrecordedNotificationSource; recorded_delivery_window allocates no placeholder activity for it
NotificationAssignment.changed groups retained reads by their own viewer and re-reads each group as that viewer
SelectedParticipant.prepare captures the sent read as OwnerOutcomes of the owner (sender sees all); capture_sent_outcomes lost its now-unused owner parameter
RegistryPrivacy.missing_notification_assignments(sources, viewer) public: empty read with that viewer; private: still refuses

Public envelopes: ThreadPresentation.with_sources and MessageNotification.for_sources attach source.envelope, not source.message. Census: 0 sites still attach a record as a notification's message.

Acquired versus published:

  • ThreadPresentation is the UI value: status, binding, notifications and read_identity.
  • AcquiredThreadPresentation(ThreadPresentation) adds incoming_references, sent_references and notification_window.
    • It is built by with_sources, supplies revision inputs, and owns sent_summaries() (SentMessageSummary.recent is deleted).
    • published() derives the base from its declared fields.
    • __reduce_ex__ raises TypeError, so pickling it is refused with a message naming published().
  • ThreadReadResult.published() is self by default, and ThreadPresented projects its presentation.
  • ThreadReading keeps acquired results for its next change check and publishes only the changed results' projections.
  • Toad read_thread_presentation returns .published() before leaving its worker (P325: return owner subscription busy refusals without waiting #1302 cdd6e15), so Conversation and ObservedSourcePublication never hold the acquired value.

Executed here (fresh processes):

  • Probe p872-gc-probe.py in 4 import orders, all identical:
    • published fields are exactly the 7 UI fields, and acquired adds exactly 3;
    • pickling acquired is refused (alone and inside ThreadPresented), while published round-trips;
    • only acquired values supply revision inputs;
    • a read without a viewer is refused;
    • the claim key join uses recipient_lookup/wire_seq/message_id, with duplicate keys bound once.
  • AST census over Core and Toad src/tests/tools: 1,813 files, 0 parse failures, 0 stale names. The order check rg 'replace\(notification, message=source\.message\)' src gives 0. Of 66 non-literal reflection sites, 7 sit in presentation, notification, observation or status files; these are listed in p872-gc-census.json as unknown dynamic limits.
  • py_compile, the undefined-name checker and ruff F are clean on every changed file.

Not run (root):

  • New controls:
    • test_outbound_transcript_source: per-viewer decode count and rows, no placeholder for an invisible recipient, refresh grouped by viewer;
    • test_recipient_activity_index: rename and rebirth, missing public and private stores;
    • test_thread_read_results: retained acquired versus published, pickled types, UI current and last-published rows;
    • test_thread_presentation.
  • Existing behaviour that must hold:
    • test_thread_read_results::test_assignment_row_changes_attribute_only_their_view_without_projection;
    • test_historical_notifications recorded-response cases (345, 424);
    • test_tools threads and outcomes;
    • test_recipient_activity_index::test_viewer_selects_the_outcomes_of_a_delivery_window;
    • the Toad paint, owner and settlement suites.

Root run on 2e8db8229 + Toad cdd6e15 (selections): 60 Core cases passed in 17.01 s, and 9 Toad consumer cases passed in 87.56 s. Neither is the whole suite or a GC/frame result.

Correction 5126bc6c6:

  • Type fix: a focused type check found 10 new arg-type errors at thread_presentation.py:287/311. presentation_fields() -> dict[str, object] erased each field's type before the typed constructor. It is deleted.
    • with_sources builds shown = replace(self, …) once and constructs the acquired value from the inline {declared.name: getattr(shown, declared.name) for declared in fields(ThreadPresentation)} plus its three acquisition fields.
    • published() uses the same inline mapping over itself, the status_display shape. No cast, Any, ignore or field roster.
  • Forced-batch control (test_a_forced_key_batch_captures_the_same_evidence_through_the_recipient_index):
    • a second real #team original and a repeated original in the private _root fixture;
    • SQLITE_LIMIT_VARIABLE_NUMBER=6 on the fixture's own connection forces several batches;
    • the whole capture equals the unlimited read;
    • the traced claim statements' EXPLAIN QUERY PLAN SEARCHes wake_claims through the PRAGMA-derived recipient/seq unique index, never a SCAN.
  • Null-key control: round2/test_declared_table_family checks that select_keys selects the weight=None child once through a repeated None key, and stops after the update.
  • Root run on 5126bc6c6:
    • the focused type check shows only the two old no-untyped-def diagnostics: the 10 new arg-type errors are gone;
    • the null-key control passed; the forced-batch control failed (1 passed, 1 failed, 3.30 s), because a limit of 6 refused NativeRuntimeTable's schema catalog read before any claim query ran;
    • offline replay of the saved closure: 29 captured parent edges; published graph 154,190 B against 16.207 MB original; 748,287 → 3,005 nodes; 497,109 tracked nodes gone from the named roots; same public values and aliases; 8 actual acquired-pickle refusals. These are component numbers, not a whole-process or frame result.

Correction ef1ae76d4 (test only):

  • The limit is the larger of 6 and each validated schema's catalog bindings (declared object names plus table names): PrivateCoordinatorSchema 163, NativeRuntimeTable 21. It must be below the connection's own limit.
  • The fixture publishes limit // (3 × recipients) + 1 real #team originals (28 with 2 recipients; 29 sources including the repeat, within the window limit of 120), so the keys still force several batches while schema validation runs normally.
  • Not run.

Still open: this stage removes the captured payload only. Root still has to remeasure the whole-process GC tail; the earlier capture covered 17,681 of 38,895 assignments, and Conversation and publication aliases were not captured. The 16 ms frame target, the p99.9 tail, 29-agent coverage and the remaining shared-read phases stay open.

Final read surface: commander detail, owner goal, transcript page (commits ffe8bd27c … d5b7376f8)

Three reads were still declared only as UI requests. Their domain arguments were mixed into root-bearing envelopes, and MCP had nothing to serve.

commander detail: a UI class with the business body inside
owner goal: a runtime command whose change() was pass, sent by string
transcript page: five arguments restated on every UI request

Each one is now one root-free operation in order_tools.py. MCP, native and the UI derive it.

Read Declaration Capabilities Domain fields Result
comms_commander_unit CommanderUnitRead UiRead + ReadTool + ToolRequest commander, unit (held incarnations), orders CommanderUnitDetail
comms_owner_goal OwnerGoalRead OwnerLiveRead + ThreadReadTool inherited thread selector only GoalSnapshotReply
comms_transcript_page TranscriptPageRead UiRead + ThreadReadTool thread, before, after, through, historical_source TranscriptSnapshotUpdate

Before / After

Before                                              After
ReadCommanderUnit(ReadRequest)                      CommanderUnitRead(UiRead[CommanderUnitDetail], ReadTool, ToolRequest)
  root, request_id, commander, unit, orders           commander, unit, orders; read_as = old body verbatim
  read() = the business body                        ReadOperation(ReadRequest): root, request_id, operation: UiRead
ReadOwnerGoal(OwnerRead) -> request("goal_snapshot") OwnerGoalRead(OwnerLiveRead[GoalSnapshotReply], ThreadReadTool)
GoalSnapshotRuntimeRequest: change() = pass           owned(ctx) = the one snapshot + turn projection
GoalSnapshotResultRuntimeRequest.result: a copy     result = await change(ctx); return await OwnerGoalRead().owned(ctx)
TranscriptRequest(ReadRequest)                      TranscriptRequest(ReadRequest): root, request_id, operation: TranscriptPageRead
  thread, before, after, through, historical_source   page_read = operation.capture_as(comms, OperatorReader())
RefreshTranscript: builds ReadTranscript(...)       RefreshTranscript: builds TranscriptPageRead from its identity
OwnerLiveRead.apply (only copy)                     BusRead.apply, once
  • Commander detail. read_as checks both held incarnations against one registry snapshot, takes CommanderFrame.current, selects the exact card and calls card.detail with the exact orders. Every ToolRefusedError and the post-read checks are unchanged. ReadOperation is the one generic UI envelope: it decodes a bare UiRead through its family and keeps ToolRefusedError/WorkBranchError as refusals, with deferred_reads still (CoordinationReadUnavailable,). ReadNotifications keeps its own typed NotificationRead envelope.
  • Owner goal. owned(ctx) runs goals.goal_snapshot(ctx.name), then turns.turn_state(ctx.session_id), then GoalSnapshotReply.from_snapshot(snapshot, UpdateMetadata.of(TurnChangedUpdate(turn))), in the same order. Writes delegate to it after their change. OwnerReadRuntimeRequest/ReadOwnerOperation stay the only owner-live envelopes. action=goal_snapshot, with or without an operation, now decodes as MalformedRecord.
  • Transcript page. capture_as resolves reader.subject(thread) and calls capture_page_read once. ReadTranscript, SubscribeTranscript (deferred_reads=(StaleRevision,)), ReadTranscriptUpdate (previous publication state) and RefreshTranscript stay UI lifecycle boundaries and are never tools. There are no forwarding properties. TranscriptSnapshotUpdate.capture's owner-publish path is untouched, and acp_extension does not import order_tools.
  • Results. CommanderUnitDetail, GoalSnapshotReply and TranscriptSnapshotUpdate became ToolResult directly, with no added fields or wrappers. TranscriptSnapshotUpdate keeps AgentCommsUpdate first in its MRO.
  • Generic result introspection. tests/test_tool_replies.py reads a BusRead's result off the first concrete specialisation of a BusRead capability along its MRO (types.get_original_bases, the CliCommand.result_type pattern), and refuses none or two. Non-BusRead tools keep the apply-annotation path. No per-leaf apply copies, no result roster.
  • MCP refusal. WorkBranchError joined the existing tools/call failure tuple (connector.py:463), so an unreadable original Git is a failed call, not a crash.
  • Corrupt transcript over MCP (f675fbe01). MalformedRecord joined the same tuple: a newline-complete corrupt native entry escaped tools/call as HTTP 500 with no id. Now it is isError with the original id and the domain's own MalformedRecord (JSONDecodeError cause), and the connector keeps serving. The shared-read controls send FieldCodec.encode_fields(operation); the old FieldCodec.encode carried the tool selector that ToolRequest.invoke refuses. goal_snapshot has its own UnknownFamilyMember test.
  • Result producer typed (701b58768). TranscriptSnapshotUpdate.from_read(read: TranscriptRead) -> Self; body and wire unchanged, so TranscriptPageRead.read_as no longer receives Any.
  • Debt ratchet: one gate for clean lines, plus field extraction (5ed8ac75a).
    • Comparison.failed failed a clean-lines measure twice: once for introduced occurrences, and again for raw per-file growth through Measure.blocks. The second gate failed a moved occurrence that wrote nothing. CleanChangedLines.blocks is now False, and every introduced occurrence still fails, including one an unrelated deletion offsets. Measure.grown is the growth predicate shared by blocks and the site locator, so base/head/delta and found are unchanged.
    • FieldExtraction proves one transfer from the AST: an old class present at both revisions newly holds, in one typed field, one class absent from the base that no other old class holds. Each field it lost is declared once in that class with the same name, type (typing.Annotated metadata aside) and default. Only occurrences inside that field are credited, one departure each.
    • The production case: TranscriptRequest.before/after/through/historical_source moved into TranscriptPageRead (order_tools.py:567/568/569/571). Raw NoneAbsence still shows observation.py −4 and order_tools.py +4. These are moved declarations with the same None defaults, not deleted debt and not new debt.
    • No credit for: a retained field, a field with no typed holder, two old owners or two held classes, a shadowed, star-imported or unresolved binding, a changed name/type/default, an extra field, or a None in a method.
  • One lexical binding owner, and missing evidence fails (908db01d6).
    • DecoratorBinding.bindings(tree, scope) reads ScopeNames' lexical scopes. declarations() proves a name only when its scope binds it exactly once, by an unconditional import, class, function or annotated assignment in that body, and nothing is star-imported.
    • named()/of() (boundary_decoder, dataclasses.field), held-class exports, field uniqueness, source disappearance and the typing.Annotated strip all read that one owner. DeclaredClass.bound_once is deleted.
    • These no longer earn credit: a nested-only import; a rebound or local-only Annotated; class Page followed by Page = dict; a field reassigned after its annotation; a same-name binding hidden in unpacking.
    • Measure.missing / CleanChangedLines.missing: a positive clean-lines head count with no introduced report now fails and is reported as missing. introduced=[] stays the explicit measured answer of zero new sites.
  • Field types compared by owner, not spelling (1372658f7).
    • DecoratorBinding.identity rewrites each name and attribute in a field's type and default to its owner, using the same lexical declarations:
      • an import resolves to its source module plus export (relative imports and aliases go through module_of);
      • a declared class resolves to its own module;
      • a builtin counts only when neither the module nor the class binds the name.
    • Everything else is UnprovedIdentity, which equals nothing, not even itself: class-scope bindings, type variables, rebinding, star imports and unresolved names.
    • Production: TranscriptCursor resolves to agent_comms.transcripts.TranscriptCursor in both the old observation.py and the new order_tools.py, and str to builtins.str. The credit is unchanged at 567/568/569/571.
    • Occurrences pair node for node across the two proved declarations. So Cursor as Mark from the same module carries its departure, while the same Cursor spelled from another module does not.
  • Class-scope reads (1ea8ab3b6). DecoratorBinding.named(..., scope) drops a spelling whose root the class rebinds. The typing.Annotated strip and the held-class edge now read in the class's own scope, so Annotated = dict or Page = dict in a class body proves nothing.
  • The ratchet's own AST reading goes through one visitor (75773856d).
    • Hosted debt-tools on 1ea8ab3b6 failed this measurement repair on its own code: TypeErrors +6, TypeSwitch +2, TypeSwitchArms +20. Root's 87 focused measurement controls passed on that head.
    • ScopeNames' visit_<kind> hooks now record bound names and declaring nodes in the walk that assigns scopes. DecoratorBinding.bound and the switch in declarations are deleted. On the file itself, TypeSwitch is 12 → 10 and TypeSwitchArms 62 → 42, both equal to main.
    • Credit is keyed by the node itself. SitePlace and FieldExtraction.place are deleted.
    • The edges are typed. introduced is a classmethod over cls.members_with(cls).
    • Root's results on 75773856d (recorded by root, not run by the author):
      • source verdict OK;
      • the same 87 measurement controls passed (20.72 s);
      • the corrected one-file checker shows all 9 diagnostics from the changed node keys, typed edges and class receiver removed. 22 older file diagnostics remain, so this is not a clean whole-file claim;
      • hosted debt-tools succeeded.
    • Still open: debt-src is separate. The final matched-source qualification belongs to P883.
    • The earlier 37 Core + 7 Toad consumer passes stay credited to the source stages they ran on, not to 75773856d. No installed, frame or heap outcome is claimed.

Numbers

  • UI-only read declarations with domain bodies: 3 → 0 (ReadCommanderUnit, ReadOwnerGoal, GoalSnapshotRuntimeRequest deleted, with no aliases).
  • Public shared reads: 1 → 4 (comms_input_delivery, plus comms_commander_unit, comms_owner_goal, comms_transcript_page), each cataloged once in ToolRequest, NativeRequestTools.catalog() and ConnectorCaller.exposed().
  • Transcript domain arguments restated per UI envelope: 5 → 0. Copies of the goal snapshot/turn projection: 2 → 1. apply forwarding copies: one, moved to BusRead.
  • Wire identity, old tree ed6ff38d vs new, in fresh processes:
    • CommanderUnitDetail pickle 4fc68ac7, schema 74234e98, unchanged;
    • GoalSnapshotReply pickle 92ead7d5, wire 648ee867, unchanged;
    • the update family keeps the same 23 names, transcript_snapshot, and page/identity/draft. The full transcript schema hash can't be computed in either tree (recursive types), so it isn't claimed.
  • Connector input schemas build for all 65 exposed tools. The new schemas carry no root/request_id/previous/owner/identity.
  • Source census (1,819 Core + Toad src/tests/tools modules, 0 parse failures):
    • 0 old declarations, imports or calls;
    • the literal goal_snapshot remains only in the two must-fail decode cases;
    • every transcript envelope constructor holds a TranscriptPageRead;
    • 30 string-named request() senders remain for other commands (cancel, edit_goal, goal_history, …), none a goal snapshot read;
    • 92 non-literal getattr/setattr/hasattr sites remain unresolved static limits, not zero consumers.

Controls added (not run here)

  • test_commander_notifications::test_expanded_unit_reads_original_orders_and_bounded_activity_then_refuses_stale_selection:
    • the direct operation, the root-bound ReadOperation and invoke_tool return identical records for the same card;
    • the stale controls now vary the operation's witnesses;
    • with the original .git broken, MCP raises exactly WorkBranchError.
  • test_input_delivery_history::test_mcp_and_the_ui_read_the_owner_goal_from_the_live_owner: on the canonical owner socket, the owner's projection equals MCP invoke and the UI ReadOwnerOperation for an empty and a set goal, including meta.batch == (TurnChangedUpdate(...),). It refuses after the owner stops.
  • test_transcript_pages::test_one_page_operation_pages_alike_for_the_ui_and_mcp: 45 records, to the beginning and back with no duplicates, then an empty transcript. Every page is identical through the operation, the UI envelope and MCP. test_historical_views does the same for an attached recorded source.
  • test_notification_read_state:
    • root-free family round-trips through bare UiRead/OwnerLiveRead;
    • envelope codec, pickle, hash, set and dict identity, where root, request, unit birth, orders, cursor and archive each make a distinct request;
    • one operation under two roots keeps the foreign-root refusal;
    • catalogs and exact results, read from capabilities;
    • lifecycle envelopes are never tools.
  • test_observer_death owner reads derive solely from members_with(OwnerLiveRead). test_command_families round-trips owner_read(comms_owner_goal) and refuses both old goal_snapshot forms.

Not claimed: root's focused selections, hosted checks, the paired #3538 + #1302 integration, whole-process GC, 16 ms / p99.9, 29-agent coverage and P872 completion. They wait on root's evidence.

Plan (ticked as commits land)

  • Settle input delivery's scope: owner-live read.
  • BusRead[Result] with BusReader authority; notifications and recorded notifications migrated end to end (c57a7476b).
  • Goal history: one RecordedGoalHistory record for every boundary (7326056; Toad Goal history is Core's one RecordedGoalHistory record toad#1301 merged as 5fa29f3).
  • Input delivery as one OwnerLiveRead: MCP, the UI and the owner socket carry one InputDeliveryRead; the 3 duplicate declarations are deleted (36611e2b6, tests c0821f571; Toad Input delivery reads through Core's one owner-live operation (agent-comms#3538) toad#1302).
  • UI dispatch decodes a bare UiRead through ReadOperation; connector and native catalogs derive from the existing ToolRequest capability (no McpRead, no pairing table); each boundary keeps its own caller/root validation and publication; the root is never a tool argument (780b9957e, d5b7376f8).
  • Notification reads: root-free LiveNotificationRead/RecordedNotificationRead under NotificationRead, one ReadNotifications(root, operation) UI envelope, MCP outcomes without a UI envelope (21b07552a; Toad a9a330b). Head verdict pending.
  • Transport relocation: OwnerReading/OwnerReplyConnection in owner_connection.py, OwnerReadRuntimeRequest in bus_read.py, Result = ToolResult default, require_native_owner; no cycle, no function-local imports (5c262628f).
  • Owner lifetime: shared native check (also repairs ReadOwnerGoal for external owners), held-identity check, and real EOF/root/recreation/death/external controls (a4702b1f7, 08263a4c0).
  • Commander-unit read: CommanderUnitRead (comms_commander_unit) in the generic ReadOperation envelope; ReadCommanderUnit deleted (780b9957e; Toad 9b6c482e2).
  • Owner-goal read: OwnerGoalRead (comms_owner_goal) owner-live, turn metadata kept, writes delegate; GoalSnapshotRuntimeRequest and ReadOwnerGoal deleted (1c8b7e8eb; Toad 6f41bc6f9).
  • Public transcript page: TranscriptPageRead (comms_transcript_page); TranscriptRequest holds only the operation; RefreshTranscript keeps its identity (d92c06392; Toad 9572b2f2c).
  • Installed-Toad callers: commander page, acp/agent.get_goal_snapshot, session_scope snapshot/update requests and historical_sessions build root-free operations inside their UI envelopes; acp/transcript_reader's RefreshTranscript(root, identity) is a retained lifecycle boundary. Root stays in transport identity.
  • Root: focused selections, hosted checks, paired One declaration per bus read: UI, MCP and owner-socket reads derive one operation #3538 + P325: return owner subscription busy refusals without waiting #1302 integration, installed GC/frame acceptance.

Toad impact so far

  • Goal history (7326056): Goal history is Core's one RecordedGoalHistory record toad#1301 moved 7 consumers (16 sites) to RecordedGoalHistory; merged 5fa29f3.
  • Input delivery (36611e2b6): Input delivery reads through Core's one owner-live operation (agent-comms#3538) toad#1302 (3772cf0 source, +7/−6; 0be58d8 adds the native acquisition test). input_delivery sends ReadOwnerOperation(root, thread, InputDeliveryRead(include_history=...)) and wraps the owner's overview in ReadDeliveryView. The no-coordination DeliveryView(), require_owner and the dismiss_historical_inputs write are unchanged. It must land with or after this PR.
  • Native acquisition control (Toad 0be58d8, tests/native/test_native_goal_owner.py): the real native session runs controller.input_delivery() and input_delivery(include_history=True). Each returns a ReadDeliveryView whose current_scope is OwnerQueueInputDeliveryScope, the scope only the live owner produces, with empty inputs and zero counts on the fresh private wire. Nothing is mocked. Not run here.
  • Hosted debt-src on 08263a4 also counted ClientTypeErrors +1 in Toad's agent_controller.py. That's Toad main still importing the deleted ReadDeliveryOverview, which only P325: return owner subscription busy refusals without waiting #1302 removes, so it clears when the pair lands together, not from Core.
  • Notifications (c57a7476b): none.
  • Final read surface: Input delivery reads through Core's one owner-live operation (agent-comms#3538) toad#1302 9b6c482e2 (commander page → ReadOperation(root, CommanderUnitRead(...))), 6f41bc6f9 (get_goal_snapshot → ReadOwnerOperation(..., operation=OwnerGoalRead()), with turn token, require_owner and meta.batch dispatch unchanged), 9572b2f2c (session_scope/historical_sessions → TranscriptPageRead with every cursor and source key; a recorded-reader test asserts the operation's page arguments and the envelope's root/previous).

Verification

  • py_compile, import and undefined-name checks on every touched file.
  • Fresh-process declaration check of the input-delivery wire, catalogs, schema, pickle and hash (output above).
  • Toad P325: return owner subscription busy refusals without waiting #1302 imports against this tree.
  • At 5c262628f, fresh-process imports in forward and reverse order (owner_connection → … → connector and back), plus runtime, ui_model.observation and bus_read alone: all import, and RuntimeRequest.member_named('owner_read') resolves to bus_read. OwnerLiveRead.__type_params__[0].__default__ is ToolResult. The declaration check gives identical wire, schema, catalogs, pickle and hash; the only change is that OwnerReading left InputDeliveryRead's MRO, as intended. The changed source contains 0 banned words.
  • Moved-name census across Core and Toad src/tests/tools: 0 imports from runtime_requests/runtime of the moved names, 0 OwnerReadRuntimeRequest.declared_name reads, 0 admit in our code. Anchors: bus_read.py:108, owner_connection.py:260/263/272.
  • Fresh-process imports of owner_socket_fixture, test_runtime, test_observer_death, test_input_delivery_history and test_command_families against installed Core 6c1887e59's interpreter: all import, and the 6 lifetime tests are collected by name.
  • No tests run here, per order; the root runs the combined selection.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA

trissim and others added 2 commits October 27, 2026 13:26
ReadNotifications carried a caller-choosable viewer field so the MCP
outcomes tool could reuse it under the caller's authority. That made
the reader's authority an argument any caller could set, and the UI and
MCP sides each restated who reads. Now a BusRead member owns its
arguments and its read once, as read_as(comms, reader); the boundary
supplies the BusReader. UiRead derives the UI's read() as the operator,
and the MCP tool reads under its own ToolCaller, which is now a
BusReader. OperatorOutcomes moves to bus_read beside the reader it
serves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
@trissim
trissim marked this pull request as ready for review October 27, 2026 17:27
Copilot AI balanced review requested due to automatic review settings October 27, 2026 17:27

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@trissim

trissim commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

Handoff: exact state of #3538 at 3c23ca2 (P872 item 4)

Landed on the branch (step 1, c57a747, +77/-29 over 5 files):

  • src/agent_comms/bus_read.py (new, 58 lines): BusReader (whose authority a read runs under), OperatorOutcomes moved here from tool_caller.py, OperatorReader(OperatorOutcomes, BusReader), BusRead[Result] with read_as(comms, reader), and UiRead[Result], whose read(comms) runs read_as as the operator.
  • ToolCaller now inherits BusReader. Its own abstract outcome_viewer is deleted, because the parent declares it.
  • ReadNotifications / ReadRecordedNotifications are UiReads. The viewer: OutcomeViewer = field(default_factory=AllOutcomes) field is deleted, so a caller can no longer pick whose outcomes it reads by passing an argument.
  • CommsMessageOutcomesTool (tools.py:716) calls ReadNotifications(...).read_as(comms, caller). It runs the UI's read declaration under the MCP caller's authority, not a second copy of the read.

Nit to fold in: tools.py:719 still asks caller.outcome_viewer(comms) for the sees check, and read_as asks it again. Move the refusal into read_as, or give BusReader a require_sees(source).

Remaining migration, checked against origin/main just now:

  1. Input delivery: one read, two copies that already disagree.
    • MCP: CommsInputDeliveryTool.apply at order_tools.py:507-508 runs comms.goals.input_delivery(caller.subject(self.thread), include_history=True). It never asks for a delivery scope, so it always answers "unobserved".
    • UI: runtime_requests.py:279-282 passes scope=await ctx.server.agent.inputs.delivery_scope(ctx.session_id).
    • Fix: make it one BusRead (InputDelivery) whose read_as takes its scope from the reader. A new OwnerLiveRead capability answers through the owner socket when the owner is live; otherwise the read answers with history. Then delete the hand-written MCP apply. The scope owner stays input_drain.py:865-877.
  2. The rest of the UI-read and MCP-tool pairs are listed in One declaration per bus read: UI reads, MCP tools and owner-socket requests restate the same operations #3537 (ui_model/observation.py AwaitedRead/ReadRequest/OwnerRead, against the ReadTool classes in tools.py). Each pair becomes one BusRead member, and the duplicate body is deleted.
  3. Last step: give McpRead capability on BusRead that derives the ToolRequest exposure, so the MCP tool class stops being hand-paired with the read. The done check is that grep -rn "def apply" src/agent_comms/tools.py src/agent_comms/order_tools.py lists only tools that are not reads.

Done list: (A) input delivery is one declaration and the MCP side reports live scope; (B) every #3537 pair is migrated; (C) McpRead derives exposure, and the hand-written read tools are deleted; (D) the PR body follows 1b.

one read, two copies
MCP copy hard-codes include_history=True
"unobserved" forever
works on my socket

@trissim
trissim marked this pull request as draft October 27, 2026 17:41
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

debt-src at 80cff6a, every finding:

grew ClientTypeErrors:batrachian-toad/src/toad/acp/agent.py +1: this change removes or changes an agent_comms name that client file uses; run mypy on it to see which
grew ClientTypeErrors:batrachian-toad/src/toad/acp/agent_controller.py +1: this change removes or changes an agent_comms name that client file uses; run mypy on it to see which
grew ClientTypeErrors:batrachian-toad/src/toad/screens/commander.py +1: this change removes or changes an agent_comms name that client file uses; run mypy on it to see which
grew ClientTypeErrors:batrachian-toad/src/toad/screens/historical_sessions.py +1: this change removes or changes an agent_comms name that client file uses; run mypy on it to see which
grew ClientTypeErrors:batrachian-toad/src/toad/session_scope.py +7: this change removes or changes an agent_comms name that client file uses; run mypy on it to see which
grew ClientTypeErrors:batrachian-toad/tests/textual/test_historical_notification_source.py +1: this change removes or changes an agent_comms name that client file uses; run mypy on it to see which
::warning file=src/agent_comms/typed_table.py::GodClassExcess +13 (warns, never blocks)
::warning file=src/agent_comms/bus_read.py::LintFindings +2 (warns, never blocks)
::warning file=src/agent_comms/notification_assignment.py::LintFindings +1 (warns, never blocks)
::warning file=src/agent_comms/order_tools.py::LintFindings +5 (warns, never blocks)
::warning file=src/agent_comms/owner_connection.py::LintFindings +2 (warns, never blocks)
::warning file=src/agent_comms/transcript_updates.py::LintFindings +3 (warns, never blocks)
grew NoneAbsence:src/agent_comms/order_tools.py +4
::warning file=src/agent_comms/bus_read.py::Unformatted +1 (warns, never blocks)
::warning file=src/agent_comms/notification_reads.py::Unformatted +1 (warns, never blocks)
found NoneAbsence:src/agent_comms/order_tools.py at src/agent_comms/order_tools.py:580 src/agent_comms/order_tools.py:578 src/agent_comms/order_tools.py:577 src/agent_comms/order_tools.py:576

trissim and others added 3 commits October 28, 2026 05:02
)

ContentCache stored mypy counts under a key of committed content and checker
versions, but mypy also reads the installed packages, which no key names.
Hosted #3538 restored debt-ratchet-src-71453f18 (saved by main cache job
40654054315 under a Core-only install, Pydantic 2.14.1) and compared that
stored base count with a head count measured under the full stack
(Pydantic 2.13.5).

- CheckedRevision.findings runs the checker. TypeErrors inherits it and
  keeps mypy's own --cache-dir, which tracks dependency changes itself.
- LintFindings.findings keeps the content-keyed memo unchanged: Ruff reads
  only the tree and the head's configuration.
- Unformatted keeps its per-file blob cache unchanged.
- ClientTypeErrors.findings calls checked directly. The client commit and
  src tree it computed only for the key are gone.

Regression: one parameterized real-mypy test switches an installed PEP 561
dependency between two sys.path environments, with committed source, client
and configuration unchanged and the same count-cache directory. The count
must follow the dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
GoalHistory (tools.py) and GoalHistoryReply (runtime_requests.py) were
empty subclasses of RecordedGoalHistory, and recorded_history took the
result class as an argument so each caller picked its own empty wrapper.
Neither added a field or a behavior.

- RecordedGoalHistory now extends ToolResult (itself a DeclaredReply), so
  the MCP tool and the owner socket return the same record.
- GoalHistory and GoalHistoryReply are deleted.
- GoalManagement.recorded_history(name, scope) returns RecordedGoalHistory;
  the reply argument, its HistoryReply TypeVar and the TypeVar import are gone.
- CommsGoalHistoryTool and GoalHistoryRuntimeRequest return it directly;
  tool name, declared runtime name, subject resolution, scope decoding and
  the worker-thread execution are unchanged.
- tests/test_goal_execution.py compares against RecordedGoalHistory.empty().

The encoded record is unchanged: one populated history/failure payload
decodes and re-encodes byte-identically through GoalHistory,
GoalHistoryReply and RecordedGoalHistory before this commit and through
RecordedGoalHistory after it; empty() encodes as
{"failures": [], "history": []} on both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
trissim and others added 2 commits October 28, 2026 06:24
…the owner (#3538)

CommsInputDeliveryTool read goals.input_delivery in the connector's own
process under UNOBSERVED_DELIVERY_SCOPE: it could not see the owner's live
queue, yet returned an ordinary-looking delivery view. The UI and the owner
socket each restated the same read again (ReadDeliveryOverview,
InputDispositionsRuntimeRequest, with their own include_history).

- BusRead is now an abstract capability of the existing ToolDeclaration
  family (no DeclaredFamily root of its own); UiRead still derives from it.
- BusReader owns subject/own_thread/refuse_thread once; the copies on
  ToolCaller and NoThreadCaller are gone, refusal text and type unchanged.
- OwnerReading: one exchange with one held owner incarnation; admit()
  defaults to any owner, OwnerLiveRead admits only native owners, so an
  external bridge (live PID, no socket) is refused instead of retried forever.
- OwnerReplyConnection.held/answer: the one connection factory (exact
  incarnation, root, process; 3 s reply timeout, close in finally, no retry
  after dispatch) for OwnerRead and OwnerLiveRead.
- OwnerLiveRead: MCP apply/read_as resolves caller.subject(thread) and asks
  the owner via asyncio.run on the tool's synchronous boundary; the owner
  answers in owned(ctx) with ctx.name and its own session.
- InputDeliveryRead (comms_input_delivery, thread, include_history=True):
  the one declaration, in the ToolRequest/native catalogs by capability.
  owned() takes scope only from the owner's delivery_scope.
- OwnerReadRuntimeRequest (owner_read) and ReadOwnerOperation: one generic
  runtime envelope and one generic UI envelope carrying the operation.
- Deleted: CommsInputDeliveryTool, ReadDeliveryOverview,
  InputDispositionsRuntimeRequest, the input_dispositions request.
- The connector encodes Refused, ConnectionError and TimeoutError from an
  owner read as a failed tool call instead of dropping the HTTP call.
- tests/test_command_families.py: owner_read round trips for both history
  settings; a non-owner-live operation and a bad include_history refuse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
… a live owner (#3538)

- test_observer_death: unavailable/stale owner reads are parametrized from
  ReadOwnerGoal plus ReadOwnerOperation over BusRead.members_with(OwnerLiveRead),
  not OwnerRead.__subclasses__(); refusal and observer-alive assertions kept.
- test_input_delivery_history: the CLI test that asserted a declared owner with
  no process answers delivery state is deleted (that answer was the defect).
  The replacement starts the canonical owner socket, queues a prompt, records an
  off-queue notice, and asserts MCP invoke and the UI envelope both equal the
  owner's own projection for include_history False and True, that the off-queue
  notice is current only under the old unobserved projection, and that a stopped
  owner refuses the MCP read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
trissim and others added 10 commits October 28, 2026 06:49
…re handed (#3538)

- OwnerReading.admit is the shared native-transport admission
  (owner.execution.require_native()). OwnerLiveRead.admit and
  ReadOwnerOperation.admit were identical restatements and are deleted.
  ReadOwnerGoal now reaches it too: an external bridge has a live PID and no
  RuntimeServer socket, and _connect_current waited for that socket forever
  before dispatch. It is refused before the wait.
- OwnerReplyConnection.held compared the registry's incarnation with the
  supplied one, then OwnerConnection.__init__ read the registry again into
  _identity, so a replacement incarnation could be recaptured unchecked.
  held now constructs the connection and OwnerConnection.require_identity
  compares the captured _identity with the supplied witness before returning;
  the earlier separate comparison is deleted. No second identity field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
…ation, death, external controls (#3538)

tests/owner_socket_fixture.py (registered in pytest_plugins) is the one fixture
owner: runtime_processes (moved from test_runtime.py; it now yields the spawned
children so a test can stop one through its own owner), declare_owner (native or
external registration) and silent_socket (the real Unix-socket peer from
test_dispatched_request_without_a_reply..., which receives one request and
closes without replying). The existing reply-loss test is refactored onto it.

New controls, nothing of ours patched, no fabricated reply:
- MCP InputDeliveryRead without a reply, for include_history False and True:
  OwnerReplyLostError, exactly one received request decoding to
  OwnerReadRuntimeRequest(thread, InputDeliveryRead(thread, include_history)),
  durable inputs unchanged. The peer runs no domain body.
- Root replaced under a held connection (rename to a sibling, new directory at
  the old path): OwnerIdentityChangedError, zero requests; root restored.
- Same-name recreation (unregister, begin_delete, remove, declare): the held
  read and a new held() with the old witness both refuse; incarnations differ.
- Held process death (runtime_processes child stopped through stop_sync): the
  read refuses with ConnectionError before any request; inputs unchanged.
- External owner with a live child and no socket: MCP delivery refuses with
  RelationViolationError; through the observer, ReadOwnerGoal and the
  input-delivery envelope (derived from OwnerLiveRead membership) both refuse
  and the observer stays alive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
…ps importing its transport (#3538)

OwnerLiveRead extracted OwnerReadRuntimeRequest.declared_name and reached
runtime/runtime_requests through two function-local imports to escape the
cycle that extraction created, and OwnerReadRuntimeRequest.result returned Any
because OwnerLiveRead had no Result default (hosted debt-src: FunctionLocalImport
+2, FamilyFlattened +1, TypeErrors runtime_requests +1, BannedVocabulary +3).

- OwnerLiveRead[Result: ToolResult = ToolResult]; operation fields stay the bare
  family class. OwnerLiveRead no longer inherits OwnerReading and has no socket
  read: owned/decode_reply are its answer and decoder. read_as builds
  OwnerReadRuntimeRequest(thread=owner.name, operation=self), sends it through
  OwnerReplyConnection.answer and decodes the reply. asyncio.run stays at the
  synchronous tool boundary.
- OwnerReadRuntimeRequest moves from runtime_requests.py to bus_read.py as
  OwnerReading[object] + ResultRuntimeRequest; same owner_read action and
  operation field. Its read(owner) is the one socket exchange:
  owner.request(self.declared_name, operation=self.operation).
- OwnerReading and OwnerReplyConnection move into owner_connection.py beside
  OwnerConnection. OwnerReading.read takes OwnerConnection. OwnerReplyConnection
  derives OwnerConnection directly (owner_read and goal_snapshot use the base
  proxy_payload; only PromptRuntimeRequest needs the ACP controller token).
  REPLY_TIMEOUT, held/answer/close and the require_identity check are unchanged.
- The native transport check is OwnerReading.require_native_owner, called from
  held; RuntimeRequest.require_owner(entry) is untouched.
- ReadOwnerOperation.read builds the envelope with owner.session_id and decodes
  through the operation; ReadOwnerGoal.read takes OwnerConnection; OwnerRead.ask
  loses its local import. All imports are module scope.
- Old definitions deleted, no aliases. tests/test_runtime.py imports the moved
  names from bus_read/owner_connection, and the held root/recreation/death
  controls hand held() the real OwnerReadRuntimeRequest and await its read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
…on lifecycle (#3538)

Root's selection on 5c26262 ran 60 cases: 49 passed, 11 failed. One same-name
control used remove() after begin_delete, which correctly refuses Deleting
status; ten observer fixtures used the removed synchronous observer surface
(`with`, readable()/results()/fileno(), sync close, NotificationWindow == {}).

- test_runtime same-name control: deletion completes through
  edit.document.remove_originals((old,)) in registry.store.editing(), the path
  production uses (thread_management.py:537); the captured connection, old
  witness, zero-dispatch peer and both OwnerIdentityChangedError refusals stay.
- test_observer_death: every observer is `async with`; empty-window checks
  compare .outcomes. The killed-observer test uses the real receiver: one read
  proves startup, SIGSTOP is confirmed with waitpid(WUNTRACED), the next read is
  shown pending, then SIGKILL; ObserverDied carries the child pid, -SIGKILL and
  signal text, and StopSidebar and a later read raise that same object. A stopped
  child is killed and a pending task cancelled in finally. The failed-observer
  test asks one real read on the not-a-wire route and requires exit status 1 and
  the original traceback. The readable() helper is deleted.
- test_busy_history_loading: three `async with` sites, the close-once test's
  `async with process` and awaited repeated close, .outcomes comparisons; the
  Connection.close endpoint observation is unchanged.
- test_deleted_thread_observation and test_unreadable_history_observation (the
  two readable() importers) start the receiver once with start_receiving and
  wait on tests/observed_queue.py, a test-local queue of delivered results that
  raises a receiver failure as its original exception. No manual pumping.

No production change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
…ked for (#3538)

Root's rerun on 9bb8b2e: 22 cases in 70.37 s, 20 passed, 2 failed, both the
other_root=False stale-identity cases (goal and input delivery). held looked up
the selected thread's process before any identity comparison, so a stale
incarnation was refused as "no owner process" (RelationViolationError) instead
of OwnerIdentityChangedError. a4702b1 had dropped the initial comparison when
it added the constructor check.

- OwnerReplyConnection.held: registry.require(owner.name), then
  thread.incarnation != owner -> OwnerIdentityChangedError, then
  reading.require_native_owner(thread), then require_process/socket path and
  construction, then connection.require_identity(owner). The two comparisons
  check two different registry reads (the selected thread and the
  constructor's capture); no identity field or cache is added.
- ObservedQueue.until holds one 30 s bound over the whole match; next() keeps
  its own bound for direct callers. Before, each unrelated result restarted the
  bound, so a steady stream could keep a wait running indefinitely.
- test_deleted_thread_observation restores one 30 s bound around waiting for
  both the missing-thread result and the retirement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
…one (#3538)

The notification read lived on the UI request: ReadNotifications held root and
request_id beside its references, ReadRecordedNotifications subclassed it for
the archive, and comms_message_outcomes built that UI envelope with
str(comms.root) only to reach read_as.

- notification_reads.py (new): NotificationRead(UiRead[NotificationWindow])
  with changed_since; frozen LiveNotificationRead(references) and
  RecordedNotificationRead(references, source). Two members: archive identity
  and the change rule differ (live activity/evidence vs history revision).
  The module sits outside notification_read.py because presentation imports
  that module and bus_read imports presentation.
- NotificationsRead and UnreadableNotificationsRead move unchanged into
  notification_read.py beside CapturedNotificationWindow.
- ReadNotifications(ReadRequest) keeps root and request_id and carries
  operation: NotificationRead; it is no longer a UiRead. read() delegates to
  the operation (OperatorReader). ReadRecordedNotifications is deleted.
  NotificationReading asks request.operation.changed_since; read states stay
  keyed by the whole request.
- CommsMessageOutcomesTool reads LiveNotificationRead((reference,)) under its
  caller; refusal, visibility, paging and its public schema are unchanged.
- Callers migrated: observer fixtures carry LiveNotificationRead(()),
  test_thread_read_results reads the operation instead of a fake UI root,
  read-state tests call the operations. New controls: family/codec/pickle and
  request identity, archive change drops state, root validation per
  envelope, capability discovery, a foreign operation refused; a 125-message
  live batch across two window chunks; the recorded operation reads the
  archive (not live evidence) and a detached key refuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
…e says what it returns (#3538)

NotificationsRead.activity spelled "this participant has no projection" twice:
absence from the window's watched names, and a None value. observed_activity
already omits registered humans and archived rows and answers DeletedThread for
a deleted name, so the None was a second encoding of absence.

- activity is Mapping[str, ObservedActivity | DeletedThread], keeping only the
  window's names that the projection actually returned.
- live_changed re-projects the window's full activity_names() when ActivityView
  moved and compares the sparse maps, so omitted->observed, observed->omitted and
  observed->DeletedThread still invalidate. With no ActivityView change it does
  not project at all.
- HistorySource.notification_references annotates references as
  Sequence[MessageReference] and returns NotificationWindow, the type
  MessageNotification.read_references already returns. That closes the Any that
  reached RecordedNotificationRead.read_as.
- The read-state fixture that built {absent-participant: None} now settles a real
  #team read whose participants are an agent, a human, an archived agent and a
  deleted agent. New controls cover: omitted humans and archived agents, an
  archived agent declared again, a deleted participant, and zero or one
  projection by revision kind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
NotificationWindow.activity_names() comes from native-recipient outcomes, so the
#team fixture watches worker, archived and gone, never the agent sender or the
human. Two assertions expected sender and human, and failed in the root run
(29 passed, 2 failed). The window-names test now requires exactly
{worker, archived, gone} and excludes sender and human; only archived is omitted
from the sparse activity. The projection-count test requires archived as the one
watched-but-omitted name. No production change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
…e no acquired evidence (#3538)

A recipient-scoped thread read selected every claim of each broadcast by
wire_seq alone, decoded them, and filtered recipients only afterwards: the saved
six-tab desktop held 17,681 claims to show 15 rows. The same acquired graph
(frozen audiences, decisions, WakeAssignment and activity rows) then travelled
inside ThreadPresentation to the UI, twice over, through notification_window and
through each notification's message, which was the backend record itself.

Viewer captured once:
- NotificationAssignmentRead carries its required OutcomeViewer. read, capture,
  missing_store and invalid_store take it, and the public missing store returns an
  empty read with that viewer; the private one still refuses.
- capture builds the visible (recipient_lookup, wire_seq, message_id) keys
  through the viewer before any SQL. Claims are selected through the
  recipient/seq index by those keys, using TypedTable.requested_keys (factored
  out of select_keys, which now consumes it). HarnessDelivery.for_recipients
  gets the same keys. Rows keep the original wire_seq DESC, recipient order
  across batches.
- delivery_window, project_delivery_window and for_sources read the captured
  viewer instead of taking a second one. for_deliveries skips invisible
  recipients before allocating an UnrecordedNotificationSource, and
  recorded_delivery_window allocates no placeholder activity for them.
- NotificationAssignment.changed groups retained reads by their own viewer and
  re-reads each group's sources as that viewer.
- SelectedParticipant captures its sent read as OwnerOutcomes of the owner, so the
  sender still sees every outcome.

Public envelopes:
- ThreadPresentation.with_sources and MessageNotification.for_sources attach
  source.envelope, the declared public Message, instead of the Delivered record.

Acquired versus published:
- ThreadPresentation keeps only the UI fields: status, binding, notifications and
  read_identity.
- AcquiredThreadPresentation adds incoming_references, sent_references and
  notification_window. It is built by with_sources, supplies revision inputs and
  sent_summaries (replacing SentMessageSummary.recent), and published() derives
  the base from the declared fields. Pickling it raises a TypeError naming
  published().
- ThreadReadResult.published() defaults to itself and ThreadPresented projects its
  presentation. ThreadReading keeps the acquired results for its next change
  check and publishes only the changed results' projections.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
trissim and others added 3 commits October 28, 2026 10:24
…ations (#3538)

- test_outbound_transcript_source:
  - each recipient's own read decodes exactly one claim row (profiled
    AssignmentActivityEvidence._decode_row), equal to that recipient's rows and
    attempts in the operator read; the sender read equals the operator read;
  - invisible recipients allocate no UnrecordedNotificationSource, and the
    sender keeps all of them;
  - one claim transition refreshes only the retained reads whose viewer sees it:
    the recipient, sender and operator reads, not the other recipient's.
- test_recipient_activity_index:
  - a viewer follows frozen lookups through rename and never into a rebirth;
  - a public root without a coordinator returns an empty read with the same
    viewer and projects only visible recipients;
  - a private root without one still refuses.
- test_thread_read_results:
  - ThreadReading retains the AcquiredThreadPresentation and publishes its
    ThreadPresentation projection, with the same status, read_identity and
    notifications, whose messages are public envelopes;
  - the pickled observation carries no acquired evidence type, and sending the
    acquired result is refused;
  - after a second change, the UI's current and last-published rows reach no
    acquired evidence.
- test_thread_presentation: absence publishes itself and summarises no sent
  message; its notification_window assertion read a field absence no longer has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
…ch and null-key controls (#3538)

presentation_fields() returned dict[str, object], so expanding it into
ThreadPresentation(...) and AcquiredThreadPresentation(...) erased every field's
type: 10 new arg-type errors at thread_presentation.py:287/311. It is deleted.

- with_sources builds shown = replace(self, notifications=..., read_identity=...)
  once and constructs AcquiredThreadPresentation from
  {declared.name: getattr(shown, declared.name) for declared in
  fields(ThreadPresentation)} plus its three acquisition fields.
- AcquiredThreadPresentation.published constructs ThreadPresentation from the
  same inline mapping over itself, the ThreadStatusDisplay.status_display shape.
  There is no cast, Any, ignore or copied field roster.
- test_outbound_transcript_source::test_a_forced_key_batch_captures_the_same_evidence_through_the_recipient_index:
  - setup: a second real #team original in the private _root fixture gives at
    least 4 distinct claim keys, plus a repeated original;
  - batching: on the fixture's own coordination connection, SQLITE_LIMIT_VARIABLE_NUMBER=6
    (restored in finally) forces several key batches, with deduplicated bindings
    equal to the unlimited batch;
  - result: the whole capture equals the unlimited read in rows, attempts,
    evidence, order and viewer, with no duplicated assignment;
  - index: the traced claim statements' EXPLAIN QUERY PLAN SEARCHes wake_claims
    through the unique recipient_lookup/wire_seq index found by PRAGMA, never a
    SCAN of it.
- round2 test_declared_table_family: select_keys selects the weight=None child
  through a None key once, even when the key is repeated, and stops selecting it
  once the weight is 3.25.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
…n allows (#3538)

The fixed limit of 6 refused NativeRuntimeTable's schema catalog read inside
capture before any claim query ran (root: 1 passed, 1 failed in 3.30 s), so it
proved nothing about claim batching.

- The limit is the larger of 6 and each schema capture validates
  (privacy.coordinator_schema() and NativeRuntimeTable), counted as declared
  object names plus table names, the bindings of objects_of's catalog read. It
  must still be below the connection's own limit.
- Enough real #team originals are published and accepted in the same private
  fixture for more than limit / 3 distinct claim keys. The count is derived from
  the recipient count and must fit NotificationWindow.window_limit, and the
  repeated original stays as the duplicate control.
- The batching, equality, ordering, trace and EXPLAIN index assertions are
  unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
trissim and others added 10 commits October 28, 2026 11:25
…s its type (#3538)

The GC stage made two new source-gate regressions:
- NotificationAssignment.changed built an untyped `changed = []`.
- AcquiredThreadPresentation.sent_summaries imported SentMessageSummary and
  MessageNotification.summary back from presentation inside the method, a
  hidden runtime cycle.

Now:
- `changed: list[NotificationAssignmentRead] = []`.
- ThreadPresentation/AcquiredThreadPresentation.sent_summaries are deleted.
  sent_notifications() returns (reference, outcomes) pairs: () on the public
  presentation, the captured window's outcomes per sent reference on the
  acquired one.
- SentMessageSummary.for_sources aggregates those pairs with the same-module
  MessageNotification.summary. ThreadCaller.listing calls it.
- thread_presentation keeps only TYPE_CHECKING imports for these types;
  function-local imports 5 -> 4 (the four remaining predate this stage).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
…s class header (#3538)

OwnerLiveRead carried the only `apply(comms, caller) -> Result` forwarding to
read_as; every other BusRead would have needed a copy just to repeat a return
annotation for tests/test_tool_replies.py. The method moves to BusRead and the
copy is deleted.

The test's result_type now resolves a BusRead's result from the first concrete
specialisation of a BusRead capability along its MRO (types.get_original_bases,
the CliCommand.result_type pattern). Family headers still carrying their type
parameter declare nothing; none or two different answers are refused. Other
tools keep the apply-annotation path. Controls: RecordedNotificationRead
inherits UiRead[NotificationWindow]; bare BusRead/UiRead/OwnerLiveRead refuse;
an ordinary tool keeps its annotation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
…3538)

ReadCommanderUnit restated the commander-detail read as a UI-only request: the
domain witnesses (commander, unit, orders) and the business body lived on the
root-bearing envelope, so MCP had no declaration to serve.

- CommanderUnitRead (order_tools.py) is UiRead[CommanderUnitDetail] + ReadTool +
  ToolRequest, declared_name="comms_commander_unit", with only the held
  commander/unit incarnations and the card's orders. read_as is the old body
  verbatim: both incarnations checked against one registry snapshot, the
  current published frame, the exact card, card.detail with the exact orders,
  every ToolRefusedError kept.
- CommanderUnitDetail is a ToolResult directly; fields, codec schema and pickle
  bytes are identical to ed6ff38 (probe: pickle 4fc68ac7, schema 74234e98).
- ReadOperation(ReadRequest) is the one generic UI envelope: root, request id,
  the operation as a bare UiRead decoded through its family, and the old
  ToolRefusedError/WorkBranchError refusals. deferred_reads stays
  (CoordinationReadUnavailable,).
- ReadCommanderUnit is deleted. The real detail test compares the direct
  operation, the root-bound envelope and invoke_tool on the same card, and its
  stale-selection controls now vary the operation's witnesses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
The goal snapshot had two owner paths: the runtime command goal_snapshot (a
GoalSnapshotResultRuntimeRequest whose change() did nothing) that UI
ReadOwnerGoal sent by string, and a second copy of the same snapshot/turn
projection inside every goal write. MCP had neither.

- OwnerGoalRead (order_tools.py) is OwnerLiveRead[GoalSnapshotReply] +
  ThreadReadTool, declared_name="comms_owner_goal", with only the inherited
  thread selector. owned(ctx) is the old projection verbatim:
  goals.goal_snapshot(ctx.name), then turns.turn_state(ctx.session_id), then
  GoalSnapshotReply.from_snapshot(snapshot, UpdateMetadata.of(TurnChangedUpdate(turn))).
- GoalSnapshotReply is a ToolResult directly (it was a DeclaredReply); pickle
  and wire bytes are unchanged (probe: 92ead7d5 / 648ee867).
- GoalSnapshotResultRuntimeRequest.result awaits change(ctx) and then returns
  OwnerGoalRead().owned(ctx): the writes keep one copy of the read.
- Deleted: GoalSnapshotRuntimeRequest and UI ReadOwnerGoal. Decoding
  action=goal_snapshot, with or without an operation, is now MalformedRecord.
- Tests: the observer-death owner reads derive solely from
  members_with(OwnerLiveRead); the missing-owner busy test and the runtime
  family roundtrip use owner_read(comms_owner_goal); a canonical-owner test
  compares the owner's projection, MCP invoke and the UI envelope for an empty
  and a set goal, and refuses after the owner stops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
…#3538)

TranscriptRequest restated five domain arguments (thread, before, after,
through, historical_source) on every UI envelope, and RefreshTranscript built a
whole UI request just to reach the page read. MCP had no page tool.

- TranscriptPageRead (order_tools.py) is UiRead[TranscriptSnapshotUpdate] +
  ThreadReadTool, declared_name="comms_transcript_page": the inherited thread
  selector plus before/after/through cursors and historical_source with their
  old defaults. capture_as(comms, reader) resolves reader.subject(thread) and
  calls comms.transcripts.capture_page_read once; read_as returns
  TranscriptSnapshotUpdate.from_read of it. No new renderer, window, cap or
  wrapper.
- TranscriptSnapshotUpdate is AgentCommsUpdate + ToolResult. The update family
  stays first: same 23 update names, transcript_snapshot discriminator and
  page/identity/draft fields as ed6ff38. ACP encode/decode_updates unchanged.
- TranscriptRequest holds only `operation: TranscriptPageRead`; page_read is
  operation.capture_as(comms, OperatorReader()). ReadTranscript,
  SubscribeTranscript (deferred_reads=(StaleRevision,)) and
  ReadTranscriptUpdate (previous publication state) stay UI lifecycle
  boundaries, unexposed. No forwarding properties.
- RefreshTranscript keeps its original identity, bind_page_read and None for
  current content; on change it builds TranscriptPageRead from the identity's
  requested_name, cursors and historical_source and reads it through the UI's
  UnreadableHistory conversion. No UI request is constructed.
- Callers migrated: transcript_updates replay/subscribe and the busy-history
  tests. New tests: one operation pages 45 records to the beginning and back,
  then an empty transcript; each page is identical through the operation, the
  UI envelope and MCP invoke. The attached-recorded witness test does the same
  with historical_source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
…d-read declaration controls (#3538)

comms_commander_unit can raise WorkBranchError when an order's original Git
cannot be read. The UI envelope already lists it as a refusal, but the
connector's tools/call tuple did not, so over MCP it would escape as a crash
instead of a failed tool result. It joins the existing tuple; the real detail
test breaks the original .git and asserts invoke_tool raises exactly
WorkBranchError there. No other class is added and nothing broadens to
ValueError.

Declaration controls beside the notification envelope controls:
- commander-unit and transcript-page operations encode without root,
  request_id, previous, owner or identity, decode through bare UiRead, and
  their ReadOperation envelope round-trips codec, pickle, hash, set and dict;
- the owner goal decodes through bare OwnerLiveRead and its owner envelope
  pickles;
- root, request id, unit birth, orders, cursor and archive each make a
  distinct request;
- one operation under two roots keeps the foreign-root refusal;
- each public read is cataloged once in ToolRequest and NativeRequestTools,
  and its result, read off its class header, is exactly CommanderUnitDetail,
  GoalSnapshotReply or TranscriptSnapshotUpdate. UiRead tools are exactly
  {commander_unit, transcript_page}, OwnerLiveRead tools exactly
  {input_delivery, owner_goal}, and no UI lifecycle envelope is a tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
…guments

ToolsCallMessage.result caught CoordinationError but not MalformedRecord, so a
newline-complete corrupt native entry escaped tools/call as an HTTP 500 with no
id. MalformedRecord joins the existing failure tuple; ToolsCallResult.failed
keeps the id and the nominal error.

The shared-read controls sent FieldCodec.encode(operation) into invoke_tool,
which carries the "tool" selector ToolRequest.invoke refuses; they now send
FieldCodec.encode_fields(operation), the declared fields without the tag.

Real HTTP regressions in test_connector: corrupt saved session -> failed call
equal to the domain's MalformedRecord, then comms_channels still serves; stale
cursor after rebinding the session -> failed call equal to TranscriptChanged.
The deleted goal_snapshot command gets its own UnknownFamilyMember test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
The producer took an unannotated read and returned Any, so the typed
TranscriptPageRead.read_as (order_tools.py:584) failed the hosted type gate
through it. from_read now takes TranscriptRead and returns Self; the body,
fields and wire bytes are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
…elds extracted into a held class move

CleanChangedLines already decides which occurrences a change writes, but
Comparison.failed also failed its raw per-file growth through Measure.blocks,
so a moved occurrence that introduced nothing still failed on its
destination's count. CleanChangedLines.blocks is now False; every introduced
occurrence still fails through Comparison.failed, including one hidden by an
unrelated deletion. Measure.grown is the growth predicate blocks and the
site-locating step share, so raw counts, deltas and found sites are unchanged.

FieldExtraction proves one more transfer from the before/head AST: an old
class present at both revisions newly holds, in one typed field, one class
absent from the base that no other old class holds, and each field it lost is
declared once there with the same name, type (typing.Annotated metadata
aside) and default. Only occurrences inside that field declaration are
credited, each spending one departure. The holder is resolved through
DecoratorBinding (now also DecoratorBinding.named / module_of), refused when
the name is bound twice or the module star-imports. TranscriptRequest's four
cursor/archive fields moved into TranscriptPageRead are that case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

debt-tools at 6fc1a17, every finding:

::warning file=tools/debt_ratchet.py::LintFindings +8 (warns, never blocks)
grew TypeErrors:tools/debt_ratchet.py +6
grew TypeSwitch:tools/debt_ratchet.py +2
grew TypeSwitchArms:tools/debt_ratchet.py +20
found TypeSwitch:tools/debt_ratchet.py at tools/debt_ratchet.py:714 tools/debt_ratchet.py:1399 tools/debt_ratchet.py:1489 tools/debt_ratchet.py:1521 tools/debt_ratchet.py:1932 tools/debt_ratchet.py:1954 tools/debt_ratchet.py:2059 tools/debt_ratchet.py:2077 tools/debt_ratchet.py:2089 tools/debt_ratchet.py:2098 tools/debt_ratchet.py:2175 tools/debt_ratchet.py:2542
found TypeSwitchArms:tools/debt_ratchet.py at tools/debt_ratchet.py:714 tools/debt_ratchet.py:714 tools/debt_ratchet.py:714 tools/debt_ratchet.py:1399 tools/debt_ratchet.py:1399 tools/debt_ratchet.py:1399 tools/debt_ratchet.py:1399 tools/debt_ratchet.py:1399 tools/debt_ratchet.py:1399 tools/debt_ratchet.py:1399 tools/debt_ratchet.py:1489 tools/debt_ratchet.py:1489 tools/debt_ratchet.py:1489 tools/debt_ratchet.py:1489 tools/debt_ratchet.py:1489 tools/debt_ratchet.py:1489 tools/debt_ratchet.py:1489 tools/debt_ratchet.py:1489 tools/debt_ratchet.py:1489 tools/debt_ratchet.py:1489 tools/debt_ratchet.py:1489 tools/debt_ratchet.py:1489 tools/debt_ratchet.py:1489 tools/debt_ratchet.py:1489 tools/debt_ratchet.py:1521 tools/debt_ratchet.py:1521 tools/debt_ratchet.py:1521 tools/debt_ratchet.py:1521 tools/debt_ratchet.py:1521 tools/debt_ratchet.py:1521 tools/debt_ratchet.py:1932 tools/debt_ratchet.py:1932 tools/debt_ratchet.py:1932 tools/debt_ratchet.py:1932 tools/debt_ratchet.py:1932 tools/debt_ratchet.py:1932 tools/debt_ratchet.py:1954 tools/debt_ratchet.py:1954 tools/debt_ratchet.py:1954 tools/debt_ratchet.py:1954 tools/debt_ratchet.py:2059 tools/debt_ratchet.py:2059 tools/debt_ratchet.py:2059 tools/debt_ratchet.py:2077 tools/debt_ratchet.py:2077 tools/debt_ratchet.py:2077 tools/debt_ratchet.py:2089 tools/debt_ratchet.py:2089 tools/debt_ratchet.py:2089 tools/debt_ratchet.py:2089 tools/debt_ratchet.py:2089 tools/debt_ratchet.py:2098 tools/debt_ratchet.py:2098 tools/debt_ratchet.py:2098 tools/debt_ratchet.py:2098 tools/debt_ratchet.py:2098 tools/debt_ratchet.py:2175 tools/debt_ratchet.py:2175 tools/debt_ratchet.py:2175 tools/debt_ratchet.py:2542 tools/debt_ratchet.py:2542 tools/debt_ratchet.py:2542

trissim and others added 4 commits October 28, 2026 13:44
…fails

DecoratorBinding.bindings(tree, scope) reads ScopeNames' lexical scopes as the
one binding owner, and declarations() proves a name only when its scope binds
it exactly once, by an unconditional import, class, function or annotated
assignment in that body, with no star import. named()/of() (boundary_decoder,
dataclasses.field), held-class exports, field uniqueness, source
disappearance and the typing.Annotated strip all read it, so a nested-only
import, a rebound Annotated or export, a field reassigned after its
annotation, or a same-name binding hidden in unpacking no longer earns
transfer credit. DeclaredClass.bound_once and its whole-tree census are gone.

Measure.missing names counts whose required evidence a comparison lacks;
CleanChangedLines.missing is each positive head count with no introduced
report. Comparison fails on any, reports them as "missing" and prints them;
an explicit empty introduced list stays the measured zero-new-sites answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
…nd to

The field proof compared printed annotations, so the same spelling T imported
from alpha.Cursor and beta.Cursor counted as one type. DecoratorBinding.identity
rewrites every name and attribute reference in a field's type and default to
its owner from the existing lexical declarations: an import's source module
and export (relative imports and aliases via module_of), a declared class's
own module, a builtin only when neither the module nor the class scope binds
the name. Anything else - a class-scope binding, type variable, rebinding,
star import or unresolved name - is UnprovedIdentity, equal to nothing; the
proof and its key are one NominalProof result, and DeclaredClass.proved is
gone. Occurrences pair node for node across the proved declarations, so an
alias of the same owner carries its departure while a changed owner does not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
…cope

DecoratorBinding.named takes the scope it is read from (module by default)
and drops a spelling whose root that scope rebinds. DeclaredClass.annotated
and DeclaredClass.holding pass the class's own scope, so a class body that
rebinds Annotated or the held class's name proves neither the stripped inner
type nor the held-class edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
…e itself

The hosted tools gate counted the measurement repair's own AST case switches
(DecoratorBinding.bound and the switch inside declarations: TypeSwitch +2,
TypeSwitchArms +20) and its reconstructed coordinates (TypeErrors). ScopeNames
is the one foreign-AST visitor: its visit_<kind> hooks record each node's
bound names and the declaring nodes during the walk that assigns scopes, and
DecoratorBinding.bindings/declarations group those facts; a statement owns a
binding when it is that node or its direct child. SitePlace and
FieldExtraction.place are gone: credit is a Counter keyed by the destination
node the cached tree already owns. The edge list is typed and introduced is a
classmethod reading cls.members_with(cls).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dS2Kmg1WxNnAc78YHA
notification_assignment keeps this branch's requested-key batching through the
recipient/seq index and takes main's SQL-derived queued presence (#3579) in
place of transporting the current execution id; rows are already sorted by
(-wire_seq, recipient) after the batches, so main's ORDER BY adds nothing.
tool_caller and test_command_families keep the imports each merged file uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK
trissim added a commit that referenced this pull request Oct 29, 2026
Takes #3538's resolution of main; tool_caller drops the unused
CallingThreadUnavailable import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK
@trissim

trissim commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Batched: this PR's head is inside carrier #3566 (batch/integ-20261029-queue-core). Its own auto-merge is off and its own runs are cancelled. It lands when the carrier lands, so CI runs once on the batch, not per member.

@trissim

trissim commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Superseded: all 31 unmerged patches are carried in #3566 (queue-core-fix's P872/P883/P904 integration, which names P872). Nothing lost; landing goes through #3566.

@trissim trissim closed this Oct 30, 2026
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.

One declaration per bus read: UI reads, MCP tools and owner-socket requests restate the same operations

2 participants