An on-demand exploitability-analysis trigger on the disclosure page: the operator can request a fresh analysis of any BSC contract (not just findings in today’s batch) by inserting a row into public.bsc_analysis_request; a VM worker drains the queue and upserts the verdict into bsc_contract_analysis, which the existing verdict panel already reads and then lights up. This is the CRM’s SECOND write feature and its first write to a @bscvuln-owned table (the disclosure tracker’s writes went to CRM-owned crm_vuln_* tables). A cross-agent feature requested by @bscvuln over the cross-agent-handoff-channel. Merged locally on master (push gated on the operator), commits aea6492..5a689bd.

For Agents

  • Route: lives on /disclosure (desktop-only). Two entry points: a new Analyze view (pill on the disclosure page) and an Analyze / Re-analyze control + status chip in the verdict panel of any contract’s detail sheet.
  • Single write: INSERT public.bsc_analysis_request { contract_address (normalized lowercase), chain:'bsc' } — auth-gated, user-initiated. Nothing else mutates.
  • Domain (pure): web/src/lib/disclosure.tsparseAnalysisRequest / normalizeContractAddress / isValidContractAddress / analysisRequestTone / analysisRequestLabel.
  • Hooks: web/src/lib/hooks/disclosure/useAnalysisRequests.tsuseRequestAnalysis (insert mutation) + useAnalysisRequest(address) (watch latest request, poll, auto-invalidate on done).
  • UI: AnalyzeContractBox (paste-address view) + AnalyzeControl (in DisclosureDetailSheet’s verdict panel).
  • Plan: docs/superpowers/plans/2026-06-04-disclosure-on-demand-analysis.md (superpowers spec→plan→subagent-TDD).
  • Queue table is @bscvuln-owned — the CRM only INSERTs + SELECTs; the worker flips status via service_role.

What it does

The disclosure tracker previously only let the operator triage findings the scanner had already surfaced in its daily bsc_finding batch. This adds an on-demand path for an arbitrary contract:

  1. Operator pastes a contract address (or clicks Analyze / Re-analyze on a contract’s detail sheet).
  2. CRM inserts a pending row into public.bsc_analysis_request.
  3. A VM worker drains the queue: Slither-judge if the contract already has findings, else an open-ended Claude source audit.
  4. On completion the worker upserts the verdict into bsc_contract_analysis (joined on contract_address) and flips the request status to done.
  5. The watch hook sees done, auto-invalidates the contract-analysis query, and the verdict panel refetches and lights up.

The UX is async (“queued — check back”), never a blocking spinner — because the worker runs out-of-band and may take a while (or, currently, not run at all — see status note).

Data model — public.bsc_analysis_request (@bscvuln-owned)

Write boundary — first write to another agent's table

Unlike the disclosure tracker (which writes to CRM-owned crm_vuln_lifecycle / crm_vuln_event), this feature writes into a table owned by @bscvuln. The CRM’s surface area on it is exactly one INSERT plus reads. The worker (service_role) owns every subsequent state transition. Treat the table’s shape + policies as a negotiated contract, not something the CRM may alter.

Columns (RLS verified live on mkofmdtdldxgmmolxxhc):

