Skip to content

feat(dashboard): the agent chat confirms held actions and shows minted keys once (WP-D client, lands with #381) - #413

Draft
LamaSu wants to merge 56 commits into
masterfrom
feat/agent-chat-held-actions
Draft

LamaSu wants to merge 56 commits into
masterfrom
feat/agent-chat-held-actions

Conversation

@LamaSu

@LamaSu LamaSu commented Sep 24, 2026 •

Copy link
Copy Markdown
Owner

Draft: the client half of gateway WP-D (#381, board N9). Land it together with #381. Stacked on #354 (the route model: /agent renders this chat) and #368 (authorizedFetch). Base: master.

What changes in OnboardChatPage (/agent and /onboard/chat)

  • Who the chat runs as. /agent runs the conversation as the signed-in user: its requests go through authorizedFetch, so the key reaches only the configured gateway. Public /onboard/chat stays anonymous and sends no key, even when someone is signed in.

  • Held actions (pendingActions). Each renders with:

    • the gateway's own summary, the method and target;
    • for a credential-minting call, whose credential it creates (bindsTo), shown prominently.

    Nothing runs until the person presses Confirm. That sends one {conversationId, confirmActionId}, and a synchronous guard blocks a second send. The outcome shown is the gateway's confirmedAction (tool and HTTP status):

    • a refusal shows its reason;
    • a reply that doesn't say whether the action ran reads as unknown, never done;
    • an expired action offers no Confirm.
  • Revealed secrets. Each renders once, with boundTo and "shown once". It lives only in component memory and is never written to storage; the gateway never replays it.

  • Gateway URL. The health check and the anonymous chat use the validated gateway URL, not the raw VITE_PCC_URL.

  • Copy. The agent intro now says the agent acts as you and that changes wait for your Confirm. The onboarding intro says nothing is created until you confirm it.

Tests

Browser-verified against #381: 14/14 (returns/pcc-shell-work/e2e-wpd-20260924/)

Setup:

Anonymous /onboard/chat:

  • provision_api_key is held, with "This creates a credential for maker@example.test.";
  • the POST carries no key;
  • one Confirm request runs it, and the page shows "Done: the gateway ran provision_api_key and it answered 201";
  • the new key (and the Ed25519 private key) are shown once, with whose they are;
  • neither key is in any browser storage, and they are gone after a reload;
  • GET /api/onboard/chat/:id never replays the key, and the revealed key validates.

Signed-in /agent:

  • the POST carries the user's key, to the gateway;
  • list_kernels runs directly;
  • create_kernel is held, and nothing is created until Confirm (kernels 0 → 0 → 1);
  • the page shows "create_kernel … answered 201".

The run found a bug, now fixed at 1577eb6. A new conversation sent conversationId: null. #381 looks up any conversationId it is sent, so it answered 404 and no chat could start. The client now omits the field until it has an id, and a new test covers it (9 tests; 1 fails with the old body).

🤖 Generated with Claude Code

https://claude.ai/code/session_01VU6exGFC7uLukeDF2GBUpQ

LamaSu and others added 30 commits September 24, 2026 05:29
…-sepolia

The StatusBar defaulted kernelsOnline and activeJobs to 0, networkStatus to
"connected", and always printed "base-sepolia". A caller with no data
therefore rendered plausible values instead of saying it had none.

- Counts are number | null | undefined; unknown renders as a dash.
- networkStatus gains "unknown" and defaults to it ("Checking gateway...").
- The network label is an optional prop with no default.

Tests: 7 in StatusBar.test.ts; 5 fail against the previous component.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6exGFC7uLukeDF2GBUpQ
…ccount state

StatusBar: App.tsx passed kernelsOnline={2} activeJobs={3}
networkStatus="connected" on every page. LiveStatusBar now derives them from
the same react-query reads the Command Center uses:
- /api/health, re-checked every 30s;
- /api/kernels: online only with a fresh heartbeat (isStale);
- /api/jobs: active = the gateway's in-flight set in /api/agent/me
  (pending, queued, in_progress, paused).
A failed or pending read shows as unknown, not 0.

Settings showed wallet 0x1234...5678, "Base Sepolia", "1,000.00 USDC",
"Tier 1" and "Auto-fund Escrow: Enabled", all hard-coded. It now shows:
- the account behind the API key (GET /api/agent/me: operator, key, scopes,
  and how many keys hold the * scope);
- the wallet wagmi reports as connected, and its chain.
Balance and preferences are removed until something serves them.

Tests: live-status.test.ts (8), including a source check that App.tsx passes
no literal counts to a status bar.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6exGFC7uLukeDF2GBUpQ
Every live page read its data as `const { data = [] } = useX()`, so during
an outage it showed "No jobs yet", "0/0 kernels", "$0.00 locked" or
"Welcome to PCC, ready": plausible values in place of "couldn't load".
Each page now has four states:
- loading;
- unavailable: the read failed and nothing was read before;
- stale: the refresh failed, so earlier data is shown with its time;
- data, where empty means the read succeeded and returned nothing.

Also removed, because each showed a value no source serves:
- Command Center: "Evidence Events 0 / last 24 hours" (hard-coded);
  "Recent Activity: No activity yet" (a static claim); job.amount and name
  (not in JobDTO, so every job showed $0.00).
- Total Value Locked (Command Center and Escrow) summed every escrow's
  totalAmount, counting refunded and released escrows as locked. Removed
  until #313's exact map or a read model serves funds held.
- Escrow: "Challenge Windows 0" (hard-coded); milestones read a field the
  DTO doesn't have (now milestoneCount).
- Revenue: summed job.amount over completed jobs, which presents completion
  as payment. Removed until settled income is served. Success rate now uses
  finished jobs, not all jobs.
- Kernels: capability count read a missing field (now capabilityCount).

Crashes fixed:
- DiscoverPage and KernelLeaderboardPage called useMemo after the loading
  early-return. React threw "Rendered more hooks than during the previous
  render" on every first load.
- KernelsPage rendered kernel.location, a {lat, lng} object, as a React
  child. It now shows physicalAddress, the label, or the coordinates.

Active and online counts use lib/live-status.ts, the same definitions as the
StatusBar. Job status chips map the canonical StepStatus values; pending,
in_progress and paused previously rendered as "offline".

Tests: live-pages-honesty.test.tsx (18) renders the real pages with the real
hooks and a stubbed fetch, in outage / partial / empty / data cases.
15 of the 18 fail against master's pages.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6exGFC7uLukeDF2GBUpQ
…conversation

One route model (product s3 items 3-5; PX-14's "/ vs /dashboard"). Before:
- The in-memory interfaceMode defaulted to "spatial", so every signed-in URL
  (/dashboard, /jobs/:id, /settings) rendered the empty spatial canvas;
  navigate() from a page changed the URL and nothing visible happened.
- The sidebar's "Dashboard" item pointed at "/", the public landing.
- "Agent mode" rendered AgentChatPage: hard-coded counts (847 capabilities,
  163 operators, 12,438 jobs), bounties and a leaderboard, not a chat.
- /legacy/* rendered the dashboard shell at a path none of its routes match.
- A signed-in /login rendered the SPA landing page.

Now (lib/workspaces.ts):
- Each workspace has an address. /app is spatial, /agent is the agent, and
  every other app path is the dashboard shell. The mode toggle navigates
  between them; nothing held in memory overrides the URL.
- A deep link opens its page in the dashboard shell. An unknown app path
  shows "Page not found".
- "Dashboard" goes to /dashboard.
- A signed-in /login and the setup wizard's finish go to APP_HOME
  (/dashboard).
- /legacy/x redirects to /x.
- "/": the gateway serves static landing.html. An in-app navigation to "/"
  does a full page load. The SPA renders its own landing only when the
  document was loaded at "/", so dev cannot loop.
- /agent renders OnboardChatPage variant="agent": the same live
  POST /api/onboard/chat conversation, without the onboarding step bar. It
  says it uses PCC's public tools and does not act with the user's API key.
- AgentChatPage is deleted. Its one real feature, the /api/feedback form,
  moves to components/FeedbackButton.tsx in the dashboard and agent top bars.
- OnboardChatPage's footer claimed the chat "survives a page reload", which
  the page never did. The footer now says reloading starts a new
  conversation. It no longer prints the conversation id, a bearer id for the
  public GET (bus #2288).

Tests:
- workspaces.test.ts (9)
- routing.test.tsx (11): renders the real App at each address. 9 of the 11
  fail against master's shell.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6exGFC7uLukeDF2GBUpQ
None of these modules is imported by any live module:
- hooks/use-agent-sse.ts
- stores/chat-store.ts
- agent/* (agent-client, agent-tools, agent-types, card-registry, system-prompt)
- components/chat/*

The client targeted POST /api/agent/chat, which does not exist on the
gateway. chat-store also hard-coded a "73 tools" count (aeo #2265). The real
conversation is OnboardChatPage (/onboard/chat, and /agent in the previous
commit). Product invariant 7: a superseded generation is retired, not kept.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6exGFC7uLukeDF2GBUpQ
… not a total

Product-steward review of #352 (bus #2409):

MEDIUM. GET /api/jobs returns at most 50 rows when no limit is sent
(job.facade.ts) and reports no total. The StatusBar, Command Center, Jobs
and Revenue pages counted page 1 and showed the result as exact. Now a full
page makes every count over it a lower bound, rendered "N+":
- StatusBar: activeJobsAtLeast
- lib/live-status.ts: JOBS_PAGE_SIZE, mayBeTruncated, formatCount
- the pages note "there may be more"
- Revenue's success rate shows "--" instead of a rate from a partial sample
ProductHomeDTO / a /api/jobs total (readmodels #2289, #2290) will replace
the lower bound with the exact count.

LOW. useKernels and useEscrows mapped an unexpected response shape to [],
which reads as "0 kernels online". They now throw, like readmodels' useJobs
(absence is not evidence). /api/kernels and /api/escrow return full lists,
so they need no lower bound.

Tests:
- live-status +3, StatusBar +1, live-pages-honesty +2.
- live-pages-honesty's settle wait is now condition-based (up to 2 s), not a
  fixed 50 ms that flaked under load.
- dashboard 251/251, ui 17/17. Against master's pages, 17 of the 20 honesty
  tests fail.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6exGFC7uLukeDF2GBUpQ
Product-steward review of #354 (bus #2409), nit (a): /spatial aliased the
spatial workspace instead of redirecting to its canonical address.
canonicalRedirect() now covers /spatial -> /app alongside
/legacy/x -> /x.

Also records decision D1 (#2373) in lib/workspaces.ts:
- /app becomes the one adaptive shell;
- /dashboard plus the page routes are the inspect family;
- /agent is interim until Wave 4 folds it into /app.

Tests: workspaces 10, routing 12. Dashboard 242/242.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6exGFC7uLukeDF2GBUpQ
Found while answering coord-watch's review question (#2497): can any surface
still show a fabricated, default or last-known value when a read failed or
came back off-schema?
- Command Center: after a failed refresh, the KPIs kept their last-known
  figures while the banner said failed figures show as "—". A summary now
  shows only what its latest read returned. The list pages keep earlier data
  under a timestamped StaleNotice.
- useJobs mapped an off-schema /api/jobs to [], which reads as "0 active
  jobs". It now throws. The lines are byte-identical to readmodels'
  feat/readmodels-job-execution, so the two branches merge in either order.
- useAgentMe: an answer without the identity block is a failed read.
  Before, Settings crashed.

Tests: +3 in live-pages-honesty; all 3 fail on the previous head
(753ab25). Dashboard 254/254.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6exGFC7uLukeDF2GBUpQ
…alhost:3200 (N50)

Gate A, HIGH (steward #2640, product-steward #2602).
- SetupWizardPage.tsx:53 fetched http://localhost:3200/api/capabilities with
  the signed-in user's Authorization header on every /setup load, in every
  build, then claimed "Gateway connected at localhost:3200".
- SetupAgentPage defaulted gatewayUrl to http://localhost:3200 for its three
  key-bearing requests (scan-network, identify-machine, register-device), and
  its only caller never overrode it.
In production both handed the user's key to whatever listens on port 3200 of
their own machine.

Fix:
- lib/gateway-base.ts: the configured gateway. That is same-origin, or
  VITE_PCC_URL, the variable the auth store already uses. It is the only
  origin that gets the key.
- The wizard's liveness check hits the public /api/health, with no key, and
  says "Gateway reachable".
- SetupAgentPage defaults to the configured gateway.

Guard: __tests__/no-hardcoded-gateway-origin.test.ts fails on any
http://localhost or 127.0.0.1 literal in production code. Its shrink-only
KNOWN list holds three literals that are not key leaks: two dev-only bases
and a chain-RPC form default.

Tests:
- setup-key-origin.test.tsx renders both real pages, records every fetch, and
  fails if a request leaves the configured gateway or carries the key off it.
- 4 of the 5 new tests fail against master's pages.
- Dashboard 225/225.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6exGFC7uLukeDF2GBUpQ
…irmed reachable

Operator-ux's real-state trace (#2800): about 2 s after the gateway
stopped, the bar still read "Gateway online | 2 kernels online", and it kept
that until its next 30 s health poll. The counts came from reads that
succeeded before the outage, presented as current.

- deriveLiveStatus shows kernel and job counts only while /api/health
  confirms the gateway is reachable. While liveness is unknown or down they
  are "—".
- recheckHealthOnReadFailure: any failed gateway read (other than health
  itself, so there is no loop) invalidates the health query, so the bar
  flips to "Gateway unreachable" on the next failed read instead of the
  next poll.

Tests: live-status +2; LiveStatusBar.test.ts (3, jsdom). Dashboard 259/259.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6exGFC7uLukeDF2GBUpQ
… (N50 review)

Cross-family review of #368 (sol, #2857) found that the key was attached
without regard to where a request went. This fixes each finding:

1. Two credential destinations. There is now one gateway origin
   (lib/gateway-base.ts). fetchWithKey() is the one place a key goes onto
   a request: it resolves the final URL, refuses any other origin, and only
   then adds the header. lib/api.ts, api/gateway.ts (every react-query
   hook), the auth store's key check, SetupAgentPage and
   EarnFromYourWorkPage use it. EarnFromYourWorkPage sent its new key to
   http://localhost:3200 in dev builds, and in production builds to
   https://capability.network, even when served from staging.
2. VITE_PCC_URL was not validated. It must now be an absolute https:
   origin (http: only on loopback in a dev build). A bad value fails
   closed: no request carries a key, and the reason is logged.
3. The guard was literal-localhost only. A startup egress guard
   (main.tsx) rejects any fetch that carries a PCC key or the stored key,
   in a header, the URL or a string body, to another origin.
   no-direct-auth-headers.test.ts ratchets the remaining hand-built
   headers (31 modules, each with an owning lane) out of the code.

SetupAgentPage no longer takes a gateway argument (product-steward, #2849).

Tests: dashboard 264/264 (+39). tsc and the build are clean. Negative
controls: removing the origin check fails 6 tests, accepting any scheme
fails 6, a pass-through guard fails 3, no guard at startup fails 1, and
restoring master's EarnFromYourWorkPage, lib/api.ts or api/gateway.ts
fails 3, 2 and 2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6exGFC7uLukeDF2GBUpQ
…ay client

FeedbackButton built its own Authorization header. It now calls
api.submitFeedback, so its key is attached the same way as every other
request (and, with #368, only for the configured gateway). Once #368
lands, a hand-built header is a ratchet failure. Behaviour is unchanged:
a rejected post still shows "Failed -- retry?".

Tests: FeedbackButton.test.tsx (2), which characterise the refactor
rather than a fix, so they also pass on the old code.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6exGFC7uLukeDF2GBUpQ
The spatial canvas opened with "You are using the limited web interface
... Your AI builds a better interface than this." Under route model D1,
/app is the product's own adaptive shell, so product-steward (#2873, from
product-qa #21) asked for neutral copy or none. Launch owns the words, so
the banner is removed rather than rewritten.

routing.test.tsx now detects the canvas by data-shell="spatial" rather than
by that sentence, and a new test fails if the copy returns. It fails
against the old SpatialApp: 1 of 13.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6exGFC7uLukeDF2GBUpQ
…d keys once (WP-D client)

The client half of gateway #381 (N9, WP-D), per its contract (#2841,
#3186):
- /agent runs the conversation as the signed-in user: requests go through
  authorizedFetch, so the key reaches only the configured gateway. Public
  /onboard/chat stays anonymous and sends no key.
- Held writes (pendingActions) render with the gateway's own summary, the
  method and target, and whose credential a minting call creates (bindsTo),
  shown prominently. Nothing runs until the person presses Confirm. Confirm
  sends one {conversationId, confirmActionId}; a synchronous guard blocks a
  second send. The outcome shown is the gateway's confirmedAction (tool and
  HTTP status). A refusal shows its reason. A reply that doesn't say whether
  it ran reads as "unknown", never "done". An expired action offers no
  Confirm.
- revealedSecrets render once, with boundTo and "shown once", and live only
  in component memory: never written to storage.
- The health check and anonymous chat use the validated gateway URL, not
  the raw VITE_PCC_URL.
- The intro copy now says the agent acts as the user and that changes wait
  for Confirm.

Tests: agent-chat-held-actions.test.tsx (8); 7 of 8 fail against #354's
page. Dashboard 297/297; tsc clean. Depends on #354 and #368 (merged in)
and on gateway #381, which it must land with.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6exGFC7uLukeDF2GBUpQ
The settle loop stopped at the first tick with no fetch in flight, and a
query can read as idle for one tick between retries. Under load on the
Spark (load average 16-21) this file failed 3 of 23 once (the Kernel
leaderboard, Revenue and Settings cases; reported by implementer-echo). It
now waits for two idle ticks in a row, within 2 s: the same fix #408
applies to its own tests. Test-only. Dashboard 259/259 on three
consecutive runs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6exGFC7uLukeDF2GBUpQ
Found by driving this client against gateway #381 in a browser (with a
stub model): the first message sent {"conversationId": null, ...}. #381
looks up any conversationId it is sent, null included, and answered 404
conversation_not_found, so no conversation could start. The page now omits
the field until the gateway has given it an id. New test; it fails 1 of 9
with the old body.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU6exGFC7uLukeDF2GBUpQ
…ames, including a fork

Gateway #381's round 4 (L6, c82df5b) no longer lets a signed-in caller
claim an anonymous conversation. It continues in a fork the caller owns,
and the reply carries the new conversationId plus forkedFrom. The page
kept the first id it saw, so it would have kept posting to the old
conversation. It now always follows the id the gateway returns, after a
message or a confirmation, and says when it moved to a new conversation.

With the page's two variants a fork shouldn't normally happen: /agent
always sends the key, and /onboard/chat never does. This keeps the client
correct if it does. New test (10 in the file); it fails with the old
adopt-once logic.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…Page.tsx, SettlementPage.tsx send the key only through authorizedFetch

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…estratorPage.tsx, OrchestratorDetailPage.tsx send the key only through authorizedFetch

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…SponsorTelemetryPage.tsx, EvidenceExplorerPage.tsx send the key only through authorizedFetch

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…artPage.tsx, routes/onboard/chat/index.tsx send the key only through authorizedFetch

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…st.ts KNOWN list drops the 12 files migrated to authorizedFetch

The ratchet's "KNOWN only shrinks" test failed after the n50-mig-a
migration because these 12 files no longer build an Authorization
header directly: WalletPage.tsx, TelemetryPage.tsx, SettlementPage.tsx,
ProtocolRunPage.tsx, OrchestratorPage.tsx, OrchestratorDetailPage.tsx,
SystemDashboardPage.tsx, SponsorTelemetryPage.tsx,
EvidenceExplorerPage.tsx, BatchTrackingPage.tsx, StartPage.tsx,
routes/onboard/chat/index.tsx. Removing their entries is exactly what
the test asks for; no other KNOWN entries were touched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…torDashboardPage, AgentPackagePage, OperatorA2APage, ProtocolLibraryPage send the key only through authorizedFetch

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…BoardPage, SensorDashboardPage, NegotiationSessionPage send the key only through authorizedFetch

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…Form, DiscoverabilityPanel, DisputeModal send the key only through authorizedFetch

Also drops these 4 files plus the pages/* migrated in the two prior
commits from the no-direct-auth-headers ratchet's KNOWN allowlist: the
test asserts KNOWN only shrinks, and after this migration none of the
13 files assigned to this lane build an Authorization header by hand
any more.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
astra's round-2 review of #368 (DO-NOT-SHIP on 74f758b) found that legacy
callers could still read the key, while a request scanner that could be
bypassed served as the boundary. The boundary is now structural:

- stores/auth-store.ts keeps the key out of zustand state, so reading it
  from the store is a type error, and getAuthHeaders() is gone. The key's one
  accessor, readApiKeyForAuthorizedFetch(), is read only by
  lib/authorized-fetch.ts.
- fetchWithKey() (lib/gateway-base.ts) is the only code that puts a key on a
  request. It resolves the target against the one validated gateway origin,
  refuses any other origin, and refuses redirects (redirect: "error").
- The page-origin fallback is validated like VITE_PCC_URL: https, or http
  only on a loopback host. Anything else fails closed.
- The egress guard is defence in depth. It reads URLSearchParams, FormData,
  Blob, binary and Request bodies, percent-decodes them, checks the stored
  key's base64 forms, and refuses any body it can't read: a stream, or an
  object it doesn't recognise, such as one from another realm.
- The special callers:
  - Analytics fetches only literal /api/analytics paths, enforced by type.
  - Orchestrator chat accepts only a gateway-relative api_base and encodes
    session ids.
  - CdpFundedKeyOnramp, agent-client, AgentChatPage and the passkey flow
    send through authorizedFetch.
- The ratchet (no-direct-auth-headers.test.ts) has no file exemptions:
  - getAuthHeaders doesn't exist;
  - the accessor is referenced only in authorized-fetch;
  - the storage slot is touched only in the store, whether by name or by
    sweeping localStorage;
  - Authorization and Bearer are built only in gateway-base, apart from
    exact reviewed display lines, each with the number of times it occurs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…words (N50)

lib/telemetry.ts turns on PostHog session recording and asked for request
and response headers and bodies through session_recording.
networkPayloadCapture. The client SDK never reads that option; it is a
remote project setting. So whether PostHog recorded headers and bodies was
up to the PostHog project's settings. Requests carry the API key, and the
response from /api/contributors/quickstart carries a newly issued key and,
from the demo wallet adapter, a recovery phrase.

The client now sets recordHeaders and recordBody to false. The project
settings can't override that: in posthog-js 1.363.1 the recorder enables
header capture only if the client's recordHeaders isn't false and the
remote config turns it on, and body capture the same way.

PostHog also records DOM text by default; it masks only inputs.
EarnFromYourWorkPage rendered the new key and the twelve recovery words as
text. Both now sit inside .ph-no-capture, PostHog's default blockClass,
which session recording and autocapture skip. Sentry Replay 8.55 masks all
text and records no network details by default, and a test pins that
initTelemetry() loosens neither.

Found while attacking the round-2 fix; astra's pack did not include
telemetry.ts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t-held-actions

This brings #368's round-3 boundary into #413: the key held outside the
store, authorizedFetch as its one reader, and the migrated call sites.

Conflicts resolved:
- agent/agent-client.ts and pages/AgentChatPage.tsx: kept #413's deletion.
  The held-actions chat replaces them, so #368's migration of their fetches
  no longer applies.
- routing.test.tsx and agent-chat-held-actions.test.tsx set the key with
  adoptApiKey(), because the store's state no longer holds one.

tsc is clean and the dashboard passes 320/320.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ssion recording (N50)

A credential minted by a confirmed held action is shown once, in the
reply's revealed-secret panel. The panel rendered the credential as DOM
text, and PostHog session recording captures DOM text: it masks only
inputs. The panel now carries ph-no-capture, PostHog's default block class,
which session recording and autocapture both skip. #368 did the same for
the Earn page's new key and recovery words.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
LamaSu and others added 26 commits September 29, 2026 12:42
…rmed (PX-3, astra r2)

astra's round-2 review of #352 (DO-NOT-SHIP at 459c616) found that the
query-to-view boundary treated data presence and a shallow envelope check
as proof of current, valid state.

The list hooks now reject a response with a malformed row, not just one
without the array:
- jobs need an id and a status;
- kernels need an id, a status and the isStale flag the populator always
  sets;
- escrows need an id and a status;
- capabilities need an id and a kernelId;
- templates must be an array.

/api/agent/me must carry a complete identity: operator, key_id, key_name
and scopes. Settings reads the key section through keyCounts(). That treats
{ active: null, wildcard_keys: 0, unavailable } as unavailable, not as zero
wildcard keys.

The StatusBar shows a count only if it was read after the gateway's last
failed health check and is at most 75 s old. A recovered health check no
longer certifies counts cached before the outage. The bar re-reads its
counts every 30 s, and at once on recovery. isKernelOnline now requires
isStale === false.

Pages:
- Discover, the leaderboard and Revenue label data kept after a failed
  refresh (StaleNotice).
- Discover says when site names couldn't be read, and no longer turns
  missing templates into an empty list.
- The leaderboard reads every capability page at limit 200, the route's
  maximum; it had asked for 500. If paging stops early, it says the ranking
  covers only what was read.
- The Command Center and Jobs say "in the first 50" when only one page was
  read.
- Kernels' capability total and the leaderboard's queue depth show as
  unknown, not zero-filled, when a row doesn't report them.
- The leaderboard's online pulse uses the fresh-heartbeat rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…an empty cache (PX-14a, astra r2)

astra's round-2 review of #354 (DO-NOT-SHIP at 20c61d5) found three things.

workspaceForPath() matched every /app/* path. SpatialApp never reads the
path, so /app/no-such-page and /app/jobs/job-123 rendered the canvas and
bypassed the dashboard's NotFound route. The spatial and agent workspaces
now own only their own address, /app or /agent, with or without a trailing
slash. Anything below them falls to the dashboard routes, whose catch-all
says "Page not found".

The query cache outlived the account that filled it. After a sign-out and
a sign-in with another key, the next account saw the first one's cached
jobs, with no refetch for up to the 30 s staleTime. App.tsx now empties
the cache on every sign-in and sign-out. The key changes only through
those two transitions: LoginPage is shown only when signed out.

The deep-link tests asserted only that the shell was chosen, never that the
page rendered, and the lazy page hadn't even loaded when they ran. They now
wait for each page's own subtitle. ("Command Center" is also a sidebar
title.)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…A-H)

Covers astra r2's #352 findings: malformed-row rejection (jobs/kernels/
escrows/templates/capabilities), stale-refresh labelling on Discover/
Kernel-leaderboard/Revenue, Discover's "site names couldn't load" notice,
truncated-page wording on Command Center and Jobs, the kernel capability
total going unavailable (not zero-filled) when one kernel omits it,
useAllCapabilities paging through every page of /api/capabilities (250
rows/2 pages, and a short second page marking the ranking partial), and
three Settings account-section honesty cases (missing key_id, keys.
unavailable with reason, missing keys section entirely) -- two of which
crash on the pre-fix source (identity.key_id.slice on undefined; keys.
active on an undefined keys object).

Also adds two kernel-leaderboard-logic.ts unit cases: buildLeaderboard's
`online` field reflects isKernelOnline (isStale-gated), and `queueDepth`
is null when any capability omits it rather than treating it as zero.

19 new cases total (17 in live-pages-honesty.test.tsx, 2 in
KernelLeaderboardPage.test.ts). Full dashboard suite: 281/281 passing.
tsc --noEmit: clean. Stretch case (LiveStatusBar recovery timing) skipped
per spec -- already covered by lib/__tests__/live-status.test.ts's
"counts are current, not just cached" describe block.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… not any digit-plus

Review of test-writer-alpha's 58bbfab: /\d\+/ would match any lower bound on the page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
product-steward asked for this (#3378): #352 and #313 conflicted in
EscrowPage.tsx, and #408 and #380 inherited the conflict. Resolved with
the recipe from #3124:
- The one conflicting hunk is the stats grid. It takes #352's side: the
  Total Locked panel stays removed, since it was a TVL sum with no read
  model. #313's only edit there was dropping that panel's glow.
- Everything else auto-merges, including #313's moneyBadgeColor on both
  escrow badges.

tsc is clean. The dashboard passes 284/284, @pcc/ui 17/17.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…shell-route-model

#354 now stacks on #352 (and so on #313), which answers astra's pack-19
finding 2. At #354's own head, App.tsx still passed kernelsOnline={2},
activeJobs={3} and networkStatus="connected". With #352, the dashboard shell
renders <LiveStatusBar /> instead. The RC2 merge order already puts #352
before #354. Clean merge.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…hat-held-actions

This brings #354's round-3 fixes (astra pack 19) into #413. The spatial and
agent workspaces now own only their own address, and every sign-in and
sign-out empties the query cache. It also brings #352's round-3 honesty
fixes and #313, which #354 now stacks on.

Conflict resolved: #354's new user-switch test in routing.test.tsx set the
key with useAuthStore.setState({ apiKey }). #368's store, which #413
carries, keeps the key out of state, so the test uses adoptApiKey(). That
still flips isAuthenticated, which is what App.tsx's cache-clearing
subscription watches.

tsc is clean and the dashboard passes 387/387.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This is the review loop's verify-before-fix step for astra's round-3
verdict on #368 (A03c, DO-NOT-SHIP at cfe3ce9). Each test fails at
cfe3ce9, which reproduces its finding:
- F1-A: an export hands out the key. readApiKeyForAuthorizedFetch() returns
  the signed-in key to any module, including one that reaches it by
  computed access.
- F1-B: sendBeacon is outside the egress guard. A key-carrying beacon to
  another origin goes out.
- F2: an operator-bound passkey registration without the authorized fetch
  does not fail closed. It makes an anonymous challenge request.

Also reproduced by trace at cfe3ce9: a production module holding both of
astra's bypass snippets type-checks, and no-direct-auth-headers passes 7/7.
The snippets are computed access to the accessor, and
localStorage.getItem("pcc-" + "api-key"), each sent with sendBeacon.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…l at eaedeb4)

Appends an R4 describe block to live-pages-honesty.test.tsx with 18 tests
(R1a-R5b) covering every finding in the 18b astra verdict for eaedeb4:
escrow currency/amount fabrication (Dashboard, Escrow, Revenue), Discover
presenting templates as live capabilities, off-schema rows (bogus job/kernel/
escrow status, capability missing type, negative queue depth, negative/
fractional key counts) still reaching displayed numbers, the capabilities
pager certifying a shrunk-total or duplicated-page read as complete, and two
categorical empty-state claims over a partial read. All 18 fail at eaedeb4
as designed (verified via `pnpm exec vitest run`); the 40 pre-existing tests
in the file still pass. No source file was modified.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ithout the authorized fetch

astra A03c F2 (MEDIUM): runPasskeyRegistration took authorizedFetchFn as
optional. With an operatorId and no authorized fetch, the challenge went out
through the plain fetch, without the key, so the passkey would register
unbound to the operator. It now throws before any request.

Reproduced at cfe3ce9 by 3b6d07d ("an operator binding without the
authorized fetch fails closed, before any request"), which now passes. The
401 test binds an operator, so it now supplies the authorized fetch and checks
the plain fetch is never called.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s in as a string literal

The two Node scripts AgentLinkPage (/go) shows for the user to copy or
download were template literals in the page. They build the Authorization
header the user's agent sends, so the key-boundary ratchet had to exempt
those lines (DISPLAY_ONLY), and astra A03c F1 found that exemption
context-blind: the same line moved into running code would still pass.

- The scripts are now text assets (pages/agent-link/*.js.txt, imported with
  ?raw), byte-identical to what the page showed before for a plain ?q= and
  for none. The dashboard shows them and never runs them, and no dashboard
  module holds the header line, so DISPLAY_ONLY is empty. The next commit
  removes it from the ratchet.
- Found while moving them: ?q= was pasted into the script's last line as
  chat("${capability}"). A crafted link such as
  /go?q=x"); require("child_process").execSync("..."); ("
  made the script the user downloads and runs with node run code of the
  link's choosing. Reproduced by agent-link-quickstart.test.tsx against the
  old page (chat got "x", and the injected statement ran). ?q= now goes in as
  a JSON string literal, through a replacer function so $& and $1 in ?q=
  stay literal.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… and the ratchet has no exemptions

astra A03c F1 (HIGH): at cfe3ce9 another production module could read the
signed-in key and send it off-origin without failing the ratchet or passing
the fetch guard. Reproduced by 3b6d07d (key-boundary-r4.test.ts), and by
probe modules holding astra's two snippets: both type-checked, and both passed
the old ratchet.

- One holder, no reader. lib/authorized-fetch.ts now owns the key: a
  module-private variable, and its localStorage slot. It exports
  setStoredApiKey, hasStoredApiKey, authorizedFetch and
  installGatewayKeyGuard, and none returns the key. The store's
  readApiKeyForAuthorizedFetch export is gone, and the store only knows
  whether a key is held.
- sendBeacon is under the egress guard. installKeyEgressGuard now wraps
  navigator.sendBeacon too, and inspects it synchronously: a string,
  URLSearchParams, FormData of strings, or binary body, and the URL. A
  key-carrying beacon to another origin is refused (returns false). A body it
  can't read synchronously (a Blob or a file) is refused, except to origins
  registered for such beacons: main.tsx registers the PostHog host, whose SDK
  sends Blob beacons on unload. A readable body is inspected whatever the
  origin.
- The ratchet (__tests__/no-direct-auth-headers.test.ts) is rewritten as
  rules over every production module, with no exemption for any page or
  line. Only the key's owner and the boundary do what the rules forbid
  elsewhere:
  - the slot name appears only in the owner, and other storage calls name
    their slot with one plain string literal;
  - there are no computed globals, eval or new Function;
  - the owner and the store are never imported whole or by a computed path;
  - only gateway-base builds Authorization or Bearer, or uses sendBeacon,
    XMLHttpRequest or WebSocket.
  The owner's and the store's export lists are pinned at run time.
  Self-tests run astra's two snippets, and the quickstart header line moved
  into code, through the rules, and all three are caught.

Negative controls on the real tree:
- the snippets as probe modules still type-check and now fail the ratchet;
- re-adding an exported reader fails two tests;
- unwrapping sendBeacon fails the beacon tests.

Still open, and parked with the operator: the key is persisted in
JavaScript-readable localStorage, so hostile script on this origin can read
it, or can replace a built-in the key passes through. Only an HttpOnly gateway
session takes the key out of JavaScript's reach. The ratchet is a lint for our
own modules, not a sandbox, and its header says so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ail closed, Discover lists live capabilities (astra 18b)

Each finding was first reproduced at eaedeb4 by 3a87d0b (18 cases,
R1a-R5b, all failing there). All 18 pass now.

F1 (HIGH, money): an ETH escrow showed as "$10.00 USDC", and "not-money"
showed as "$0.00 USDC".
- useEscrows now requires a canonical decimal totalAmount (no sign, exponent,
  grouping or leading zeros) and a currency the escrows table allows (USDC,
  ETH, DAI). Milestone counts, when present, are counts, and the released and
  disputed milestones are among the escrow's milestones.
- Dashboard, Escrow and Revenue pass the escrow's currency to a new
  EscrowAmount. It shows the amount digit for digit, never through a float,
  with a "$" only for USDC, and shows unavailable rather than a value. The
  shared AmountDisplay is design's (#393), and EscrowAmount follows its new
  rules, so @pcc/ui is untouched here.

F2 (MEDIUM): Discover listed the static template catalog as "capabilities
found". It now reads the capabilities operators list (GET /api/capabilities,
every page, each row validated). A read the pager capped is counted "N+",
with a note.

F3 (MEDIUM): statuses such as "bogus" were counted.
- Job, kernel and escrow statuses must be ones the gateway defines. The sets
  in api/wire-vocabulary.ts are what the gateway really writes, with a source
  for each value. That is wider than its advertised JOB_STATUSES: executing,
  evidence_submitted and settled are stored too, and a narrower set would
  turn working pages into outages.
- Capability rows need a type. queueDepth, assuranceScore and reputation must
  be in range when present.
- keyCounts needs non-negative integers, with the wildcard keys no more than
  the active ones.

F4 (MEDIUM): useAllCapabilities fails the read, and its retry starts over,
when:
- the total changes between pages;
- a page answers for another offset;
- hasMore disagrees with the page's offset, limit and total;
- an id repeats;
- more rows arrive than the total.

F5 (MEDIUM):
- The leaderboard says "Nothing to rank yet" when kernels are registered but
  list no capabilities. "No kernels" is kept for when none are registered.
- Revenue says "No completed jobs in the first 50" on a full page.

Changes to the tests:
- bravo's titles now state requirements.
- Its varying stub answers for the offset asked, so R4a and R4b test the
  total and duplicate checks rather than an offset mismatch.
- R3g checks the Active keys row: the key id "key-1234" itself contains "-1".
- The round-2 fixture's "mystery" job status is now a known inactive one,
  because an unknown status fails the read (R3a, R3b).
- New tests cover:
  - every status the gateway writes being accepted;
  - USDC keeping its "$", with every digit of a large amount;
  - DAI showing no "$";
  - malformed amounts;
  - each pager check, including a total change the other checks miss;
  - out-of-range scores;
  - more wildcard keys than active keys.
- Mutation controls: 11 mutations, each reverting one check, and every one
  fails at least one test (returns/pcc-shell-work/px3-r4-controls/).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Brings astra 18b's fixes (escrow currency and exact amounts, known statuses,
pager consistency, live Discover, scoped empty states) into #354.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d-actions

Brings #354's merge of #352 @72911cce (astra 18b's fixes).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Escrow's #462 (N79) adds refund_pending: a chain escrow whose refund is
decided but not yet executed on-chain. It is written to the escrows table,
so GET /api/escrow passes it through, and useEscrows fails the read on any
status it doesn't know. The escrow lane asked for it in #4544. It is
harmless before #462 merges and required after. The money badge already
classifies it (spec money-status REFUND_PENDING: waiting, never green). The
"every escrow status" test covers it, since it iterates the set.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Brings in master's merges since 8f94649, including #353 (job execution
read model, onIdentityChange) and the N84/A01/E-series fixes.

One conflict, in useJobs (api/hooks/use-pcc-data.ts). It keeps #352's
rowsOrThrow: an array of rows with an id and a known status, which is a
superset of master's new array check. Master's useJobExecution and
onIdentityChange come in unchanged.

Checked: tsc clean; dashboard 449/449 (20 files).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…eaching the next (these fail at 5a226a7)

astra's round-3 verdict on #354 (pack 19c, DO-NOT-SHIP) found a CRITICAL
cross-account disclosure, and two gaps around it. These three tests reproduce
each one at 5a226a7, through the store's public login() and logout() and
the real spatial ChatBar.

1. A types "account-A-secret" into the spatial chat, signs out; B signs in
   and opens Chat History. The message is still in useSpatialChatStore
   ("expected true to be false").
2. login() with a different key while already signed in (A to B, with
   isAuthenticated true throughout). A's cached job is still on the page
   right after the switch, because the cache clear follows the auth boolean.
3. Chat, sidebar, open panels and notifications, read synchronously at the
   A-to-B switch before React renders anything for B, are not back to their
   initial state.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Brings #352's refund_pending and its merge of lamasu/master 753feb4
(#353's job-execution read model and onIdentityChange, among others).

One conflict, in App.tsx: the cache-clear subscription. This takes master's
onIdentityChange(() => queryClient.clear()). It clears on a key, wallet or
SIWE session change, and replaces #354's own clear, which followed only the
isAuthenticated boolean (astra 19c). The next commit builds the 19c fix on
it.

One test follows master: /jobs/:id now waits for JobDetailPage's #353
subtitle ("What the executor reported, ..."). #353's page doesn't render
"Back to jobs" in the state a failed read leaves, so that check is dropped.
The astra-19c reproductions from e6b76c6 still fail here, as they should.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…astra 19c, CRITICAL)

astra's round-3 verdict on #354 (pack 19c, DO-NOT-SHIP) found that:
- the spatial chat store (module-level zustand) kept what account A typed
  after A signed out, and B saw it in Chat History;
- the cache clear followed the isAuthenticated boolean, so login() with
  another key while signed in kept A's cached reads.
Both are reproduced by e6b76c6.

- lib/account-scope.ts: a registry of the account's in-memory state. Every
  zustand store except auth-store (the identity itself) registers with
  accountScoped(store); that is 21 stores, among them the spatial chat, open
  panels, notifications, wizards and builders. resetAccountScopedState()
  returns each one to a fresh copy of the state it had at module load.
- stores/auth-store.ts: onAccountChange(cb) fires whenever the key changes:
  sign-in, sign-out, or another key while signed in. It follows the key, not
  the boolean.
- App.tsx: on an account change, every registered store is reset inside the
  set() that changed the key, before anything renders for the next account.
  Then the signed-in shell remounts under a new key (useSyncExternalStore
  epoch), which drops component state and gives every mounted query a fresh
  observer. Master's onIdentityChange still clears the query cache on a key,
  wallet or session change; on its own it left a mounted page showing the
  previous account's data until something re-rendered it.

Tests:
- The three reproductions from e6b76c6 now pass.
- New: B never sees what A typed into a page (Discover's search box), which
  needs the remount.
- New: __tests__/account-scope.test.ts. Every store module registers; all of
  them do at run time; a reset restores pristine state even after in-place
  mutation.
- Mutation controls 5/5 (returns/pcc-shell-work/px14-r4-controls/). Each
  removed one part (the reset, the keyed remount, the key-based trigger, the
  chat store's registration, the copy on reset), and each made at least one
  test fail.

Dashboard: tsc clean; 485/485 (24 files); vite build clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Brings in master's merges since cfe3ce9's base, including #353's job-execution
read model and its onIdentityChange (review r3 of #353): the query cache is
cleared when the signed-in identity changes.

Two conflicts, both from N50 taking the key out of the auth store:
- api/gateway.ts: keeps this PR's authorizedFetch, adds master's
  JobExecutionDTO import, and drops getAuthHeaders, which N50 removed.
  Master's getJobExecution goes through fetchAPI, which already sends with
  authorizedFetch.
- stores/auth-store.ts: master's onIdentityChange compared s.apiKey, which
  this store no longer holds. The store now has keyEpoch, a counter bumped by
  every adoptApiKey() and logout(). onIdentityChange compares keyEpoch,
  address and sessionToken. adoptApiKey counts every call as an identity
  change, even with the same key: telling "same key" from "another key"
  would make it an equality test on the stored key. logout() is now one
  set(), so a sign-out is reported once, as master's test expects.

Tests that follow:
- stores/__tests__/auth-store-identity.test.ts (master's) now signs in with
  adoptApiKey instead of writing apiKey into the store, and checks that no
  key string appears in the state.
- __tests__/no-direct-auth-headers.test.ts pins the store's exports to
  adoptApiKey, onIdentityChange and useAuthStore.

Checked: tsc clean; dashboard 439/439 (24 files).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…iterals

Four N50 test files held fake keys as pcc_test_ literals:
- no-direct-auth-headers;
- gateway-base;
- key-boundary-r4;
- setup-key-origin.
The secret scanners flag that shape: the review-pack scan, and the gate the
shell lane runs before every push. That would block #408's push, which merges
this branch. The tests now build the same values at run time, for example
["pcc", "test", "r4boundary0123456789abcdef"].join("_"). Behaviour is
unchanged.

Checked: tsc clean; dashboard 439/439.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nc results reaching B (these fail at df74d98)

astra's round-4 verdict on #354 (pack 19d, DO-NOT-SHIP) found two CRITICAL
paths. These five tests reproduce them at df74d98.

C1: A's wallet and SIWE session carry over to B. wagmi is mocked as a
connected wallet, and the gateway's SIWE cookie lives until
POST /api/auth/logout.
- After the dashboard's Disconnect (logout()) and login(B), the store still
  holds A's wallet address ("expected '0xA11ce…' to be null").
- The same after login(B) while A is still signed in.

C2: A's async work writes into B's stores.
- EvidenceExplorer: A's POST /api/zk/commit, deferred until after login(B),
  lands in useEvidenceExplorerStore.commitments.
- A store action captured during A's session (spatial chat addMessage) still
  writes after the switch.
- useSSEStream: after an error, its 3 s reconnect opens a new EventSource
  even though the component has unmounted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ync work can't refill the next account's stores (astra 19d, CRITICAL x2)

astra's round-4 verdict on #354 (pack 19d, DO-NOT-SHIP) found two CRITICAL
paths, reproduced at df74d98 by 3bdd851.

C1: A's wallet and SIWE session came back for B. wagmi and the gateway's SIWE
cookie live above the account boundary, so the next account's ConnectWallet
re-adopted them.
- lib/wallet-session.ts endWalletSession():
  - disconnects every wagmi connection, with a 3 s bound so a hung wallet
    can't wedge the switch;
  - empties wagmi's cache;
  - has the gateway destroy the SIWE cookie (POST /api/auth/logout, no API
    key, no Authorization header, 8 s bound).
  It is true only when the gateway confirms.
- App.tsx: on an account change (onAccountChange), the stores reset and the
  wallet fields leave the auth store, inside the set() that changed the key.
  The signed-in shell then unmounts, and mounts again for the new account
  only when endWalletSession() confirms. Until then the page says "Signing
  out of the previous account…". If the gateway can't confirm, nothing of the
  next account loads (fail closed), and "Try again" reruns it. A run counter
  stops a slower, superseded teardown from settling the boundary.
- ConnectWallet: results that arrive after it unmounts are dropped, namely
  the /api/auth/me check and the SIWE sign-in. A verify that completes after
  the switch immediately logs the cookie it just got back out.

C2: an async result from A reached B's stores.
- lib/account-scope.ts: each registered store's actions are bound to the
  account epoch. An action reference read under the previous account does
  nothing; only actions read after the switch write. A closure kept by A's
  component, such as EvidenceExplorer's addCommitment after
  POST /api/zk/commit, can no longer refill B's store.
- hooks/use-sse-stream.ts: unmounting cancels a pending reconnect, closes the
  latest stream (a reconnect may have replaced the first), and blocks any
  further connect.
- account-scope.test.ts:
  - only reviewed, synchronous modules may call getState() or setState() on
    an account store, since such a call reads the current account's actions;
  - a stale action is a no-op.

Tests:
- The five reproductions pass.
- New tests:
  - B's shell neither mounts nor asks the gateway anything until A's cookie
    is gone, and the store holds none of A's wallet while the switch is
    pending;
  - A's late /api/auth/me answer is dropped;
  - on /app, B starts with no wallet.
- The reproductions now connect A's wallet after A's own sign-in has settled,
  because signing A in ends any earlier wallet session by design.
- The routing tests' stubs answer POST /api/auth/logout, as the gateway does.
- Mutation controls 6/6 (returns/pcc-shell-work/px14-r5-controls/):
  - unbinding actions from the epoch;
  - letting the SSE hook reconnect after unmount;
  - skipping the teardown;
  - not holding the shell;
  - keeping a late session check;
  - keeping the wallet fields.
  Each makes at least one test fail.

Dashboard: tsc clean; 495/495 (25 files); vite build clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…-actions

#413 carried #368's round-3 auth (cfe3ce9). This brings round 4:
- no export returns the key;
- the sendBeacon guard;
- the exemption-free lint;
- the keyEpoch onIdentityChange;
- master 753feb4, through #368's own merge.

Conflicts:
- App.tsx: master's onIdentityChange replaces the boolean-keyed cache
  clear.
- use-pcc-data.ts: useJobs keeps rowsOrThrow.

An intermediate step: routing.test.tsx still signs in with
useAuthStore.setState({ apiKey }), which #368's store no longer has (RC2
#4101 item 1), so 12 of its tests fail here. The next merge (#354 @d8ff4eb2)
brings their current versions, which the next commit adapts to #368's key
API.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d-actions

Brings #354's rounds 4 and 5 of account isolation (astra 19c and 19d), with
#352 @2311bf36:
- every account-scoped store resets, and its actions are bound to the
  account epoch;
- the wallet and SIWE session end before the next account's shell mounts,
  failing closed;
- the SSE hook cancels its reconnect.

This is the first branch where #354's account boundary meets #368's key
boundary (RC2 #4101 item 1). Conflicts:
- stores/auth-store.ts: #354's onAccountChange compared s.apiKey, which
  #368's store doesn't hold. It now follows keyEpoch, which every
  adoptApiKey() and logout() bumps. #368 removed getAuthHeaders, and it
  stays out.
- App.tsx: takes #354's account boundary whole. It sits on #368's keyEpoch
  through onAccountChange and onIdentityChange.

Tests follow #368's key API:
- account-isolation-r5 signs A in with adoptApiKey("pcc_test_key"), not
  useAuthStore.setState({ apiKey }). routing.test.tsx already used
  adoptApiKey here.
- #368's ratchet pins the store's exports to adoptApiKey, onAccountChange,
  onIdentityChange and useAuthStore.

Checked: tsc clean; dashboard 591/591 (33 files). That includes #354's
isolation tests and #368's key-boundary ratchet over #354's new code.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

1 participant