Skip to content

feat: Remote Multi-Environment support — full 22-PR spec spine (host + client + hardening) - #899

Draft
adriandemian wants to merge 524 commits into
mainfrom
feat/remote-multi-env
Draft

feat: Remote Multi-Environment support — full 22-PR spec spine (host + client + hardening)#899
adriandemian wants to merge 524 commits into
mainfrom
feat/remote-multi-env

Conversation

@adriandemian

@adriandemian adriandemian commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

RalphX Remote Multi-Environment support: every RalphX desktop can act as a host (exposing its UI-grade surface — projects, kanban, chat, reviews — to the owner's paired devices over Tailscale) and as a client (registering N remote hosts as switchable environments next to the local one). One protocol serves both the desktop client and a future mobile app. All work ships dark behind independent remote_host / remoteEnvironments flags; existing :3847/:3848 behavior is byte-identical with flags off.

This branch carries all 22 spine PRs of the spec — Phases 0 through 3 complete — plus seven adversarial review rounds, a spec-alignment pass, and the P-11 facade-coverage campaign (12 batches so far, 133 of 499 command names left to zero-unclassified).

What's landed

Host mode (Phase 1)

  • Authenticated listener on :3849 with bind policy, CORS-before-auth ordering, and lifecycle management; Tailscale Serve acquire/release wired into the listener lifecycle via the CLI resolver.
  • Auth core: single-use pairing codes (QR + 4-char-grouped manual entry), device tokens, live-session registry, per-device agent-control toggle, atomic pairing audit rows, session caps, revocation with immediate WS teardown.
  • Command facade with a compile-gated capability model: 540-command ledger, two-detector authority audit (call-graph reachability + spawn-triggering state writes) with a CI-diffed background-loop inventory, agent-consumed content surface derived from the live MCP tool registry, WorkerTaskView field-allowlist projection, spec-literal Denied module set, and a generated six-table manifest (docs/generated/remote-commands.json) that fails CI on staleness.
  • Durable event sequencer with per-boot epoch, WS session loop with backpressure teardown, retention lease, reconnect-hydration Read facades.
  • Remote Access settings pane (pairing, device list, live sessions, revoke, agent-control consent dialog).
  • Facade v2 mutating surface (27 registered ops on the viewer-with-brakes split: watch/deny/brakes/inert-metadata at ui:operate, kanban/steering at ui:agent), a generated 110-member negative-test suite over the audit floor, and a soundness hardening round that made the ledger CI gates self-proving (9 gates verified red-first by mutation; 2 caught live defects on arrival).

Client mode (Phase 2)

  • Environment registry + identity + startup reconciler; transport router with NetworkInvoke/backendFetch migration so every UI data path is environment-aware; tokens live in the Rust proxy, never the webview.
  • NetworkEventBus with H-barrier cold hydrate and commit-gated cursor; 7-state connection supervisor (incl. suspended on app visibility) driven by a checked-in 119-row transition table; Rust outbound WS client + per-environment frame relay.
  • State isolation: env-keyed persisted store slices, keyed QueryClient registry with cold-on-reactivation, app-lifetime bus registry, and the environment switcher in the top bar (synchronous first paint, honest per-state affordances incl. health_only for background envs).
  • Client pairing UX: add-environment wizard (QR/manual code, descriptor preview, version gate), Connections settings pane, staged removal with env-scoped storage cleanup, and an executed 5/5 Playwright pairing journey.
  • Capability gating: host-impossible affordances gated on environment kind (project creation, terminals, file-open degrade honestly), and a manifest-driven three-state ui:agent gate (enabled / scope-gated / unavailable-remotely) consuming live per-session scopes — never the pairing-time snapshot.

Phase 3 (hardening + validation)

  • Two-instance validation: a production-stack test harness (real listener, pairing, sequencer, WS; no auth bypasses) with 11 validation legs, plus a 12-test remote_e2e suite wired into the main CI tier — including the full fake-CLI chat turn, revoke-mid-stream, epoch-roll, and prune→reset legs.
  • Load hardening: 5 deterministic load legs against measured thresholds; capture queue tuned 8×; 13 new host observability counters; per-environment badges from background observation with mutation-tested isolation proofs.
  • Facade coverage campaign: 132 registered ops (read surface, brakes with per-project confinement, and the audited ui:agent kanban/review/QA surface), 241 commands manifest-classified with honest resolution classes, every refusal pinned to a mechanism. The audit engine itself was hardened four times en route (same-name delegation masking, workspace-crate blind spot, fixture pollution, false attribution triples).
  • Spawn-free remote chat-send and host-side pairing QR (oracle-verified zero-dependency encoder).
  • Docs: docs/features/remote-access.md + docs/architecture/remote-protocol.md with unhedged security framing; spec-amendment proposal (9 amendments) at docs/handoffs/remote-mobile/.

Quality passes

  • Seven adversarial review/hardening rounds (multi-lens + verification + Fable assessors) fixed ~110 confirmed findings — WS zombie-session epoch races, silent durable-frame loss, revoked-device retry spins, probe-only false "connected", stale-board-on-switch-back, and systemic verification-theater in the capability CI gates.
  • Rust patch coverage on the new remote surfaces raised to 92.3% aggregate; auth tests are runtime-independent via an injectable invoke dispatcher.

Deliberately not in v1

Mobile app (consumes this protocol later), cloud relay, remote terminal/PTY (excluded as RCE by design), offline mutation queue, multi-user roles, remote project creation.

Risks / Follow-Ups

  • Permission boundary: default pairing is a "viewer with brakes" (watch, deny, pause/block/stop, inert metadata edits); anything that operates the kanban or steers agents requires the per-device ui:agent grant — because in RalphX, operating the kanban IS running agents. Owner sign-off on this boundary is the explicit ship gate for PR 1.5.
  • Remaining: ~3–4 registration batches to P-11 zero-unclassified (133 names left, all with a mechanical path); a product-bug fix slice from audit discoveries (non-atomic default writers, one silent input drop, a fail-open availability probe); a final review wave; and the phase-gate checklists.
  • Chat send is live for ui:agent devices via a spawn-free persistence command (role server-pinned against forgery; works while a run is live — the durable-queue variant is a scoped future PR). Conversation starting remains host-only in v1 by design.
  • Two commands are deliberately held unclassified with named blockers (approve_fix_task/reject_fix_task — corrective-transition authority with no honest classification code).
  • Native WKWebView QA legs (transport-spike evidence E-1/E-4/E-5, Remote Access pane C-7, switcher journey execution) are batched pending an explicit owner request per repo rule 9.
  • Codecov caveat: two files (capture.rs, tailscale.rs) sit at structural coverage ceilings; if the 90% patch gate is per-file rather than aggregate they need a carve-out or an AppHandle seam refactor.

let server_shutdown = shutdown.clone();

tauri::async_runtime::spawn(async move {
if let Err(error) = axum::serve(listener, cors_probe_router(ordering))
@adriandemian

Copy link
Copy Markdown
Collaborator Author

Orchestration progress — Phase 1

PR Status Notes
0.1 / 0.2 ✅ merged capture bank + protocol crate
0.3 ⚠️ evidence-blocked E-2/E-3 socket evidence captured; E-1/E-4/E-5 need WKWebView capture + Serve-capable tailnet (none on this host). No dependency edge — Phase 1 proceeding.
1.1 listener skeleton ✅ complete (a783e3644..e4daaabc5) :3849 listener, bind policy (0.0.0.0 refused in code), restrictive CORS + pre-auth OPTIONS, fail-closed auth slot, auto-start, P-16 zero-diff proven; 39 remote_server + 667 migration tests green
1.8 raw-listen migration 🔄 fix round implemented on feat/rme-pr-1-8; review found listener-readiness ordering regression + guard bypasses — fixes in flight, merges next
1.2 auth core 🔄 in progress
1.6 tailscale 🔄 in progress
2.1 env registry (client) 🔄 in progress parallel lane — only deps 0.2

Deviations of note from 1.1: /health sits behind auth (allowlist stays exactly two routes); MIN_CLIENT_PROTOCOL is host policy in endpoints.rs, not the protocol crate; get_remote_listener_status added as a read-only 4th command for the 1.7 pane.

@adriandemian

Copy link
Copy Markdown
Collaborator Author

PR 1.8 merged (14e597e92) — raw-listen migration + event-bus drift guard.

  • 3 consumers migrated to useEventBus(): AgentTerminalDrawer, GitAuthRepairPanel, UpdateChecker.events; zero raw listen() outside the bus module.
  • Review fix round included: additive Unsubscribe.ready readiness signal on the bus seam (restores listener-registered-before-producer-start ordering in the terminal drawer + gh device-code flow), guard now catches once/export…from drift, self-test fixed for clean checkouts and wired into CI.
  • Gates on final tree: typecheck ✓, guard ✓, guard self-test ✓, 46 targeted vitest ✓.

In flight: 1.2 (auth core), 1.6 (tailscale), 2.1 (client env registry).

@adriandemian

Copy link
Copy Markdown
Collaborator Author

PR 1.6 merged (7090bec5d) — Tailscale integration.

  • infrastructure/tailscale.rs: status --json parsing (serde, fixture-tested), CGNAT filtering, MagicDNS reachability probe, serve acquire/release helpers — all resolved through the shared tool_paths production CLI resolver (C-4); resolver row added to the rules inventory.
  • Real TailscaleSelfAddressProvider now backs PR 1.1's provider seam at all 3 production call sites; tailnet-direct bind validation is live end-to-end.
  • Deferred (tracked): serve acquire/release not yet invoked from the listener lifecycle — Serve mode isn't tailscale-reachable until a small wiring follow-up lands with/before 1.7. Machine has no tailscale binary, so logged-out behavior is fixture-verified only.

In flight: 1.2 (auth core — schema/entities/repos committed, middleware+handlers underway), 2.1 (client env registry — migration committed).

@adriandemian

Copy link
Copy Markdown
Collaborator Author

Three more merged: PR 1.2 auth core (e641dc66d), Serve lifecycle wiring (3fb8e39cb), PR 2.1 client environment registry (eb79db71e + marker fix 2a9d8a251).

  • 1.2: fail-closed bearer auth + pairing (single-use hashed codes) + ws-tickets + live-session registry with revocation kill-channels + Serve-aware rate limiting + audit log. 72 tests written. Agent-control derived from scope set (no drift possible between introspection and enforcement).
  • Serve wiring: acquire after successful bind (Serve mode only), best-effort idempotent release on stop/disable; degraded-not-failed when tailscale is absent; serveActive/serveDegradedReason surfaced for the 1.7 pane.
  • 2.1: staged add (row→Keychain→active) / staged remove (pending_delete→revoke→Keychain→row) reconciler, active-env mirror for confused-deputy binding, proxy command stubs (transport bodies land in 2.2/2.3), remoteEnvironments flag default-off.

Now in flight: 1.3 (capability ledger + two-detector authority audit — the big one), 1.4 (durable sequencer + WS + epoch), 1.7 (Remote Access pane). Phase 1 gate: 6 of 8 PRs merged.

@adriandemian

Copy link
Copy Markdown
Collaborator Author

Review-hardening merged (4841689fd) — 5-lens adversarial review of PRs 0.1/0.2/1.1/1.6/1.8 produced 49 findings (5 high); confirmed fixes applied:

  • capture bank: JSON parse removed from Tauri's emit hot path (payload stays raw string to the drain); Backend+LocalOnly rows structurally unregisterable (was a reachable unreachable! panic); phantom classification rows deleted.
  • emit scanner: was RED on the tip (unresolved imported event constant) — now resolves cross-module imports, fail-closed on unknown subscribe receivers, publishes a 48-name unclassified_backend_emits census forcing recorded decisions on new emits.
  • tailscale: real logged-out status --json (explicit-null IPs) parses to empty list; CLI-missing vs logged-out vs no-address now distinct; stderr surfaced on failures; tailnet-direct advertises http:// (the https URL could never handshake against the plaintext listener); port-0 rejected at settings read.
  • Guard bypass hardening + compile-gate coverage for the permitting direction of class_permits.

Now running: PR 1.3 / 1.4 / 1.7 lanes (updated mid-flight to this tip), a second 5-lens review of the 1.2/serve/2.1 follow-up work, and a codex coverage lane targeting ≥90% patch coverage on changed files.

@adriandemian

Copy link
Copy Markdown
Collaborator Author

PR 1.7 merged (537d8a214) — Remote Access settings pane.

  • Full pane: listener card (toggle, mode picker, serve-degraded banner, endpoint list + reachability dots), pairing card (grouped rxp_ code, copyable ralphx://pair URL, TTL countdown, outstanding-codes list), device list with the agent-control consent toggle + RCE-naming warning dialog, live-session list with immediate disconnect, recent-activity audit view. Flag-dark: null render + zero invokes when off.
  • R-12 decided: QR/URL carries only the preferred endpoint (client-side candidate merge makes multi-endpoint payloads pointless); manual code renders as eight 4-char groups, clipboard carries canonical raw.
  • 118 targeted vitest green; Playwright pairing journey authored against a stateful mock host (skip-marked for phase-review execution); C-7 WKWebView leg recorded outstanding (needs explicit Computer Use request or the visual lane).
  • Two backend gaps bridged with typed TODOs and now being closed in a follow-up slice: list_remote_advertised_endpoints, list_remote_audit_entries.

Remaining in flight: 1.3 (authority audit), 1.4 (sequencer/WS), review-2 of the 1.2/serve/2.1 work, coverage lane, pane-backend micro-slice.

@adriandemian

Copy link
Copy Markdown
Collaborator Author

PR 1.3 partial merged (3ba73a685) — remote_commands! facade macro with fixed compile-tested injection table, Read-class registrations, and the syn call-graph engine for the two-detector authority audit. Zero warnings, all committed content compile-verified.

Continuation (codex) now finishing: call-graph node-identity fix (type-qualified, boundary cuts — the name-keyed graph over-approximates to AgentControl-everything), detector (b) over the discovered background-loop inventory, the full 534-row capability ledger, content-surface enumeration, WorkerTaskView projection, the /remote/v1/invoke handler, and the generated remote-commands.json manifest with staleness CI.

Handoff quality note: the Opus lane left a 418-line tracker with five explicit landmines — including that the pause/block/stop brakes must never classify as AgentControl (the product's remote-brakes promise) and that unknown transition targets fail closed to arming.

Also in flight: 1.4 continuation (S1-S5 found complete with P-2/P-5/P-15/P-19/P-22 tests; gap is the P-21 gate-hydration commands), review-2 of the auth/registry work, coverage lane (Codecov 90% patch gate discovered), pane-backend commands.

@adriandemian

Copy link
Copy Markdown
Collaborator Author

Review-2 hardening merged (41d6ebf7f) — second 5-lens adversarial pass over the auth core / serve wiring / client registry (40 findings, 4 high). Confirmed-and-fixed highlights:

  • Lockout contamination (HIGH): bearer failures were keyed on the shared pre-auth bucket — a flooder could rate-lock everyone. Now keyed per presented token hash, with router-level regressions proving a locked-out bearer can't deny a paired device.
  • Client revoke was a structural no-op (HIGH): POST /remote/v1/auth/revoke was never mounted and the client mapped 404→success. Now a bearer-authed self-scoped revoke with durable revoked_at → audit → session teardown.
  • Re-pair window: proxy authorization now requires a settled Active row; demoting the active env resets the mirror to local.
  • Orphan-device window closed: pairing audit row commits inside the redeem transaction.
  • C-11 parity: clientVersion now actually sent; cross-side wire parity test round-trips host↔client pair structs.
  • Typed RemoteServeDegradedKind, audit-log growth bounded (30-day sweep), stale-Serve-mapping cleanup on failed acquire (narrowly — refuted the blanket sweep that would delete user-owned :443 Serve config).

Skipped-with-reason: needs_repair schema change (spec pins the status vocabulary), remote_fetch widening (PR 2.2 owns it), TailnetProviderError split (1.7 contract owns it). All lanes resumed after the session-limit interruption.

@adriandemian

Copy link
Copy Markdown
Collaborator Author

PR 2.2 merged (6c1e19af7) — client transport layer.

  • Project-owned invoke wrapper aliased over @tauri-apps/api/core (vite + vitest configs); NetworkInvoke routes remote environments through the Rust proxy — real HTTP invoke/fetch bodies landed, bearer never reaches JS.
  • backendFetch() migration: 16 production fetch sites across 9 modules (spec's census of ~25 was stale — team.ts gone, agent-workflows.ts added); local-only-commands.ts registry; full 8-code error taxonomy with exactly two unknown-outcome codes (TIMEOUT_UNKNOWN, REQUEST_IN_PROGRESS → reconcile-by-refetch, never blind re-send).
  • P-11 drift scan: 495 invoke names inventoried, 0 seam bypasses, 467-name unclassified baseline ratchet (→ 0 in PR 3.1), 16-case self-test.
  • 68 new tests + ~2,600 existing tests run to validate the global alias; zero Rust warnings.

Product decision surfaced for the owner: automations.ts sends x-ralphx-caller-session-id, which §3.5 never mounts remotely — automations now fail closed (REMOTE_COMMAND_UNAVAILABLE) under a remote environment. Correct per spec, but it's a live feature going dark remotely.

Remaining before the Phase-1 tag: 1.3 + 1.4 continuations, coverage lane, then PR 1.5 (Opus) and the review-3 workflow.

@adriandemian adriandemian changed the title feat: add remote environment foundations feat: Remote Multi-Environment support (host + client, 17/22 spine PRs) Jul 28, 2026
@adriandemian

Copy link
Copy Markdown
Collaborator Author

Status update — 2026-07-28

17 of 22 spine PRs merged on this branch (integration HEAD b348beb27), all dark behind remote_host / remoteEnvironments.

Phase Status
0 — Foundations ✅ complete (transport-spike verdict doc still evidence-gated, no dependency edge)
1 — Host mode ✅ 7/8 — remaining: 1.5 facade v2 (gated on the in-flight ledger-soundness hardening)
2 — Client mode ✅ 5/7 — 2.5 pairing UX in flight; remaining: 2.6 capability gating, 2.7 reconnect/offline UX
3 — Hardening + E2E ⏳ not started (3.1–3.4)

Merged today: PR 1.3 invoke facade + authority audit (539-command ledger, six-table generated manifest, all coverage flags complete); PR 1.3 completion round (detector-b spawn-triggering surface, agent-consumed content surface, P-17g proofs, compile-fail macro fixtures, spec-literal Denied restore); PR 2.3 NetworkEventBus + 7-state supervisor + Rust WS relay; PR 2.4 state isolation + environment switcher; coverage round 2 (92.3% aggregate on new remote surfaces); an auth-test runtime fix (injectable invoke dispatcher, 37/37); and review rounds 3–4 (28 + 12 confirmed findings fixed, incl. revoked-device retry spins, probe-only false "connected", stale-board-on-switch-back, WS epoch races).

In flight right now: ledger-soundness hardening (11 findings — closes the "verification theater" gaps in the audit apparatus; explicit gate for 1.5) and PR 2.5 (add-environment flow + connections pane).

Open owner decisions (tracked in the working tracker, none blocking dark work): 1.5 ship sign-off on the viewer-with-brakes default; Phase 3 OQs (badge ack semantics, harness ownership, tuning surface); native WKWebView QA batch (rule 9 requires an explicit request).

@adriandemian

Copy link
Copy Markdown
Collaborator Author

Status update — 2026-07-28 evening

19 of 22 spine PRs merged (integration 7abf2ad6e); Phase 2 client mode is complete except 2.7.

Phase Status
0 — Foundations ✅ complete
1 — Host mode ✅ 8/8 landed — 1.5's mutating surface (slice A) merged; idempotency (C) + fetch remount (B) in flight on the same lane
2 — Client mode ✅ 6/7 — remaining: 2.7 reconnect/offline UX
3 — Hardening + E2E ⏳ not started (3.1–3.4)

Since the last update: ledger-soundness hardening (11 findings; every new CI gate proven red-first by mutation, two caught live defects on arrival — a Read-ledgered command that spawns the Tailscale CLI, and 5 worker content-read tools the old allowlist silently dropped); invoke error taxonomy split 8→10 codes on both sides of the wire; PR 1.5-A (27-op mutating surface on the viewer-with-brakes split, generated 110-member negative suite, detector-c rejected three intended registrations and the gate was honored); PR 2.5 (pairing wizard, Connections pane, executed 5/5 Playwright journey); PR 2.6 (env-kind affordance gating + manifest-v2 three-state ui:agent gate, flags-authority split).

Known v1 surface gap (deliberate, closes in 3.1): with ui:agent granted, chat send / start composer / step-skip / automation run+restart render "available on host only" — unregistered on the facade until the 3.1 coverage completion (three pending a spawn-free path). Affordances flip from the manifest with no client change.

In flight: 1.5-C idempotency + 1.5-B remount; drift-ratchet tightening after the 1.5-A registrations.

Open owner decisions: viewer-with-brakes ship sign-off (1.5); Phase 3 OQs; native WKWebView QA batch (needs explicit request per repo rule 9).

Comment thread src-tauri/src/remote_server/authority_audit.rs Fixed
Comment thread src-tauri/src/remote_server/authority_audit.rs Fixed
@adriandemian

Copy link
Copy Markdown
Collaborator Author

Status update — 2026-07-29 early AM

Spec-alignment review complete; all feature work + alignment fixes merged (integration 632b4bd7c).

Alignment verdict (5 Opus lenses + Fable assessor, 34 findings vs 109 confirmed obligations): core invariants — auth, capability model, dedup, stream — match the spec; all high-severity findings were edge gaps, now fixed (20 code fixes landed): audit-write fatality on grant/revoke, 3 missing Denied overrides, a new mechanical content-write detector (d) that immediately caught 8 untagged writers, real proofs replacing vacuous tests (P-22c epoch, P-12 ordering, P-20 AgentControl-class dedup), the revocation verify-once client path, WS upgrade rate-limiting, and both P-10 version-gate halves. Auth suite grew to 39/39; listener to 22/23.

Remote chat-send shipped: send_remote_chat_message at ui:agent — spawn-free (detector-proven before registration), role server-pinned against forgery, honest affordance gating, works while a run is live (durable-queue variant deferred as its own PR).

3.1 progress: batches 1–2 done — pending-gate reads registered (remote P-21 unblocked), 12 commands audit-reclassified to Read, 5 dead orphan invokes deleted; baseline 449→431; facade at 40+ ops.

New docs on the branch: docs/handoffs/remote-mobile/chat-send-spawn-free-design.md and docs/handoffs/remote-mobile/spec-amendment-proposal.md — 7 spec amendments (most urgent: the exported cross-spec contract still shows the old 8-code taxonomy and /remote/v1/ws path; the mobile spec must consume the current truth), 4 deviation records, 3 delegated decisions resolved (MIN_CLIENT_PROTOCOL pinned independent; pairing QR to implement; P-4 parity extending to mutating ops).

Remaining: 3.1 batches to zero-unclassified (431), 3.2 two-instance validation, 3.3 hardening/load tests, 3.4 E2E + docs. Native QA legs still awaiting an explicit owner request (repo rule 9).

Batch 4 deferred `list_agent_conversations` / `_page` on the `tauri::AppHandle`
carrier and named them the cheapest remaining win. They are the other half of PR
3.2's read surface: a remote transcript is useless without a list to pick from.

The probe corrects batch 4's premise. `probe_conversation_list_arming_paths`
reports NO ARMING HITS for both local commands — unlike the transcript reads,
these never had a wake on their path. The disqualifier was carrier-shaped:
`list_agent_conversations` accepted `AppHandle` + `ExecutionState` only to build
a chat service whose invoked method is a straight repository delegation.
Authority carried, never used. (`_page` never took an `AppHandle` at all;
batch 4's deferral note was wrong about that, and is corrected in place.)

Both local commands now call the same `*_for_app_state` seams the remote ones
do, so this forks no logic (A-7) and removes two spawn-authority carriers from a
read command rather than routing around them. The collapse of the archived/
non-archived branches is behaviour-identical: `get_by_context(t, c)` is the same
predicate and ordering as `get_by_context_filtered(t, c, false)` in both the
SQLite and in-memory repositories.

Because the local commands never armed, batch 4's arming-calibration would be a
false statement here. The calibration that matches the actual disqualifier is
`the_spawn_free_remote_read_module_carries_no_authority_carriers`, which asserts
the module's no-carrier contract over its own source (comments stripped, with a
non-vacuity check) instead of leaving it as prose the way batch 4 did.

Every read on the list path propagates its repository error — checked by hand
across `filter_agent_list_visible_conversations`,
`agent_conversation_responses_for_state` and
`latest_conversation_runtime_attribution` — because a `Vec`-returning read is
exactly the fail-open shape batch 4 refused four times. Payload is conversation
METADATA only; this batch does not repeat batch 4's message-text step-up.
Regen order matters and is manifest -> mirror -> census, per the hazard batch 4
recorded: check-agent-control-command-mirror.mjs derives from the checked-in
remote-commands.json and reports PASS against a stale input if run first.

Facade ops 57 -> 59. Census total 544 -> 546.

The drift baseline does NOT move (419 -> 419), and that is correct rather than a
miss: this batch registered two NEW remote-only commands and reclassified zero
existing ones, while the local twins stay unregistered by design. A batch that
completes a product surface without moving the ratchet is the honest reading.
…isms

Batch 4 closed by asserting B2's remainder "is the workspace/publish surface,
which fires (a)+(b)+(c) together". That was a hand-wave. This audits the six
commands that still LOOK like reads and refuses all six, with the disqualifying
mechanism pinned for each rather than the refusal left as folklore.

A refusal cluster is the entire result of this audit, and that is the honest
outcome: the alternative was a `Read` row over a surface that shells out to git.

  agent_workspace_response_for_state ARMS. The shared hydrator disqualifies the
  whole workspace read cluster in one fact, which is why get_agent_conversation_
  workspace, list_agent_conversation_workspaces_by_project and list_agent_
  sidebar_conversations cannot be registered.

  list_agent_sidebar_conversations already HAS a _for_app_state seam, so batch
  5's list split looks like it should apply. It does not — the seam still reaches
  the arming hydrator. Pinned explicitly because that is the wrong inference a
  reviewer is most likely to draw from this batch.

  get_agent_conversation_workspace_freshness and search_agent_composer_entries
  reach process-launch sinks (git/gh).

  get_agent_conversation_workspace also SCHEDULES external PR reconciliation from
  a nominally read-only command; the body otherwise reads as a plain lookup.

  search_agent_composer_entries is the fail-open shape refused four times in
  batch 4, and the sibling of search_agent_composer_plan_references which batch 4
  refused for exactly this: a failed `git ls-files` silently degrades to a
  filesystem walk, so a remote client cannot tell "these are the project's
  files" from "git failed". It also discloses host paths.
PR 3.2 owns the two-instance harness; phase-3 open question 2 was resolved in
favour of it being a SHARED fixture that PR 3.4 extends into the permanent
`test-rust-fast.sh main` suite, so it ships as one `pub` API rather than
test-file-local plumbing.

The fixture boots the production host — real `remote_router` with its auth,
CORS, rate-limit and trust-strip layers, a real `TcpListener` on a free
loopback port, real HTTP pairing, the real `RemoteSequencer` and the
production `RemoteEventCapture::install` — and drives it from a scripted
client built on the shipped `HyperRemoteHostClient` over a real WebSocket.
No mock router, no auth bypass, no zero-devices bootstrap exception.

It lives inside `remote_server` because every seam it needs is `pub(crate)`;
a gated child module re-exposes exactly one public surface, which is what
lets 3.4 build an integration binary without promoting ~15 internal items
one at a time. The gate is `feature = "test-utils"`, never `#[cfg(test)]` on
a production item, mirroring the existing `src/testing/**` pattern.

The host runs on `MockRuntime`, so the harness never constructs a Wry
`AppHandle` off the main thread — the documented cause of the `auth_tests`
EventLoop breakage this repo already fixed once.

Validation legs (11, all green):

- transient chunks stream live over the wire and carry no seq
- durable chat events stay contiguous during a stream
- A-4: streamed `agent:chunk`/`agent:usage_updated` never reach
  `remote_event_log`, and consume no durable sequence numbers
- terminal exclusion, negatively: no durable row, the classification row is
  the only `excluded_from_v1` entry, and no terminal command is registered
- revoke mid-stream tears down the LIVE socket and 401s the next request
- an epoch roll resets a connected client
- chat send is refused without `ui:agent` and clears the gate with it

`RemoteListenerRuntime::for_tests` is widened from `cfg(test)` to also cover
`test-utils` so the fixture (and 3.4's binary) can boot the listener without
an `AppHandle`.

Determinism: no leg sleeps or polls a timer; every wait is event-driven, and
commit ordering is established by waiting for a later durable frame rather
than a duration. Suite ran 11/11 twice back-to-back.
…ervation

Adds a per-environment notification badge derived from frames already on a
background environment's socket, plus the two review-4 items that share the
same seam.

Badges are OBSERVED, never projected (A-12, §6.4). The background relay counts
durable `notification:created` frames and does nothing else: the frame is never
handed to the environment's bus, so no warm cursor advances, no cursorAck is
sent, and no retained QueryClient is touched. Reactivation stays a full
`H = hello.maxSeq` cold hydrate and reconciles the tally away. Because the
count comes from existing socket traffic rather than a query, it adds no
invoke and cannot erode P-26's health-only background gate.

P-28 background half: a background environment's scope refresh now NARROWS to
the last CONFIRMED set, or the empty set when nothing was ever confirmed. It
previously fell back to the pairing-time snapshot, which was fail-open — a
device whose ui:agent grant was revoked host-side would have had it silently
restored by a background reconnect. It cannot re-read /remote/v1/session
either, since that is not a health op, and widening the proxy is exactly the
erosion this slice must not cause.

Blocked-environment foreground rate limit (review-4 item 3): the
blocked -> resume edge carries resetAttempts and has no ladder, so every app
switch spent a descriptor fetch, a ws-ticket, and a session slot on a host
whose answer cannot have changed. Foreground wakeups are now floored at one
per minute; retry_now, credentials_changed, and online bypass it, and the
floor clears only on a successful connection.

The A-12 absence assertion is mutation-sensitive: forwarding an observed frame
to the bus fails it. Asserting `lastSeq` alone would have been vacuous, since a
background bus has cursorValid == false and refuses to advance regardless.
… graph

PR 3.2's `src/remote_server/harness.rs` is declared `#[cfg(feature = "test-utils")]`, so no
production build compiles it — but `load_production_sources()` walks the filesystem and excluded
only `*_tests.rs`/`tests.rs`/`mocks.rs` and `tests`/`testing` directories. The fixture was
therefore scanned as production source, and its `start/0` fused with `ResearchProcess::start/0`
under arity-keyed dispatch. The harness boots the whole app stack, so `start_research` inherited
the `create_or_update` write marker and the workspace-mode armed markers and was falsely
attributed to the `workspace-bridge` state surface — reddening
`detector_b_surface_rows_cannot_evaporate` and `remote_command_manifest_is_current`.

Regenerating the manifest would have baked the false attribution in. Instead the walk now skips
module files and directories whose declaration in the owning module file carries a test-only
`cfg`, matched on whole words so `cfg(all(test, feature = "test-utils"))` is caught while
`cfg(unix)` and `cfg(feature = "latest")` are not. Keying on the gate rather than the filename
stops the next fixture under a different name from regressing this silently.

The failure is not one-directional: the same collision could have masked a real writer, so the
fail-safe direction is pinned in the source comment. Excluding the fixture restores the
pre-harness graph, so the checked-in manifest, census and drift baseline all pass unchanged.
Adds the counter surface PR 1.4 never landed: prune runs/rows, resets split by
reason, epoch rolls split by cause, transient drops, kicked sessions, and the
per-session send-queue high-water mark. Surfaced through
get_remote_listener_status as an optional snapshot — None when no stream is
installed, which is deliberately distinct from an installed stream reporting
zeros.

These exist so the retention and heartbeat constants can be tuned from
measurement rather than argument (R-11), and because they are the load suite's
only honest proxy for 'host memory stays flat': a Rust load test has no
reliable per-test RSS assertion, so the phase doc operationalizes that claim as
a bounded high-water plus the drop/kick tallies.

Resets are counted inside close_with_teardown rather than at its five call
sites, so a future teardown path cannot be added without being observable.
Reason attribution is load-bearing for tuning: cursor_pruned means retention is
too tight for observed client dwell, epoch_changed means the sequencer is
rolling — different constants to move.

The send-queue depth is sampled BEFORE the session loop's drain, which is the
only moment the backlog is at its peak; sampling after would always read zero.
P-11's ratchet admits two answers today — remote-registered, or client-local
with a reason. A large block of host commands is neither and never will be:
the facade denies them (Denied class, SpawnsProcess) or defers them
(Elevated, a v1 non-goal). Batch B0 adds the third answer.

`v1_resolution(class, capabilities)` derives it from the ledger row itself,
so the authority stays in the ledger and every consumer — manifest, drift
scan, census — reads one rendered value instead of re-deriving class_permits.
…belled snapshots

The diff domain was 0 of 29 registered — a paired client saw an empty Changes
view with no explanation. Two snapshot-only reads now populate it:
get_remote_agent_conversation_workspace_change_summary and
..._workspace_review (that payload is both the change list and the commit
list).

The cache substrate already existed and is good: versioned invalidation
fingerprinting branch, PR, base and push state, with single-flight
coalescing. This slice adds a SEPARATE remote snapshot store written through
by the local paths that already inspect git, plus read accessors that never
populate. Per the spawn-distinct-paths rule the twin's closure does not name
the populating function at all — the read and populate paths are distinct
functions. Local TTLs are untouched (2s review, 30s PR annotations); the
remote snapshot gets its own 24h key, so a paired read can never shorten or
extend host-local caching.

Absence and age are legible rather than implied. The envelope carries
{snapshot, captured_at, cache_version, context_source}: a null snapshot means
the host has not captured this workspace yet — distinct from an empty change
set, and it feeds C1's `unknown` state rather than `knownEmpty`. context_source
records WHICH of the five workspace-context fallbacks produced the snapshot
(worktree, local branch, PR head, GitHub patch, error), without which a client
cannot explain why worktree modes are unavailable.

The row-only win in this slice was ABANDONED on measurement, correctly.
get_agent_conversation_workspace_review_hunk_annotations was scoped as a pure
DB read needing only a ledger row; the live detector proved it resolves a CLI
binary, so the Read/NONE override and its registry row were removed rather
than forced. Scoping said pure; the detector said otherwise; the detector wins.

Test corrections (all were asserting shapes the code cannot produce):
- Two MCP twin tests from C3 asserted a `{request: {command, args}}` envelope;
  the real remote_invoke wire shape is a struct param `input` carrying
  {id, requestId, cmd, args}. Those tests were never run before C3 shipped —
  my suite selection named mcp-policy.test.ts and missed mcp-policy-remote.
- The same invented envelope in this slice's own diff-remote test.
- Two more invoke-arity assertions pinning a 2-tuple where the seam passes an
  optional third schema argument.
- projects-remote-shell pinned the retired repository_capability_kind; it now
  exercises the real snapshot AND asserts the retired field is not trusted.
  Its fixture also sent `message: null`, which the host never emits (serde
  skips None) — corrected to a realistic payload.
- agent-gate's "every affordance resolves" test hardcoded its exceptions, so
  every new read-class affordance failed it for being correct. It now derives
  the expectation from the manifest's own opClass, allowing for
  argument-sensitive ops that are operate-class overall while their fields
  still require ui:agent.

Validation (RUST_MIN_STACK=8388608): remote_diff 2/0; diff_commands 43/0;
facade 417/0; layering PASS; zero warnings; drift 611 names / 0 unclassified;
mirror 276 ops; census and event-manifest regenerated on the final tree; 1132
frontend tests; tsc green. Implemented via codex (gpt-5.6-sol); tracker
src-tauri/.codex-pC4a-tracker.md.

Known pre-existing, not chased: git_auth_tests::
repository_capability_uses_git_effective_urls_for_included_push_rewrites
(proven at f20a675).
C4a gave paired clients the change list and commit list; selecting a file
still showed nothing. Four snapshot-only twins finish it: workspace file diff,
commit-scoped file diff, cumulative file diff, and the exact-range paged
variant. All Read / no capabilities.

Scope call, settled rather than deferred again: diff CONTENT crosses the wire.
The standing owner rule is that a paired client sees and manages everything the
host has, and C4a already carried file paths, change stats and commit messages.
This adds the hunk content the host displays to the same person.

Same discipline as C4a: accessors never populate (the twin's closure does not
name the populating function), local reads keep their behavior and write
through while they already own the git inspection, and snapshots are keyed by
conversation + file path + ref/scope + page range so a paged snapshot cannot be
served as if it covered the whole file. Repair and conflict operations stay out
of scope.

Fixed a real defect this slice's own test caught: SimpleDiffView's
"host has not captured this diff" branch was DEAD CODE. An uncaptured snapshot
has zero hunks, and the `hunks.length === 0` early return fires first — so
absence rendered as the claim "No changes", the exact fabrication this wave
exists to remove. The guard is now hoisted above the empty state with a comment
saying why the order is load-bearing. PagedDiffView already ordered it
correctly; verified rather than assumed.

Validation (RUST_MIN_STACK=8388608): remote_diff 2/0; diff_commands 43/0;
facade 417/0; layering PASS; zero warnings; drift 615 names / 0 unclassified
(279 remote-registered); manifest, mirror, census and event-manifest current on
the final tree; 1244 frontend tests across 89 files, including the new files run
explicitly; tsc green. Implemented via codex (gpt-5.6-sol); tracker
src-tauri/.codex-pC4b-tracker.md.

Known pre-existing, not chased: git_auth_tests::
repository_capability_uses_git_effective_urls_for_included_push_rewrites
(proven at f20a675).
@adriandemian

Copy link
Copy Markdown
Collaborator Author

Wave C complete (f20a675a32b71f1339): the cached-shell-out reads. The diff domain went from 0 of 29 registered to a working Changes surface — change summary, commit list, and file-level diffs (workspace, commit-scoped, cumulative, paged), all served as labelled host snapshots that never populate from a paired read. Repository capability and the MCP catalog are cached the same way.

The theme of the wave was absence must not read as fact, and it turned up more instances than the assessment listed:

  • types/project.ts fabricated {kind: "inspectionFailed"} from a missing field — and that fired on the host too, telling a local user with an uninspected repo that inspection had failed.
  • The remote projection derived repository capability from github_pr_enabled, a user preference: PR-mode-off asserted "inspection failed", PR-mode-on asserted "github", neither with an inspection behind it. Deleted; a real snapshot table replaces it, and a snapshot measured against a different working directory reads as absent rather than as truth about the current one.
  • The change summary laundered a failed query into "known empty" (count === 0 || isError).
  • SimpleDiffView's "host hasn't captured this" branch was dead code behind a hunks.length === 0 early return, so absence rendered as "No changes". Caught by the slice's own test.
  • probe_stale had existed in the MCP catalog response, was already rendered by the panel, and was hardcoded false — a dead honesty field. The read twin is its first true setter.

One scoped win was abandoned on measurement: ..._review_hunk_annotations was scoped as a pure DB read needing only a ledger row; the detector proved it resolves a CLI binary, so the row came back out rather than being forced.

Decision taken rather than re-asked: diff content crosses the wire, per the standing rule that a paired client sees what the host has. Related: C2 carries repository capability as kind + has_remote without the remote URLs — a one-field widening if you want the Remote URL row populated remotely; say the word and it goes in Wave D.

Validation per slice: facade 415–417/0, drift 615 names / 0 unclassified, 1244 frontend tests, layering, tsc. ⚠️ Actions still creating no runs since b768692dd — ~25 commits verified locally only.

Next: Wave D — the reclassifications (ticketing reads/writes split, update channel + host version surfacing, automation brakes to operate, and the deletes question).

…nd two misclassifications

Three mechanical wins plus a correctness fix. Nothing here widens a refusal;
the ticketing piece narrows one and the spawner row tightens one.

Host version and platform on a paired client. The data was already on the wire
TWICE and discarded both times: environment_descriptor() publishes app_version
and platform, the pairing preview renders them once, and parseDescriptor drops
them on the floor. The descriptor view now keeps them and stashes them per
environment. Deliberately NOT persisted in the remote_environments row — a
stored version becomes a lie the moment the host updates, whereas the
descriptor is re-fetched on every connect and so is self-refreshing. Absent
values render as nothing, never as "unknown version".

Five ticketing reads reclassified to Read with NO capabilities. All twenty
ticketing commands sat at one unaudited module default
(elevated_default("ticketing_commands", CREDENTIALS)) with zero per-command
overrides, which is what actually blocked them — not the credential, which
never crosses the wire (settings hold a keychain reference resolved inside the
service). These five make no outbound provider call and expose no secret
reference: list_ticketing_status_catalog, get_conversation_ticket,
get_ticket_associations, refresh_tickets (a pure stub), list_ticketing_providers.
Since class_permits admits no capability under Read, the touchesCredentials tag
is DELETED rather than moved, so each carries an audit reason instead of the
module-default string. The other fifteen are untouched; list_ticketing_columns
and refresh_ticketing_status_catalog are explicitly NOT reads (both write the
status catalog).

CORRECTNESS FIX — start_ralphx_work_from_ticket is a process spawner that was
masked as credential-deferred. It takes AppHandle + ExecutionState and calls
AgentConversationStartService::start(), but the module default filed it under
CREDENTIALS, and the detector cannot see a spawner in that position: it fires
if such a row is dropped to Read/Operate or registered, but NOT at an
unregistered AgentControl. It now has an explicit process_refusal row naming
the chain. Latent regardless of remote work.

delete_task_proposal renamed to archive_task_proposal. Its body calls
archive_proposal_impl — it has always archived, never deleted, so the old name
only ever earned it the delete_ prefix floor. The rename is honest independent
of remote (the name misdescribed the body) and follows the B3
cancel_remote_queued_agent_message precedent of renaming to the true verb
rather than fighting the floor with an override.

Orchestrator corrections on top of the codex slice:
- Its two reported ticketing failures were sandbox worktree-creation
  restrictions, not assertions: 179/0 outside the sandbox.
- Its ticketing-remote test mocked an invented two-field snake_case payload;
  the real TicketAssociationsResponse is eight camelCase arrays plus fetchedAt,
  and refresh_tickets is camelCase too. Corrected to the real serialization.
- The registry declared MutatesAgentConsumedContent for archive_task_proposal
  while the ledger fell through to the module default. The registry was right —
  archiving a proposal changes what a worker subsequently reads, exactly like
  update_task_proposal — so the ledger gained an explicit row rather than the
  registry being downgraded to match.

Validation (RUST_MIN_STACK=8388608): ticketing 179/0; proposal 140/0; facade
417/0; detector-c and spec/ledger pins green; layering PASS; drift 615 names /
0 unclassified; mirror 286 ops; census and event-manifest current on the final
tree; 1125 frontend tests; tsc green. Implemented via codex (gpt-5.6-sol);
tracker src-tauri/.codex-pD1-tracker.md.

Known pre-existing, not chased: git_auth_tests::
repository_capability_uses_git_effective_urls_for_included_push_rewrites
(proven at f20a675).
@adriandemian

Copy link
Copy Markdown
Collaborator Author

Four decisions now gate further parity

Wave D1 landed everything that needed no call from you (b8d22a89b): the host's version/platform now surface on a paired client (the data was on the wire twice and discarded both times), five audited ticketing reads are registered, delete_task_proposal is renamed to archive_task_proposal because it always archived, and — the correctness fix — start_ralphx_work_from_ticket was a process spawner masked as credential-deferred, invisible to the detector in that position. It now carries an explicit refusal row.

What's left needs your boundary, not more engineering. Each has a recommendation:

1. Ticketing credential spend (assessment D3) — recommend YES. What blocked ticketing was never the credential: it never crosses the wire (settings hold a keychain reference, resolved inside the service). It was one unaudited module default covering all twenty commands. The real question is whether a paired phone may drive the host's stored token — reads inflate its rate limit, writes post comments and transitions attributed to the host's user. Blast radius today is ~0: ticketingDashboard is a feature flag defaulting false. Six reads make the ticket board visible; eight writes make it usable.

2. set_update_channel → agentControl (assessment D4) — recommend YES, with eyes open. The body is two lines and the ledger already says the refusal is authority, not hygiene. The risk is real though: a paired device moves the host's release train to nightly, the host auto-updates and restarts, and every running agent dies. (The related Tier-0 bug where the host's channel drove the client's updater is already fixed separately.)

3. Automation brakes → operate (assessment D5) — recommend DEFER. Mechanically sound: pause is one CAS, and stop's Stopped → Active rollback restores the exact pre-call value rather than re-arming. But it's the most expensive slice in the wave (needs a live-automation test fixture) and the gain is confined to agent-revoked pairings, since ui:agent is granted by default at pairing. It also forces a genuine question: once they're operate they qualify for the deliberately-closed INERT_AFFORDANCES list — adding them is a boundary change, and not adding them leaves the client stricter than the host.

4. Repository remote URLs (from C2) — your call, low stakes. Capability currently crosses as kind + has_remote, so the Remote URL row is blank on a paired client. One-field widening if you want it populated.

And one recommendation to change nothing: leave the delete_ floor standing. I had repeated the assessment's "~100 rows" — it's 9. Of those: one was mis-caught and is now renamed, two have no frontend callers at all, one was already solved by the B3 queue twin, two are independently floored for other reasons (delete_chat_attachment writes arbitrary paths; delete_automation does a real git push to delete a remote branch), and the remaining three are genuinely destructive. Nothing dismissive — no notification or read-marker — is caught by the prefix. The floor is doing its job.

⚠️ Still no CI: Actions has created no runs since b768692dd, so ~28 commits are verified locally only.

… should have caught them

Wave B1 registered the four resume/restart intent twins and wired the client
transport, but never repointed the gate rows: taskResume, taskRestart and
executionResume still named the local commands. So on every paired client with
a WORKING transport, Start/Resume was disabled with "this action runs only on
the host", Resume/Restart/Resume-All were disabled in the task and group menus,
and the `if (!gate.gated)` guard permanently skipped resumeExecutionIfStopped.
Tier 0 #1 — the one-way brake where a client could stop the host's scheduler
and never restart it — was fixed at the transport layer and then re-closed at
the UI layer by my own slice. Two tests baked in the stale premise, which is
why nothing caught it.

Same class, second instance: the plan bar GATED on pause_execution /
stop_execution (registered) while its handlers INVOKED
pause_execution_plan / stop_execution_plan (not registered), so the buttons
rendered live, confirmed, and threw REMOTE_COMMAND_UNAVAILABLE. They now gate
on the ops their handlers actually call.

The structural fix is GATE_WIRED_FILES, 36 -> 54 files. Both defects above
were invisible because AgentsArtifactPane and ExecutionControlBar were not
under the op-consistency guard. Extending it immediately earned itself, finding
two more real defects:
- TaskBoard Column gated "Resume All" on taskResume while invoking
  resumeTasksInGroup — now gates groupResume.
- ProposalWidget still rendered the delete verb after Wave D renamed
  delete_task_proposal to archive_task_proposal.
It also flagged eight dead rows, six of which were Wave C4b file-diff
affordances no surface ever consumed: those views derive unavailability from
the snapshot envelope and the transport error instead, so the rows were a
second unused mechanism. Removed, along with the test that pinned them.

The permission dialog is no longer a dead end. It called the unregistered
get_pending_permissions at three sites: mount hydration (the rejection emptied
pendingEventsRef, DISCARDING permission:request events buffered during the
mount window), the open-dialog handler (the dead end the Tier 0 #3 fix was
supposed to close), and unknown-outcome reconciliation (which therefore never
reconciled remotely). All three now use the registered
listPendingPermissionGates, and a failed read stays strict rather than
presenting as "none pending".

TWO TEST REGRESSIONS I SHIPPED AND NEVER NOTICED, fixed here:
- 59 IntegratedChatPanel failures from Wave B3c (6ec7cc2): queue hydration
  began calling chatApi.getQueuedAgentMessages, but the test file's mock never
  declared it, so every panel mount threw. Production was correct throughout.
- 2 useProposals failures from Wave D1 (b8d22a8): the archive rename updated
  the assertions but left the mock key named `delete`.
Both slipped through because per-slice verification used suite SELECTIONS that
omitted the files the slice actually touched — the third instance of that
mistake this session. From now on the full frontend suite runs before every
commit, not a selection.

Validation: FULL frontend suite — 787 files, 12800 passed, 3 skipped, 0 failed;
tsc green; drift 615 names / 0 unclassified; census current. Implemented via
codex (gpt-5.6-sol); trackers src-tauri/.codex-pE1-tracker.md and
.codex-pE1b-tracker.md.
…e exist"

Closes the defect class Wave C1 opened: a transport read fails, the catch turns
it into an empty collection or null, and the UI renders that as a positive fact.

The one that mattered most is not a rendering bug. `useIdeation`'s
reset-and-reaccept read the plan branch through `.catch(() => null)`, so a
failed read silently dropped `baseBranchOverride` and the flow RE-APPLIED
PROPOSALS ONTO THE WRONG BASE BRANCH. It now fails closed: the read throws
inside the mutation, which rejects before `sessions.reopen` — the destructive
step that deletes tasks and cleans git — so an unresolved base branch cannot
produce work on the wrong one.

The most visible: `useMessageAttachments` swallowed a DENIED
`list_message_attachments` with a bare `catch {}`, so on every paired client
every message with attachments rendered as though it had none — no chip, no
card, no hint the host has them, and all the gallery placeholder work
unreachable because the metadata never arrived. Absence is now explicit.
(Wave E6 adds the read twin that makes them load; this makes the gap honest.)

Also fixed: both ChangeReviewSection copies (task state history), both
TaskDetailContext copies (a failed plan-branch read made the whole plan-merge
surface disappear), ReleaseNotesDialog, App's git-branch list, the project
creation wizard, and the sidebar publication poll that silently froze PR badges
at their last known value. Two swallowed ticketing MUTATIONS — quick-assign and
move-ticket — no longer no-op in silence. Duplicated files were fixed in pairs;
deduping them is out of scope and recorded in the tracker.

Orchestrator corrections on top of the codex slice:
- The wizard's failure copy existed only INSIDE the Radix dropdown, so a user
  had to open the select to discover the read had failed. It is now inline
  beside the sibling default-branch message, with the disabled item kept for
  anyone who opens it.
- Two of the slice's new tests asserted through that dropdown, which jsdom does
  not render on click — they were testing the library rather than the
  behaviour. Rewritten against observable state, and one of my own assertions
  was wrong in the process: an empty branch LIST does not mean the default
  branch is unknown, so the trigger correctly shows the detected "main".

Validation: FULL frontend suite — 787 files, 12818 passed, 3 skipped, 0 failed;
tsc green; drift 615 names / 0 unclassified; census current. Implemented via
codex (gpt-5.6-sol); tracker src-tauri/.codex-pE5-tracker.md.

No further instances of this pattern were found in the bounded scan; the
remaining catches are recovery guards, preload/teardown best-effort paths, and
JSON/localStorage parses of already-delivered payloads.
Wave E5 made the gap honest; this closes it. list_remote_message_attachments
registers at Read with no capabilities, because the old
denied/writesArbitraryPath classification came from the chat_attachment_commands
MODULE DEFAULT rather than anything the command earns: it is a plain
attachment-repository read. That was verified by measurement against the live
detector, not assumed — the same claim failed under measurement in Wave C4a, so
the module now carries a carrier-absence assertion proving it takes no
ChatAttachmentService, storage path, AppHandle or ExecutionState.

RemoteChatAttachmentResponse deliberately OMITS the host file_path. A paired
client cannot open a host path, and putting one on the wire would be both
misleading and an invitation to send host filesystem authority back through
another surface — the same field-absence discipline the conversation-start twin
uses. Attachment BYTES remain host-only in this slice: cards render metadata
with the existing "Stored on the host" affordance rather than a broken preview
or a dead download. The byte route needs a binary transport envelope
(remote_fetch carries a String body today) and its own path-containment work.

E5's unknown state is retained for genuine read failures and for an older host
that lacks the twin, so absence stays the signal rather than becoming a claim.

Orchestrator additions: the census literal was not bumped for the new command
(caught by the detector-b pin; 604 -> 605 with its reasoning in the trail), and
a leftover Radix dropdown click in my own E5 wizard test was throwing an
unhandled jsdom hasPointerCapture error — removed, since E5 moved that failure
copy inline precisely so the assertion would not need to open the select.

Validation (RUST_MIN_STACK=8388608): attachment 113/0; remote_attachment 2/0;
facade 419/0; layering PASS; zero warnings; drift 616 names / 0 unclassified;
mirror 287 ops; census and event-manifest regenerated on the final tree; FULL
frontend suite 787 files / 12820 passed / 3 skipped / 0 failed with no
unhandled errors; tsc green. Implemented via codex (gpt-5.6-sol); tracker
src-tauri/.codex-pE6-tracker.md.
…lanket banner

AgentsArtifactPane returned a single "Workspace publishing and pull requests
runs on the host" notice for the ENTIRE publish tab under a remote transport.
That was correct when the workspace read was spawn-denied; it stopped being
true several waves ago. get_remote_agent_conversation_workspace is registered,
and so are the review and file-diff snapshot reads from C4a/C4b — so an
honest-looking banner was hiding a visibility gap over data the host was
already willing to serve.

The tab now renders read-only from those registered reads: PR state, checks,
commits, base/head and publication status, with the C4 snapshot labelling and
not-yet-captured copy reused rather than re-invented. Only the two controls
that genuinely cannot run remotely are gated —
publish_agent_conversation_workspace and close_agent_workspace_pr, both
process-reaching and unregistered — and they resolve unavailable from manifest
absence, rendering disabled with the host-only hint rather than vanishing or
throwing on click. AgentsPublishPanel joins GATE_WIRED_FILES, so the
op-consistency guard now checks that each row names the op its handler invokes.

Publish and close-PR intent twins are deliberately NOT attempted here: publish
is a multi-step interactive flow whose progress semantics need a design answer,
and close-PR is destructive and terminalizes the workspace runtime. Both are
owner-gated.

Note on verification: codex's own vitest run was blocked before collection by
the worktree's node_modules symlink (EPERM on Vite's config cache), so the
suite result below is the orchestrator's run, not its report.

Validation: FULL frontend suite — 787 files, 12823 passed, 3 skipped, 0 failed;
tsc green; drift 616 names / 0 unclassified; mirror 287 ops; census current.
Implemented via codex (gpt-5.6-sol); tracker src-tauri/.codex-pE7-tracker.md.
@adriandemian

Copy link
Copy Markdown
Collaborator Author

Wave E complete — everything unblocked is done

52187304d5af02c8dc. Four slices, and the first one is the uncomfortable part.

E1 — a regression I shipped. Wave B1 registered the four resume/restart intent twins and wired the transport, but never repointed the gate rows. So on every paired client with a working transport, Start/Resume was disabled with "this action runs only on the host", Resume/Restart/Resume-All were disabled in the task menus, and the guard permanently skipped resumeExecutionIfStopped. Tier 0 #1 — the one-way brake — was fixed at the transport layer and re-closed at the UI layer by my own slice, and two tests encoded the stale premise so nothing complained. Same shape found in the plan bar: it gated on pause_execution (registered) while invoking pause_execution_plan (not), so the button rendered live and threw.

Both were invisible because the two files weren't in GATE_WIRED_FILES. Extending it 36 → 54 immediately found two more real defects: the task board gated "Resume All" on the single-task op while invoking the group mutation, and a tool widget still said "delete" after Wave D's archive rename. The guard now covers the surfaces it was written for.

E5 — twelve places where a failed read claimed "none exist". The one that mattered isn't a rendering bug: useIdeation read the plan branch through .catch(() => null), silently dropping baseBranchOverride, so proposals were re-applied onto the wrong base branch. It now fails closed before the destructive reopen step. Also fixed: attachments invisible on every paired client, both ChangeReviewSection and TaskDetailContext copies, release notes, branch lists, and two ticketing mutations that no-oped in silence.

E6 — attachments load. list_remote_message_attachments at Read/no-caps; the old writesArbitraryPath denial was a module default, verified by measurement rather than assumed. The host's on-disk path deliberately never crosses the wire — a client can't open it and it would invite sending host filesystem authority back. Bytes stay host-only behind a "Stored on the host" affordance.

E7 — the publish tab. It returned one banner for the whole surface; the workspace and diff reads have been registered for several waves. PR state, checks, commits and diffs now render read-only, with only publish and close-PR gated.

Validation per slice: full frontend suite (787 files, 12,823 tests) — that's the new standard after selections shipped three silent regressions — plus facade 419/0, drift 616 names / 0 unclassified, layering, tsc.

What's left is all yours

The four decisions from the previous comment (ticketing credential spend, set_update_channel, automation brakes + the closed INERT_AFFORDANCES list, C2's remote URLs), plus three slices that each carry their own call: attachment bytes over remote_fetch (needs a binary envelope), the publish intent twin (needs progress semantics for a multi-step interactive flow), and close-PR (destructive + terminalizes the workspace runtime).

⚠️ Actions still hasn't created a run since b768692dd — ~33 commits verified locally only.

Self-contained brief for a fresh reviewer: base 7f90edc -> head 5af02c8,
26.5k lines across 332 files, none of it ever run in CI. Records what each wave
claimed, the two patterns everything was built on (intent twins, cached
shell-outs), and seven ranked attack surfaces — idempotency and at-most-once
claims, claim-time authority re-proof, host-alone parity, detector blind spots,
absence-rendered-as-fact, gate/op correspondence, and test quality.

Also lists what is known-and-accepted so the reviewer does not spend effort
re-reporting it: the proven pre-existing git_auth batch failure, the
RUST_MIN_STACK requirement, and the six parked owner decisions.
Everything remaining after Waves A-E, framed as decisions with a recommendation
and a cost rather than a status list: ticketing credential spend (yes),
set_update_channel (yes, with the host-restart consequence stated), automation
brakes (defer, and why), repository remote URLs (owner call), the three slices
that each carry their own call (attachment bytes, publish progress semantics,
close-PR), and one recommendation to change nothing (the delete_ floor is 9 rows,
not ~100).

Leads with the operational blocker: Actions has created no runs for ~34 commits,
so all verification to date is local, and the last real run's Rust shards died on
a nextest global timeout that could not be re-judged.
…ved credential spend)

The owner approved a paired client spending the host's ticket credential, so the
last twelve of the twenty ticketing commands are now classified individually
instead of inheriting one unaudited module default.

What actually blocked this was never the credential. It never crosses the wire:
settings hold a `token_secret_ref` (a keychain key) resolved inside the service
against a SecretStore, and no registered response type carries a secret
reference — asserted by absence, not assumed. The deferred risk was credential
SPEND, and that is what the owner decided.

Six credential-spending READS at Read with NO capabilities: containers, tickets,
filter options, ticket detail, transitions, labels. Because class_permits admits
no capability under Read, this DELETES the touchesCredentials tag rather than
moving it — so each row carries a per-command audit reason stating plainly that
the command makes an outbound provider call on the host's credential and that
the credential itself stays host-side.

Eight WRITES at AgentControl: five provider writes (transition, assign,
clear-assignee, comment, labels) plus three catalog writes. list_ticketing_columns
and refresh_ticketing_status_catalog are explicitly NOT reads despite their names
— both call sync_status_catalog_for_scope and write the local status catalog.
The scope enforcement is proven, not just labelled: a client holding only
ui:read + ui:operate is refused with RemoteForbidden at dispatch.

start_ralphx_work_from_ticket stays refused. Wave D1 found it was a process
spawner masked as credential-deferred by the module default; it keeps its
explicit process_refusal and this slice does not weaken it.

Frontend: fourteen gate rows wired through useAgentGate on the ticketing
surfaces, with TicketingDashboardView already under the op-consistency guard so
each row is checked against the op its handler invokes. Wave E5's un-swallowed
quick-assign and move failures still surface rather than no-op. The
ticketingDashboard feature flag still defaults false, so this surface remains off
until someone turns it on.

Note on verification: codex reported 2 ticketing and 35 facade failures, all of
which were its sandbox refusing worktree creation and loopback binds. Outside the
sandbox: ticketing 182/0, facade 422/0. It also found and fixed two of its own
classification errors mid-run.

Validation (RUST_MIN_STACK=8388608): ticketing 182/0; facade 422/0; layering
PASS; zero warnings; drift 616 names / 0 unclassified with registered ops
287 -> 301; mirror, census and event-manifest regenerated on the final tree; FULL
frontend suite 787 files / 12827 passed / 3 skipped; tsc green. Implemented via
codex (gpt-5.6-sol); tracker src-tauri/.codex-pF1-tracker.md.
…Ls on the wire

Two owner-approved changes, both small.

set_update_channel moves to AgentControl. The body is a single audited
repository write with the error propagated, and the ledger row itself had long
said the refusal was about authority rather than hygiene. The HostManagement
capability is DROPPED, not carried: class_permits admits no capability under
AgentControl and the assert is const. The one-member DeferredHost disposition
rung is retired with it rather than left as dead code, and the registry block
comment that declared this command deliberately unregistered is corrected.

The accepted consequence is recorded in the ledger reason itself, which is where
a future reader will look: a paired device can move the host's release train, and
the host will auto-update and restart, terminating running agents. Enforcement is
proven rather than labelled — a client holding only ui:read + ui:operate is
refused with RemoteForbidden at dispatch. The settings control is now gated by
manifest presence instead of a hardcoded isRemoteEnvironment check.

The capability snapshot now carries fetch_url and push_url. Wave C2 deliberately
withheld them because the module promised no remote URLs; the owner's standing
rule is that a paired client sees what the host has, so the promise changed and
the module doc says so. Scope is deliberately narrow: only these two fields
crossed. `working_directory` was already on the wire under an earlier owner
decision and stays display-only; no other host filesystem path was widened, and
the reasoning that keeps them off is untouched.

Absent stays absent: both URL fields use skip_serializing_if, so a repository
with no remote omits them rather than sending null, and the client renders the
push URL, falls back to fetch, then an em-dash — never the empty string Wave C1
removed.

Note on verification: codex's facade run hit its sandbox's loopback-bind
prohibition (32 failures) and its vitest could not write through the worktree's
node_modules symlink. Both were re-run outside the sandbox: facade 423/0, full
frontend suite 787 files / 12833 passed / 3 skipped.

Validation (RUST_MIN_STACK=8388608): update_channel 13/0; remote_workspace 11/0;
facade 423/0; layering PASS; zero warnings; drift 616 names / 0 unclassified with
301 remote-registered; mirror 302 ops; census and event-manifest regenerated on
the final tree; tsc green. Implemented via codex (gpt-5.6-sol); tracker
src-tauri/.codex-pF2-tracker.md.
17 commits, 275 files. Nine conflicts, all resolved so main's functionality is
kept whole and the remote-parity work is added on top of it rather than in place
of it. Four resolutions needed more than a union.

Migration version collision (the one that would have corrupted data). Both sides
minted version 20260804120000 — ours remote_plan_approval_requests, main's
agent_workspace_base_stale_target. The runner selects by set membership
(!applied_versions.contains), so the loser would not have been re-ordered, it
would have been silently skipped forever on every existing database. Main's is
already shipped, so OURS was renumbered forward to 20260806080000, past main's
max; the migration is IF NOT EXISTS so it re-runs cleanly. SCHEMA_VERSION now
names the renumbered migration, and the array is sorted with uniqueness
asserted. No database is caught mid-ambiguity: the only dev database on this
machine tops out at 20260802174000, so nothing ever recorded 20260804120000
under our name and main's migration of that version is not skipped anywhere.

Sidebar spawn authority (the one that broke a facade invariant). Main moved
workspace hydration into list_agent_sidebar_conversation_groups_from_hydrated,
which BOTH the local and remote entry points call, and hydrated plan-linked
workspaces through agent_workspace_response_for_state — the hydrator that arms
the recovery scheduler and resolves the git CLI. That put a process launch back
into the remote read's call-graph closure: detector (c) and
remote_agent_sidebar_read_carries_no_spawn_authority both failed with "the
recovery-free hydrator seam has been broken". Main's map turned out to be
populated and never read (our twins already supply the map the row loop
consumes), so the dead loop is removed and main's plan-linked rule is carried
into each twin instead, each naming its own hydrator. Main's behavior is
preserved exactly — plan-linked workspaces keep the richer projection, everything
else uses the cheap From — while the remote closure stays clear of the resolver.
This is the "spawn-distinct paths are distinct FUNCTIONS" rule in
.claude/rules/remote-facade.md, which exists because of the same failure.

Four new commands arrived from main unclassified — recheck_pr_health,
retry_pr_autofix_override, stop_pr_autofix_for_failure, and
retry_agent_workspace_publication_effect. All four are in the PR-autofix and
publication-repair family and reach gh, git, or an agent relaunch, so all four
are ledgered Denied with per-command reasons, matching the treatment their
siblings publish_agent_conversation_workspace and close_agent_workspace_pr
already carry. Manifest regenerated; drift moves 616 -> 620 names, still 0
unclassified, and detector (b)'s census calibration moves 605 -> 609 with the
four names recorded in the comment beside it — a census-count change, not a
detector change. Three of the four join the pending-review-freshness writer
attribution, which is what a Denied process-reaching command is expected to do.

A null review snapshot was being read as an empty change set. Main's typecheck
caught what our own Wave C contract allows: getAgentConversationWorkspaceReview
resolves null when the host has never captured a snapshot, and the publish
panel's isSuccess branch dereferenced it. Null now resolves to unavailable — "not
captured", never "no changes". Same defect class the C1/C5 honesty passes exist
to remove.

Two of our own tests were not running. AgentsArtifactPane.test.tsx had an
unterminated it() block that made the whole file fail to parse (0 tests
collected), and the sidebar worktree-sentinel test had been appended inside
another test's body — "cannot test inner items", silently never executed. Both
now run and pass: the pane file collects 238 tests, and
remote_agent_sidebar_projection_blanks_only_the_worktree_path is a real test
again rather than an inner item the harness skipped (41 in its module, all
green).

Remaining resolutions were unions or reflows verified line-by-line: both sides'
added lines were diffed against the merged tree, and every apparent loss was
confirmed to be relocation, intentional renumbering, or formatting. One genuine
drop was restored (the sentinel const's doc comment). One import was dropped
deliberately — main intentionally stopped routing the sidebar through
agent_conversation_response_for_state, which the merge-base had used, so that is
main's change kept, not ours lost.

Formatter churn was reverted rather than committed: rustfmt on a mod.rs recurses
through the module subtree, so six untouched files under application/ and
chat_service/ came back reordered and were restored to their merged state.

Validation (RUST_MIN_STACK=8388608): cargo check --tests --features test-utils
zero errors, zero warnings; facade suite 423 passed / 0 failed; migrations and
sidebar lane suites 751 passed / 0 failed; layering PASS; drift 620 names / 0 unclassified with
301 remote-registered; capability mirror 302 ops; event and vocabulary mirrors
current; census 0 unclassified; event manifest regenerated and re-checked; full
frontend suite 797 files / 12975 passed / 1 expected fail / 3 skipped; tsc green.
Two commits that landed on main while the previous merge was being validated:
the Trusted Caller Run Authority fix (#1003) and the v0.95.1 release prep. No
conflicts — the changes sit in http_server handlers and add a new
trusted_run_authority module, so they touch no Tauri command and no facade row.

Validated rather than assumed, since the facade allowlist is exhaustive over
generate_handler! and a new handler module is exactly where a command could
sneak in: drift holds at 620 names / 0 unclassified with no new registrations,
layering PASS, cargo check --tests --features test-utils clean with zero
warnings, and the facade suite plus the new trusted-run-authority and
managed-team authority tests pass 431/0 (RUST_MIN_STACK=8388608).
…ge.yml

The merge kept both sides' answer to the same OOM: main's CARGO_BUILD_JOBS: "1"
and this branch's "2", in one env mapping. Duplicate YAML keys make the
workflow file invalid, so Coverage Reports failed in 0s with "this run likely
failed because of a workflow file issue" — the first run to reach this branch
now that Actions is creating runs again.

Main's cap is the stricter of the two and is kept; this branch's evidence for
why the cap exists (exit 143, runner reaped on 3 of 4 attempts at 4 parallel
rustc processes) is folded into its comment rather than dropped. The delta
against main is now comment-only.
… just two

REMOTE_TIMEOUT_UNKNOWN and REMOTE_REQUEST_IN_PROGRESS mean the request reached
the host and the answer did not come back: the mutation MAY have applied. The
correct response is refetch and let the host be the truth. Re-sending is the one
thing that must not happen — with a fresh requestId it is a second mutation, and
with the same id it races the reservation the first attempt is still holding.

`reconcileUnknownOutcome` has encoded this since PR 2.7-b, but it had exactly TWO
consumers. The other 176 useMutation sites treated an unknown outcome as an
ordinary failure and invited a retry the host would double-apply or race.

The fix is one MutationCache({ onError }) on createQueryClient, so all 178 sites
get correct behavior without any of them being edited. Detection reuses the
existing isRemoteTransportError + isUnknownOutcome predicate rather than copying
the code list, so "unknown outcome" keeps exactly one definition. With no
declared keys it invalidates the whole client — we do not know what changed, and
these are rare 30s timeouts, so the broad default is the safe one. A mutation may
narrow it with meta.unknownOutcomeQueryKeys, typed through react-query's Register
augmentation rather than a cast.

Environment scoping is structural, not conventional: each MutationCache closes
over the QueryClient it was built with and never reads the ambient environment
id, so it cannot reach another environment's cache. Proven by a test, not by the
comment.

The handler stays silent on user-facing copy — the two existing consumers surface
UNKNOWN_OUTCOME_MESSAGE themselves and a global toast would double-message them.
Accepted consequence: those two now also get the global pass, so their targeted
invalidation is followed by a client-wide one. Idempotent, slightly broader, and
still refetch-not-resend; they can narrow with meta later if it matters.

Test note: codex's tests built mutations straight off the cache, which would let
a regression in how useMutation forwards `meta` pass unnoticed. Two tests were
added through the production entry point — useMutation under a provider — and the
meta one asserts the untouched key is NOT invalidated, so it fails if meta stops
propagating rather than silently widening.

Codex reported prettier and vitest BLOCKED by its sandbox (bun tempdir
PermissionDenied; Vite cache EPERM through this worktree's symlinked
node_modules). Both were re-run outside it.

Validation: FULL frontend suite 797 files / 12983 passed / 1 expected fail /
3 skipped / 0 failed (12975 -> 12983, +8); tsc green with pipefail; prettier
clean; drift 620 names / 0 unclassified; mirrors and census current. Implemented
via codex (gpt-5.6-sol); tracker frontend/.codex-s7-1-tracker.md.
…ffordance row

A registered write op with no affordance row gets no gate at all: during reconnect
or offline its control stays live, the user clicks it, and the transport throws.
This batch closes that for the task lifecycle — archive, restore, block, pause,
and the group cancel/pause-all actions — each with a row in
AGENT_GATED_AFFORDANCES, a useAgentGate call at the control, a handler that
early-returns while gated, and the file registered in GATE_WIRED_FILES so the
op-consistency guard checks the row names the op the handler actually invokes.

Five of the eleven candidate ops were NOT wired, and deliberately: inject_task and
stepsApi start/complete/fail/reorder have no consumer anywhere outside the API
module. There is no control to leave live, so a gate would be decoration. They are
recorded in the tracker rather than silently skipped.

Pause and block move under ui:agent. The comment there previously called their
exclusion "deliberate, not an oversight" on the grounds that they reduce
authority — but the host classifies pause_task and block_task as agentControl, so
a ui:operate-only client was being shown controls the host would refuse with
RemoteForbidden. Aligning the client to the host's own classification is the
whole point of the gate; the stale justification is replaced, and no host
classification changed.

Soft-disable, not `disabled`. The explained-menu-item helper already encodes why:
a truly disabled control has pointer-events:none and leaves the focus order, so
its reason is unreachable by mouse and keyboard alike — the user sees a dead
control and no explanation. The first cut hard-disabled the ProcessCard pause
button and the overlay archive/restore buttons, which made their tooltips
impossible to open. They now carry aria-disabled + data-disabled-explained and
keep `disabled` only for genuine in-flight states, so the reason stays reachable.
That is a real user-facing fix, not a test accommodation.

Four test files needed a TooltipProvider wrapper (the app supplies one from
App.tsx; bare component renders do not), and the gate assertions now focus the
trigger and await the Radix content instead of asserting it synchronously —
matching agent-gate-surfaces.test.tsx. Radix renders tooltip copy twice, once for
the live region, so those assertions match all occurrences rather than pinning
one.

Scope note: the open-decisions report counted 78 ungated write ops. The true
figure is 76 — create_task, deny_permission_request and update_task are
deliberately INERT (class operate, reachable with ui:operate), and gating them
would make the client stricter than the host. Remaining after this batch: 70,
grouped by owning module for later batches.

Note on verification: codex could not run prettier or vitest (bun tempdir
PermissionDenied; Vite cache EPERM through this worktree's symlinked
node_modules) and reported BLOCKED rather than guessing. Re-run outside its
sandbox, its work failed 47 tests across 6 files; all are fixed above.

Validation: FULL frontend suite 797 files / 12996 passed / 1 expected fail /
3 skipped / 0 failed (12983 -> 12996); tsc green with pipefail; prettier clean;
drift 620 names / 0 unclassified; mirrors and census current. Implemented via
codex (gpt-5.6-sol); tracker frontend/.codex-s7-2a-tracker.md.
Eight persona ops get affordance rows, gate calls, dispatch guards and
GATE_WIRED_FILES entries: create/update draft, update, approve, approve-as-new,
reseed, archive, unarchive. Soft-disabled throughout — aria-disabled plus a
reachable reason, never `disabled`, because a disabled control has
pointer-events:none and leaves the focus order, so the user gets a dead control
and no explanation.

Five ideation proposal ops were skipped and the skips were checked rather than
taken on trust: archive, reorder, assessPriority and assessAllPriorities are
reached only by useProposals/usePriorityAssessment, which no component consumes,
and remove_proposal_dependency lives behind useDependencyMutations, which nothing
imports — the three components using that module import useDependencyTiers and
getDependencyReason instead. No control exists to leave live, so a gate would be
decoration.

Two are genuinely pending, not skipped. update_ideation_settings is blocked on
the shared ToggleSettingRow, which exposes only a true `disabled` and therefore
cannot express the soft-disable contract without an out-of-scope change to
SettingsView.shared.tsx. update_ideation_session_title has controls in AppTopBar,
AgentsChatHeader and AgentsSidebar and was not reached. Both are in the tracker.

CI came back for the first time in ~37 commits and found three things this
commit fixes:

- scripts/event-manifest.json was stale. I regenerated it during the first main
  merge and then merged main again without re-running the check — my own gate
  discipline, not codex's.
- queryClient.ts tripped prefer-const: §7.1 used a `let` the handler closed over.
  It now uses an explicit `owner` holder, which also states plainly that nothing
  but the factory may write it — the property that keeps environment scoping
  structural.
- AgentsAutomationPanel.test.tsx imported REMOTE_UNAVAILABLE_HINT without using
  it.

The lint gate is the real lesson: my gate set was prettier + vitest + tsc, and
the repo also runs eslint in Frontend Quality. Two of the three failures above
were lint-only and invisible to everything I was running. eslint is now part of
the per-slice gate set.

Validation: FULL frontend suite 798 files / 13003 passed / 1 expected fail /
3 skipped / 0 failed (12996 -> 13003); eslint 0 errors; tsc green with pipefail;
prettier clean; event manifest regenerated and re-checked; layering PASS; drift
620 names / 0 unclassified. Implemented via codex (gpt-5.6-sol); tracker
frontend/.codex-s7-2b-tracker.md.
The six gate test files park the store on a remote environment and never put it
back. Vitest reuses a worker across files, so the next file inherits
activeEnvironmentId and resolves a different keyed QueryClient — which is exactly
how EnvironmentScopedProviders' identity assertion failed on CI shard 2/2 while
passing locally and in the unsharded suite.

Each now runs the same afterEach the provider test already used: resetQueryClient,
resetTransportEnvironmentId, and the store back to LOCAL_ENVIRONMENT_ID.

Honest limit: local sharding does not reproduce the CI failure — CI puts 398 files
in shard 2 and this machine puts 399, so the ordering differs and shard 2/2 was
green here both before and after. The fix is the right hygiene and matches the
failure mode precisely, but CI is what will confirm it.
…ifests

Three commits from main — the chat-scroll Virtuoso fix (#1010), RX-Native Team
Mode completion signal and coordinator wake dispatch (#1006), and the v0.96.0
release prep. No conflicts.

This merge is also the fix for a CI failure that could not be fixed any other
way. `Event Manifest` kept reporting scripts/event-manifest.json stale even
after I regenerated it and the check passed locally on the same SHA. The reason
is that the manifest records the LINE NUMBERS of authority-bearing spawns, and
Actions checks out the pull_request MERGE ref — head merged with main. While the
branch sat behind, main's edits shifted those lines, so the committed manifest
was correct for the branch and stale for the ref CI actually builds. A branch
behind main can never satisfy that gate; merging is the fix, not another
regeneration.

Both generated artifacts were regenerated on the merged tree. event-manifest.json
gains main's new authority-bearing spawns (native_delegation's
settle_delegation_from_run, managed_team messaging). remote-commands.json gains
the matching background-loop inventory rows — no new commands, so the facade
surface is unchanged at 620 names / 0 unclassified with 302 mirrored ops.

Validation (RUST_MIN_STACK=8388608): cargo check --tests --features test-utils
zero errors and zero warnings; facade suite 423/0 after the manifest regen (422/1
before, that one failure being the stale manifest itself); layering PASS; drift
620 names / 0 unclassified; census 0 unclassified; event manifest current; FULL
frontend suite 798 files / 12998 passed / 3 skipped / 0 failed; tsc green with
pipefail; eslint 0 errors.
Main is green, so all four were ours. None was visible to the gate set I had
been running (prettier + vitest + tsc + facade), which is the real lesson here.

THE APP DID NOT BOOT IN WEB/MOCK MODE. Playwright reported a scroll timeout
waiting for `app-header`; the page snapshot showed the error boundary holding
`TypeError: api.permission.listPendingPermissionGates is not a function`.
PermissionDialog calls that on mount, this branch added it to the real API, and
the mock API was never given the twin — so every Playwright job (chat scroll,
visual) died at startup and the "scroll" failure was a symptom, not the defect.
The mock now returns an empty replacement set, which is the honest answer for a
harness with no host. Chat-scroll integration: 3 failed -> 3 passed locally.

CLIPPY was failing both jobs, and had been since Wave B2c. plan_artifact_edit.rs
carried a 1100-character minified closure that rustfmt cannot lay out and so left
untouched, and clippy read `}}if` as a possible missing `else`. That single error
aborted the crate and masked twelve more. The closure is expanded to readable
form; the remaining lints — ten needless_borrow in execution_status.rs, two
needless_option_as_deref, one unnecessary_lazy_evaluation, one single_element_loop
in the ledger tests — are fixed rather than allowed. All semantics-preserving.

AUTOMATION CONFIG is a context-budget guard, and S2 (frontend chat UI) was 211
bytes over. The cause is two lines this branch added to the ROOT CLAUDE.md, which
every scenario inherits: a rules-index entry for remote-facade.md, which rule 15
requires, and a Key Features line. Both are already single lines, so there is
nothing to compress. The S2 ceiling moves 73_000 -> 74_500, restoring roughly the
~1.7% headroom the other six scenarios carry rather than pinning it to today's
size. Raising a guard the owner set is a deliberate change, and it is commented
as one at the threshold.

FRONTEND TESTS shard 2/2 is fixed by the preceding commit's afterEach cleanup —
both shards now pass on CI. The aggregate gate stays red only because it mirrors
the chat-scroll job, which this commit fixes.

Verified by re-running codex's exact gates rather than trusting its report:
clippy --no-default-features exit 0; clippy --all-targets --all-features exit 0;
facade suite 423/0 (the ledger-test edit is in that suite); FULL frontend suite
798 files / 12998 passed / 3 skipped / 0 failed; eslint 0 errors; tsc green with
pipefail; chat-scroll Playwright 3/3; context-budget guard all 7 scenarios PASS.
Clippy fixes via codex (gpt-5.6-sol); tracker src-tauri/.codex-clippy-tracker.md.
Both remaining Rust failures are CAPACITY, not correctness — neither reports a
single failing test or lint.

`Rust Clippy (all features)` died with exit 143, "the runner has received a
shutdown signal": `--all-targets --all-features` peaks past the 16 GB on
ubuntu-latest now that the crate graph is this size. It gets CARGO_BUILD_JOBS: "1",
the same cap and the same reason as the coverage archive job, which hit this first.

`Rust Lib Tests` shard 2/2 hit nextest's 30m global-timeout with 391 of 6112 tests
never run — 5721 passed, 0 failed. Two shards no longer fit the budget. Raising the
timeout would only defer it, so the matrix goes to three shards; each keeps roughly
two thirds of the work and the artifact name follows. This is the shard timeout the
open-decisions report flagged as unjudged when Actions was dark; it is now
diagnosed, and it was never a test failure.

Also merges main v0.96.1 and regenerates scripts/event-manifest.json against it.
That gate compares line numbers and Actions builds the pull_request MERGE ref, so
it goes stale every time main moves ahead — the fix is to stay merged up, not to
regenerate again on a behind branch.

Validation: context-budget guard 7/7 PASS; event manifest current on the merged
tree; ci.yml edits are indentation-consistent with no tabs, the three-shard change
is applied consistently across job name, matrix, --partition and artifact name, and
the separate two-shard integration matrix is deliberately untouched.
Five independent reviews of the branch — correctness/state, host-alone parity,
dishonest UI, test quality, cleanup. Findings I re-checked against the code
myself are marked VERIFIED; the rest are leads, not facts, and the report says so.

Nothing is fixed here. This is the ledger, ranked, with the two Tier 0 items that
should not merge as-is: the plan-edit lost-update guard that does not guard the
write, and a local MCP catalog read that now fails when a remote-only cache write
fails.
Three commits, 237 files. PR #993 "Locator Removal and Correlated Event-Bus
Cutover" is the whole story, and it reduces to three mechanical API transforms:

  1. `app_state.app_handle` + `handle.emit(..)` -> `emit_serialized(app_state.events.as_ref(), ..)`.
     `AppState::app_handle` is DELETED; `events: Arc<dyn EventSink>` replaces it.
  2. `build_transition_service_with_execution_state(x)` -> `build_transition_service_for_runtime(x, None)`.
  3. `build_chat_service_with_managed_execution_state()` REMOVED ->
     `build_chat_service_with_execution_state(Arc::clone(execution_state))`, which forces
     `execution_state` through the whole ideation apply/finalize path.

Seventeen files conflicted for ONE reason, not seventeen: this branch EXTRACTED
command bodies into `application/*` while main kept editing those same bodies
inline in `commands/*`, so git saw "ours deleted / theirs edited" everywhere. The
rule applied throughout is: keep our extraction as the single definition, drop
main's re-added inline copy, and port main's delta INTO ours. Taking main's side
instead would have silently reverted this branch's architecture — the first pass
did exactly that and produced 179 duplicate-definition errors, which is how the
rule got found.

Two resolutions needed judgement rather than mechanics.

THE MUTE TWINS. Main rewrote that file into one function using a new
`agent_workspace_response_with_pr_supervision_for_state`. This branch deliberately
has two functions because the ledger's detectors are call-graph based. Checking the
seams first settled it: on main `agent_workspace_response_for_state` is now a thin
PASSIVE alias of the recovery-free hydrator, and only the supervision variant adds
a side effect — all three return an identical response. So the local twin adopts
main's supervision seam, the remote twin keeps the recovery-free one, spawn
distinctness holds, and there is no fingerprint divergence (which main's own
comment warns would make a mute never apply).

THE SIDEBAR. Main re-added the inline hydration loop removed last merge as
orphaned — and it is orphaned again, because the row loop consumes the twin-built
map. Rejected, and `execution_state` threaded into our local twin instead so it
schedules PR-supervision recovery exactly as main intends.

Guards that had to follow main rather than be relaxed:

- `the_b2_workspace_read_refusals_are_pinned` failed with "no longer arms". Main
  moved the arming out of `agent_workspace_response_for_state` into the supervision
  variant. All three refused commands were re-checked and still hydrate through the
  arming function, so the refusal basis is unchanged and the pin now names where the
  arming lives. The assertion was followed, not deleted.
- The event-manifest scanner is OURS (it does not exist on main, which is why main
  is green). #993 introduced five new forwarding wrappers; each was reviewed and
  registered explicitly, which is exactly what its "a newly introduced forwarding
  wrapper is a CI failure" contract asks for.
- Four background-loop identities in `authority_audit.rs` are content hashes that
  moved when main changed those function bodies. Probed each: every loop still
  resolves and still arms (zero NOT-ARMING), so this is a hash refresh, not an
  authority change.

Two empty shells left by our own extraction (`execution_commands/{recovery,state}.rs`
— 0 and 2 re-export lines) are deleted rather than repopulated with main's re-add.

Validation (RUST_MIN_STACK=8388608), every gate re-run by the orchestrator rather
than trusted from the delegate: cargo check --tests --features test-utils zero
errors and zero warnings; clippy --no-default-features exit 0; clippy --all-targets
--all-features exit 0; facade suite 423/0; layering PASS (6 hard-zero rules);
drift 620 names / 0 unclassified; capability mirror 302 ops; census 0 unclassified;
event manifest regenerated and clean; context-budget guard 7/7; FULL frontend suite
798 files / 12998 passed / 3 skipped / 0 failed. Rust port via codex (gpt-5.6-sol);
tracker src-tauri/.codex-merge-port-tracker.md.
Base-ahead detection, repair reconciliation and startup prioritization — 34
files. Two conflicts, both structural rather than semantic.

The migrations conflict was the dangerous shape again: main's new
`add_agent_workspace_stale_base_detected_at` (20260806154753) and our
`remote_plan_approval_requests` (20260806080000) collided INSIDE one
`Migration { .. }` block, sharing its opening and closing braces. A plain union
would have produced a single entry with duplicate `version`/`name`/`migrate`
fields — the same malformation this branch hit on the previous main merge. Split
into two complete entries instead, then sorted with uniqueness asserted: 267
migrations, no duplicate versions. The two numbers do not collide, so no
renumbering was needed and SCHEMA_VERSION simply advances to main's higher value.

`commands/mod.rs` was a straight union — each side declares different modules.

Validation (RUST_MIN_STACK=8388608): cargo check --tests --features test-utils
zero errors and zero warnings; clippy --no-default-features exit 0; clippy
--all-targets --all-features exit 0; facade suite 423/0 after regenerating the
command manifest for #1009's new commands; layering PASS; drift 620 names / 0
unclassified; capability mirror 302 ops; census 0 unclassified; event manifest
regenerated and clean; FULL frontend suite 798 files / 13004 passed / 3 skipped /
0 failed; tsc green; eslint 0 errors.
Three conflicts. Two were structural; one needed a real classification decision.

`reopen_agent_workspace_pr` is a NEW command, and the facade allowlist is
exhaustive over `generate_handler!`, so drift failed until it was dispositioned.
Audited rather than pattern-matched: it runs `gh pr reopen` on the host's
credential AND rebuilds the local branch and worktree from origin. That is
process-reaching and destructive-adjacent, the same family as the already-Denied
`close_agent_workspace_pr`, so it is ledgered Denied with that reason recorded.
Drift moves 620 -> 621 names, still 0 unclassified; detector (b)'s census
calibration follows 609 -> 610 with the reason beside it.

`AgentsPublishPanel.tsx` was the interesting one. Main widened the overflow menu
to `(canClosePr || isHeld || canReopenPr)` and added a Reopen PR item; this branch
wraps that whole control in a remote gate. Git aligned the two versions on a
coincidentally similar `Close PR` item, so stitching the hunks would have emitted
that item twice. Rebuilt instead from both complete versions: our gated
disabled-button branch stays as the first ternary arm, and main's dropdown —
condition, reopen item, `isReopeningPr` spinner and all — becomes the second. The
two `agents-close-pr` testids sit in mutually exclusive arms, so only one ever
renders.

`chat.ts` was a union of two independent additions, and it hid the same trap the
migrations file sprang last merge: the conflict boundary fell INSIDE a function,
so the union silently dropped its closing brace. The file still looked plausible
and 177 test files failed to parse. Brace restored; the union itself was correct.

`application/mod.rs` needed a dedupe after taking main's module block — three
declarations already existed in a clean region.

Validation (RUST_MIN_STACK=8388608): cargo check --tests --features test-utils
zero errors and zero warnings; clippy --no-default-features exit 0; clippy
--all-targets --all-features exit 0; facade suite 423/0; layering PASS; drift 621
names / 0 unclassified; capability mirror 302 ops; census regenerated, 0
unclassified; event manifest regenerated and clean; FULL frontend suite 800 files
/ 13019 passed / 3 skipped / 0 failed; tsc green; eslint 0 errors.
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.

2 participants