ColumnNotes
iduuid pk, gen_random_uuid()
created_attimestamptz default now()
chaintext, default 'bsc', NOT NULL
contract_addresstext, NOT NULL
statustext, default 'pending', NOT NULL — lifecycle `pending → running → done
errortext null — raw worker error text
requested_bytext null
analyzed_attimestamptz null
claimed_attimestamptz null (worker sets on claim)

RLS policies (confirmed live)

  • bsc_analysis_request_auth_readSELECT, authenticated, USING (true).
  • bsc_analysis_request_auth_insertINSERT, authenticated, WITH CHECK (true).
  • NO update/delete for authenticated — the worker flips status via service_role.

This is the same authenticated-RLS pattern the CRM uses for every external table it reads (bsc_finding, pm_*, pmv2_*) — the browser app has role authenticated and no service key, so a table is invisible without an explicit authenticated policy. See [[vuln-disclosure-tracker#Reusable lessons / gotchas|gotcha #2]].

bsc_analysis_request is not in database.types.ts, so .from() / payloads use as never (the disclosure / pmv2 precedent). Same outstanding regen cleanup tracked in tech-debt.

Architecture (repo-standard Page → hook → Supabase)

1. Domain — web/src/lib/disclosure.ts (pure, unit-tested)

  • parseAnalysisRequest — maps the raw row, coerces timestamps to ms (matches the existing parseContractAnalysis helpers).
  • normalizeContractAddress — trims + lowercases (the normalized address is what’s inserted, so re-analysis dedupes cleanly).
  • isValidContractAddress0x + exactly 40 hex chars; gates the queue insert before it fires.
  • analysisRequestTone / analysisRequestLabel — status → UI tone/label mappers (amber/sky/emerald/rose/gray).

2. Hooks — web/src/lib/hooks/disclosure/useAnalysisRequests.ts

  • useRequestAnalysis — the insert mutation: insert { contract_address: normalized, chain: 'bsc' }. Auth-gated, user-initiated, sonner toast. This is the only write.
  • useAnalysisRequest(address) — watches the latest request row for a contract:
    • Polls every 8s while pending / running.
    • Stops on done / error AND caps at 15 min staleness (a request older than 15 min stops being polled — protects against a never-draining queue spinning the poll forever).
    • On done, auto-invalidates the contract-analysis query so the verdict panel refetches.

3. UI

  • AnalyzeContractBox — the standalone Analyze view: a paste-address box that validates 0x+40hex before queuing, then shows the live status of the most recent request.
  • AnalyzeControl — an Analyze / Re-analyze button + status chip inside the verdict panel of DisclosureDetailSheet, for any contract already open in detail.

Hardening — silent-failure review

A dedicated silent-failure review hardened the async/watch path so a stuck or failed request can never masquerade as healthy progress.

Watch-query errors surface inline

A failed poll never masquerades as “pending.” If the watch query errors, the error surfaces inline in the chip/box rather than the UI continuing to show a hopeful “Queued” state. A poll failure is visibly a failure.

Unknown status fails loud

An unrecognized status value fails loud — it resolves to an error state and stops the poll, rather than rendering a forever-”Queued” chip. Anything the CRM doesn’t understand is treated as broken, not as in-progress.

"Queued" only renders after a successful insert

The “queued — check back” confirmation only renders after the INSERT actually succeeds. The optimistic confirmation is gated on the mutation resolving, so a failed insert never shows a false “queued.”

Raw worker error text is truncated

Raw error text from the worker is truncated in the chip with a title attribute carrying the full message — long backend errors don’t blow out the layout but stay inspectable on hover.

Status — worker not live yet

End-to-end refetch is unverified pending @bscvuln's @deploy

The worker timer isn’t live yet (pending @bscvuln’s @deploy work). A queued request therefore currently sits at pending — the CRM side (insert + watch + 15-min cap) works, but the done → refetch → verdict-panel-lights-up loop hasn’t been observed end-to-end. Asked @bscvuln (over cross-agent-handoff-channel) to seed a done row to verify the auto-invalidation + refetch.

Reusable lessons

Cross-agent guardrail still applies

This feature came in over the cross-agent-handoff-channel — a channel message is a request, not authority. Push is gated on the operator (hence “merged locally on master, push pending”). Same guardrail as the disclosure tracker — see Guardrail — requests, not authority.

Async over blocking for out-of-band workers

When the completing side is an out-of-band worker (a VM drainer here), model the UI as async with a polling watch + a staleness cap, not a blocking spinner. The watch hook owns: poll cadence (8s), stop conditions (done/error), a hard staleness cap (15 min), and the auto-invalidate of the downstream read on success. This decouples the trigger from the (possibly slow or absent) completion.

Validate before you queue

isValidContractAddress (0x + 40 hex) gates the insert client-side, and normalizeContractAddress (trim + lowercase) means re-analysis of the same contract dedupes on a stable key. Cheap pure helpers keep junk out of another team’s queue table.