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.ts—parseAnalysisRequest/normalizeContractAddress/isValidContractAddress/analysisRequestTone/analysisRequestLabel.- Hooks:
web/src/lib/hooks/disclosure/useAnalysisRequests.ts—useRequestAnalysis(insert mutation) +useAnalysisRequest(address)(watch latest request, poll, auto-invalidate ondone).- UI:
AnalyzeContractBox(paste-address view) +AnalyzeControl(inDisclosureDetailSheet’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 flipsstatusviaservice_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:
- Operator pastes a contract address (or clicks Analyze / Re-analyze on a contract’s detail sheet).
- CRM inserts a
pendingrow intopublic.bsc_analysis_request. - A VM worker drains the queue: Slither-judge if the contract already has findings, else an open-ended Claude source audit.
- On completion the worker upserts the verdict into
bsc_contract_analysis(joined oncontract_address) and flips the requeststatustodone. - 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):
| Column | Notes |
|---|---|
id | uuid pk, gen_random_uuid() |
created_at | timestamptz default now() |
chain | text, default 'bsc', NOT NULL |
contract_address | text, NOT NULL |
status | text, default 'pending', NOT NULL — lifecycle `pending → running → done |
error | text null — raw worker error text |
requested_by | text null |
analyzed_at | timestamptz null |
claimed_at | timestamptz null (worker sets on claim) |
RLS policies (confirmed live)
bsc_analysis_request_auth_read— SELECT,authenticated,USING (true).bsc_analysis_request_auth_insert— INSERT,authenticated,WITH CHECK (true).- NO update/delete for
authenticated— the worker flipsstatusviaservice_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 existingparseContractAnalysishelpers).normalizeContractAddress— trims + lowercases (the normalized address is what’s inserted, so re-analysis dedupes cleanly).isValidContractAddress—0x+ 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,sonnertoast. This is the only write.useAnalysisRequest(address)— watches the latest request row for a contract:- Polls every 8s while
pending/running. - Stops on
done/errorAND 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.
- Polls every 8s while
3. UI
AnalyzeContractBox— the standalone Analyze view: a paste-address box that validates0x+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 ofDisclosureDetailSheet, 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
statusvalue 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
errortext from the worker is truncated in the chip with atitleattribute 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@deploywork). A queued request therefore currently sits atpending— the CRM side (insert + watch + 15-min cap) works, but thedone→ refetch → verdict-panel-lights-up loop hasn’t been observed end-to-end. Asked@bscvuln(over cross-agent-handoff-channel) to seed adonerow 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, andnormalizeContractAddress(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.
Related
- vuln-disclosure-tracker — the parent /disclosure feature this extends; shares the domain file, hooks dir, and detail sheet
- cross-agent-handoff-channel — the
CRM_AGENT_HANDOFF.mdchannel + guardrail this request came through - trader-scout-dashboard — the
as neveruntyped-table +authenticated-RLS-read precedent - tech-debt — the
database.types.tsregen / drop-as neverfollow-up - integrations — CRM external integrations index
- agent-context-crm — repo conventions (hooks, mutation patterns)