docs: add WV-07 and WV-08 specs for proving machine in webview-app (#1861)

WV-07 covers SelfClient assembly: exporting useProvingStore from the
browser entry point, mapping bridge adapters to SDK interfaces, creating
a keychain-backed DocumentsAdapter via the existing secureStorage bridge,
and wiring a real SelfClient in the webview-app provider.

WV-08 covers the tunnel proving flow: replacing the mock 3-second timer
with real provingMachine integration (register → disclose), storing
Sumsub KYC results as KycData documents in native keychain, and driving
UI from proving state transitions.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Seshanth.S
2026-03-24 17:51:38 +05:30
committed by GitHub
parent bd2e3c6738
commit 3eae9dc777
3 changed files with 583 additions and 0 deletions

View File

@@ -52,6 +52,8 @@ On **March 11, 2026**, the active SDK scope changed to **WebView only, with no c
| WV-04 | Define the host callback contract for launch, dismiss, and final result without native modules | Done | Medium | WV-02 | [plans/WV-04-host-callback-contract.md](./plans/WV-04-host-callback-contract.md) | Browser host fallback now uses `postMessage` for iframe/popup embedding while native transports keep their current behavior |
| WV-05 | Integrate KYC provider Web SDK into ProviderLaunchScreen (Sumsub as default) | In Progress | High | WV-02 | [plans/WV-05-sumsub-web-sdk.md](./plans/WV-05-sumsub-web-sdk.md) | Code complete on `feat/webview-sdk`, needs testing |
| WV-06 | Wire KYC result through verification pipeline to host lifecycle callback | Ready | High | WV-05 | [plans/WV-06-kyc-result-flow.md](./plans/WV-06-kyc-result-flow.md) | Sumsub result → kycResultStore → ConfirmIdentificationScreen → lifecycle.setResult() |
| WV-07 | SelfClient assembly and proving machine export for WebView | Ready | High | SC-03 | [plans/WV-07-selfclient-proving-assembly.md](./plans/WV-07-selfclient-proving-assembly.md) | Export useProvingStore, map bridge→SDK adapters, keychain-backed documents, create real SelfClient |
| WV-08 | Wire tunnel flow with real proving machine (register → disclose) | Ready | High | WV-07 | [plans/WV-08-tunnel-proving-flow.md](./plans/WV-08-tunnel-proving-flow.md) | Replace mock tunnel proving with real provingMachine: Sumsub → store doc → prove → disclose → result |
Allowed statuses: `Ready`, `In Progress`, `Blocked`, `Deferred`, `Done`
@@ -65,6 +67,8 @@ Allowed statuses: `Ready`, `In Progress`, `Blocked`, `Deferred`, `Done`
| [plans/WV-04-host-callback-contract.md](./plans/WV-04-host-callback-contract.md) | WV-04 | Done |
| [plans/WV-05-sumsub-web-sdk.md](./plans/WV-05-sumsub-web-sdk.md) | WV-05 | In Progress (code complete, needs testing) |
| [plans/WV-06-kyc-result-flow.md](./plans/WV-06-kyc-result-flow.md) | WV-06 | Ready |
| [plans/WV-07-selfclient-proving-assembly.md](./plans/WV-07-selfclient-proving-assembly.md) | WV-07 | Ready |
| [plans/WV-08-tunnel-proving-flow.md](./plans/WV-08-tunnel-proving-flow.md) | WV-08 | Ready |
## Completion Checklist

View File

@@ -0,0 +1,317 @@
# WV-07: SelfClient Assembly & Proving Machine Export for WebView
> Last updated: 2026-03-24
> Status: Ready
> Priority: High
> Depends on: SC-03 (Ready — creates `createWebNetworkAdapter()`)
- Workstream: webview
- Backlog ID: WV-07
- Owner: TBD
- Branch: TBD
- PR: TBD
## Why
The webview-app needs to run the proving machine for ZK proof generation, but
today it has no `SelfClient` — only raw bridge adapters with incompatible
interfaces. The proving machine (`useProvingStore`) is not exported from the
SDK's browser entry point, and no adapter mapping exists to bridge the gap
between `BridgeCryptoAdapter` / `BridgeStorageAdapter` and the SDK's
`CryptoAdapter` / `DocumentsAdapter` interfaces.
This spec wires the missing pieces so WV-08 can drive the proving machine from
the tunnel flow.
## What You Will Do
### 1. Export proving machine from browser entry point
**File:** `packages/mobile-sdk-alpha/src/browser.ts`
The proving machine is already browser-compatible (zero RN imports). Add value
exports alongside the existing type-only exports:
```typescript
// Existing (keep):
export type { ProvingStateType, provingMachineCircuitType } from './proving/provingMachine';
// Add:
export type { ProvingState } from './proving/provingMachine';
export { useProvingStore, getPostVerificationRoute } from './proving/provingMachine';
```
Additive-only — no RN regression risk.
### 2. Create a keychain-backed DocumentsAdapter
**Create:** `packages/webview-bridge/src/adapters/keychain-documents.ts`
The SDK's `DocumentsAdapter` interface requires structured document CRUD. The
webview-app must persist passport data in the native keychain (security
boundary), not IndexedDB. Use the existing `secureStorage` bridge domain.
```typescript
import type { WebViewBridge } from '../bridge';
import type { DocumentsAdapter, DocumentCatalog, IDDocument } from '@selfxyz/mobile-sdk-alpha';
const CATALOG_KEY = 'self_document_catalog';
const DOC_PREFIX = 'self_doc_';
export function createKeychainDocumentsAdapter(bridge: WebViewBridge): DocumentsAdapter {
async function storageGet(key: string): Promise<string | null> {
const result = await bridge.request<{ value: string | null }>(
'secureStorage', 'get', { key },
);
return result?.value ?? null;
}
async function storageSet(key: string, value: string): Promise<void> {
await bridge.request('secureStorage', 'set', { key, value });
}
async function storageRemove(key: string): Promise<void> {
await bridge.request('secureStorage', 'remove', { key });
}
return {
async loadDocumentCatalog(): Promise<DocumentCatalog> {
const raw = await storageGet(CATALOG_KEY);
return raw ? JSON.parse(raw) : { documents: [], selectedId: null };
},
async saveDocumentCatalog(catalog: DocumentCatalog): Promise<void> {
await storageSet(CATALOG_KEY, JSON.stringify(catalog));
},
async loadDocumentById(id: string): Promise<IDDocument | null> {
const raw = await storageGet(`${DOC_PREFIX}${id}`);
return raw ? JSON.parse(raw) : null;
},
async saveDocument(id: string, doc: IDDocument): Promise<void> {
await storageSet(`${DOC_PREFIX}${id}`, JSON.stringify(doc));
},
async deleteDocument(id: string): Promise<void> {
await storageRemove(`${DOC_PREFIX}${id}`);
},
};
}
```
Export from `packages/webview-bridge/src/adapters/index.ts` barrel.
### 3. Create SDK adapter mapping functions
**Create:** `packages/webview-bridge/src/adapters/sdk-adapter-map.ts`
Bridge adapters have different interfaces from SDK adapters. Create thin mapping
functions. The bridge crypto adapter already implements the right methods
(`hash`, `sign`, `generateKey`, `getPublicKey`) — it just needs type coercion.
```typescript
import type { WebViewBridge } from '../bridge';
import type {
Adapters,
CryptoAdapter,
AuthAdapter,
NavigationAdapter,
NetworkAdapter,
NFCScannerAdapter,
} from '@selfxyz/mobile-sdk-alpha';
import { bridgeCryptoAdapter } from './crypto';
import { bridgeAuthAdapter } from './auth';
import { createKeychainDocumentsAdapter } from './keychain-documents';
import {
createWebAnalyticsAdapter,
createWebNetworkAdapter, // from SC-03
webNFCScannerShim,
} from '@selfxyz/mobile-sdk-alpha/browser';
export interface CreateSdkAdaptersOpts {
bridge: WebViewBridge;
navigate: (path: string) => void;
goBack: () => void;
}
export function createSdkAdapters(opts: CreateSdkAdaptersOpts): Adapters {
const { bridge, navigate, goBack } = opts;
const bridgeCrypto = bridgeCryptoAdapter(bridge);
const crypto: CryptoAdapter = {
hash: bridgeCrypto.hash,
sign: bridgeCrypto.sign,
generateKey: bridgeCrypto.generateKey,
getPublicKey: bridgeCrypto.getPublicKey,
};
const bridgeAuth = bridgeAuthAdapter(bridge);
const auth: AuthAdapter = {
getPrivateKey: bridgeAuth.getPrivateKey,
};
const navigation: NavigationAdapter = {
goBack,
goTo: (routeName, params) => {
const query = params ? `?${new URLSearchParams(params as Record<string, string>)}` : '';
navigate(`/${routeName}${query}`);
},
};
return {
scanner: webNFCScannerShim(),
crypto,
network: createWebNetworkAdapter(),
auth,
documents: createKeychainDocumentsAdapter(bridge),
navigation,
analytics: createWebAnalyticsAdapter(),
};
}
```
Export from `packages/webview-bridge/src/adapters/index.ts` barrel.
### 4. Replace SelfClientProvider with real SelfClient
**File:** `packages/webview-app/src/providers/SelfClientProvider.tsx`
Replace the current `SelfClientAdapters` bag-of-adapters with a real
`SelfClient` instance created via `createSelfClient()`.
```typescript
import { createSelfClient, createListenersMap } from '@selfxyz/mobile-sdk-alpha/browser';
import { createSdkAdapters } from '@selfxyz/webview-bridge/adapters';
// Replace SelfClientAdapters type with SelfClient from SDK
// Keep lifecycle, haptic, biometrics as supplementary adapters
// (they are WebView-specific and not part of SDK Adapters interface)
export interface WebViewAdapters {
client: SelfClient; // real SDK client with provingMachine access
lifecycle: BridgeLifecycleAdapter;
haptic: BridgeHapticAdapter;
biometrics: BridgeBiometricsAdapter;
}
```
The `SelfClient` instance gives webview-app access to:
- `client.useProvingStore` — Zustand hook for proving state
- `client.getProvingState()` — snapshot accessor
- `client.emit()` / `client.on()` — event system
- All internal stores and adapters the proving machine needs
### 5. Add dependencies and Buffer polyfill to webview-app
**File:** `packages/webview-app/package.json`
Add packages externalized by the SDK's tsup build:
| Package | Version | Why |
|--------------------|---------|----------------------------------|
| `socket.io-client` | ^4.8.3 | TEE status WebSocket listener |
| `xstate` | ^5.20.2 | Internal state machine |
| `node-forge` | ^1.3.3 | AES-256-GCM encryption |
| `buffer` | ^6.0.3 | Node.js Buffer polyfill |
| `elliptic` | ^6.5.4 | Crypto ops via @selfxyz/common |
`zustand` and `@selfxyz/common` are already deps.
**File:** `packages/webview-app/src/main.tsx`
Add at the very top (before any other imports):
```typescript
import { Buffer } from 'buffer';
globalThis.Buffer = Buffer;
```
The proving machine uses `Buffer` at lines 196, 219, 567, 606, 826.
### 6. Update SelfClientProvider to pass config
**File:** `packages/webview-app/src/providers/SelfClientProvider.tsx`
Pass required config fields to `createSelfClient()`:
```typescript
const client = useMemo(() => {
const adapters = createSdkAdapters({ bridge, navigate, goBack: () => navigate(-1) });
const listeners = createListenersMap();
return createSelfClient({
config: {
platform: 'webview',
debug: import.meta.env.DEV,
env: verificationRequest?.env ?? 'prod',
},
adapters,
listeners,
});
}, [bridge, navigate]);
```
## Files You Will Create
| File | What | Risk |
|-----------------------------------------------------------|---------------------------------------------------|----------|
| `packages/webview-bridge/src/adapters/keychain-documents.ts` | Keychain-backed DocumentsAdapter via secureStorage | **Low** |
| `packages/webview-bridge/src/adapters/sdk-adapter-map.ts` | Bridge→SDK adapter mapping + factory | **Low** |
## Files You Will Modify
| File | Change | Risk |
|--------------------------------------------------------------|------------------------------------------------------|------------|
| `packages/mobile-sdk-alpha/src/browser.ts` | Add `useProvingStore`, `ProvingState` exports | **Low** |
| `packages/webview-bridge/src/adapters/index.ts` | Add barrel exports for new adapters | **Low** |
| `packages/webview-app/src/providers/SelfClientProvider.tsx` | Replace adapter bag with real SelfClient | **Medium** |
| `packages/webview-app/package.json` | Add 5 dependencies | **Low** |
| `packages/webview-app/src/main.tsx` | Add Buffer polyfill (2 lines) | **Low** |
| `specs/projects/sdk/workstreams/webview/SPEC.md` | Add WV-07 to backlog | **None** |
## Files You Will NOT Modify
| File | Why |
|---------------------------------------------------------------|--------------------------------------------------------------|
| `packages/mobile-sdk-alpha/src/proving/provingMachine.ts` | Engine is already browser-compatible; no changes needed |
| `packages/mobile-sdk-alpha/src/client.ts` | `createSelfClient()` factory is correct as-is |
| `packages/native-shell-android/**` | secureStorage handler already exists and handles JSON values |
| `packages/native-shell-ios/**` | secureStorage handler already exists and handles JSON values |
| `packages/webview-app/src/screens/**` | Screen wiring is WV-08 scope |
## Constraints
- **No regressions in the RN app.** Browser.ts exports are additive. No
existing exports change.
- **Keychain is always native-managed.** Documents go through secureStorage
bridge to native keychain. No IndexedDB fallback for passport data.
- **Bridge protocol unchanged.** No new bridge domains. Uses existing
`secureStorage` domain for document persistence.
- **Don't wire screens.** This spec creates the SelfClient and makes
provingMachine accessible. WV-08 wires screens to it.
- **SC-03 must land first.** `createWebNetworkAdapter()` is imported by the
adapter mapping layer.
## Validation
```bash
# SDK types still pass (additive exports only)
cd packages/mobile-sdk-alpha && yarn types
# Bridge builds
cd packages/webview-bridge && yarn build
# webview-app builds with new imports
cd packages/webview-app && yarn build
```
## Definition of Done
- [ ] `useProvingStore` and `ProvingState` exported from `browser.ts`
- [ ] `createKeychainDocumentsAdapter()` persists documents via secureStorage bridge
- [ ] `createSdkAdapters()` maps bridge adapters to SDK Adapters interface
- [ ] `SelfClientProvider` creates a real `SelfClient` via `createSelfClient()`
- [ ] Buffer polyfill added to webview-app entry point
- [ ] `yarn types` clean in mobile-sdk-alpha
- [ ] `yarn build` clean in webview-bridge and webview-app
- [ ] Backlog row added in SPEC.md
## Status Log
- 2026-03-24: Plan created.

View File

@@ -0,0 +1,262 @@
# WV-08: Wire Tunnel Flow with Real Proving Machine
> Last updated: 2026-03-24
> Status: Ready
> Priority: High
> Depends on: WV-07 (Ready — SelfClient assembly + proving machine export)
- Workstream: webview
- Backlog ID: WV-08
- Owner: TBD
- Branch: TBD
- PR: TBD
## Why
The tunnel flow is currently a pure UI mockup — hardcoded data, a 3-second
timer for "proving," and no real backend interaction. With WV-07 landing
`SelfClient` and `useProvingStore` in webview-app, the tunnel flow can drive
real ZK proof generation.
The tunnel flow is the first end-to-end integration: **Sumsub → store document
in keychain → provingMachine (register) → disclose → lifecycle.setResult()**.
Getting this working validates the entire WebView proving pipeline.
## Prerequisites
- **WV-07 done** — `SelfClient` available in webview-app, `useProvingStore`
accessible, keychain-backed documents adapter working
- **WV-05 done** — Sumsub Web SDK integrated in `ProviderLaunchScreen`
- **WV-06 done** — KYC result normalization into `KycProviderResult`
## What You Will Do
### 1. Wire Sumsub result → document storage
**File:** `packages/webview-app/src/screens/tunnel/` (new or modified screen)
After Sumsub returns a successful `KycProviderResult`:
1. Extract attestation data (`serializedApplicantInfo`, `signature`, `pubkey`)
2. Transform into an `IDDocument` (using `@selfxyz/common` parsing utilities)
3. Call `storePassportData(selfClient, document)` — this persists to native
keychain via the `createKeychainDocumentsAdapter` from WV-07
The KYC provider result contains the fields the circuit needs: country, idType,
idNumber, issuanceDate, expiryDate, fullName, dob, photoHash, phoneNumber,
gender, address.
### 2. Replace mock TunnelProvingScreen with real proving
**File:** `packages/webview-app/src/screens/tunnel/TunnelProvingScreen.tsx`
Replace the 3-second timer mock with real proving machine integration:
```typescript
import { useProvingStore } from '@selfxyz/mobile-sdk-alpha/browser';
import { useSelfClient } from '../../providers/SelfClientProvider';
export const TunnelProvingScreen: React.FC = () => {
const { client } = useSelfClient();
const currentState = useProvingStore(s => s.currentState);
const init = useProvingStore(s => s.init);
const error_code = useProvingStore(s => s.error_code);
const reason = useProvingStore(s => s.reason);
useEffect(() => {
// Init proving machine with 'register' circuit type
init(client, 'register');
}, [client, init]);
// Drive ProofGenerationScreen UI from currentState
// idle → parsing_id_document → fetching_data → validating_document
// → init_tee_connexion → ready_to_prove → proving → post_proving → completed
};
```
**State → UI mapping:**
| provingMachine state | UI shown |
|--------------------------|--------------------------------------------------|
| `idle` | Loading spinner |
| `parsing_id_document` | "Preparing document..." |
| `fetching_data` | "Fetching verification data..." |
| `validating_document` | "Validating document..." |
| `init_tee_connexion` | "Connecting to prover..." |
| `ready_to_prove` | "Ready" (auto-confirm or user confirms) |
| `proving` | "Generating proof..." (with Euclid animation) |
| `post_proving` | "Finalizing..." |
| `completed` | Navigate to result screen (success) |
| `error` / `failure` | Navigate to result screen (error with code) |
| `passport_not_supported` | Show unsupported document error |
| `passport_data_not_found`| Show missing document error |
### 3. Wire disclose flow after register
**File:** `packages/webview-app/src/screens/tunnel/TunnelProvingScreen.tsx`
The tunnel flow runs register → disclose in sequence. After `completed` state
for register:
1. Listen for `completed` state on register circuit
2. Re-init proving machine with `'disclose'` circuit type
3. Show disclosure proving UI
4. On disclose `completed`, navigate to result screen
Use `useProvingStore` subscription to detect state transitions:
```typescript
useEffect(() => {
if (currentState === 'completed' && circuitPhase === 'register') {
// Register done, start disclose
setCircuitPhase('disclose');
init(client, 'disclose');
} else if (currentState === 'completed' && circuitPhase === 'disclose') {
// Both done, navigate to result
navigate('/tunnel/proof/result', { state: { success: true } });
} else if (currentState === 'error' || currentState === 'failure') {
navigate('/tunnel/proof/result', {
state: { success: false, error_code, reason },
});
}
}, [currentState, circuitPhase]);
```
### 4. Update TunnelResultScreen with real result
**File:** `packages/webview-app/src/screens/tunnel/TunnelResultScreen.tsx`
Replace hardcoded success with navigation state from proving:
- Success: show "Identity Verified" with Euclid StatusState
- Failure: show error with code and reason, offer retry
- On "Continue": call `lifecycle.setResult()` then `lifecycle.dismiss()`
### 5. Wire tunnel route sequence
**File:** `packages/webview-app/src/App.tsx`
Update tunnel routes if needed. The flow becomes:
```
/tunnel/tour/:step
→ /tunnel/kyc
→ /tunnel/registration/country
→ /tunnel/registration/id-type
→ /tunnel/provider (Sumsub — from WV-05)
→ /tunnel/provider-result (normalize KYC result — from WV-06)
→ /tunnel/proof/generating (real provingMachine — this spec)
→ /tunnel/proof/result (real result — this spec)
```
### 6. Handle user confirmation gate
**File:** `packages/webview-app/src/screens/tunnel/TunnelProofReceiptScreen.tsx`
The proving machine has a `ready_to_prove` state where it waits for user
confirmation. Wire the receipt screen's "Verify" button to call
`useProvingStore.getState().setUserConfirmed(client)` so the proving machine
transitions from `ready_to_prove``proving`.
Alternatively, pass `userConfirmed: true` to `init()` to auto-confirm in the
tunnel flow.
## Files You Will Modify
| File | Change | Risk |
|----------------------------------------------------------------------------|-------------------------------------------------|------------|
| `packages/webview-app/src/screens/tunnel/TunnelProvingScreen.tsx` | Replace mock with real provingMachine | **Medium** |
| `packages/webview-app/src/screens/tunnel/TunnelResultScreen.tsx` | Wire real result from proving state | **Low** |
| `packages/webview-app/src/screens/tunnel/TunnelProofReceiptScreen.tsx` | Wire user confirmation to provingMachine | **Low** |
| `packages/webview-app/src/App.tsx` | Update tunnel routes if needed | **Low** |
| `specs/projects/sdk/workstreams/webview/SPEC.md` | Add WV-08 to backlog | **None** |
## Files You Will NOT Modify
| File | Why |
|---------------------------------------------------------------|--------------------------------------------------------|
| `packages/mobile-sdk-alpha/src/proving/provingMachine.ts` | Engine unchanged — consumed as-is |
| `packages/webview-bridge/**` | Bridge layer unchanged — WV-07 already handled mapping |
| `packages/native-shell-android/**` | No new native handlers needed |
| `packages/native-shell-ios/**` | No new native handlers needed |
| `packages/webview-app/src/screens/proving/ProvingScreen.tsx` | Non-tunnel proving screen — separate concern |
| `packages/webview-app/src/providers/SelfClientProvider.tsx` | Already wired in WV-07 |
## Files You May Create
| File | What |
|-----------------------------------------------------------------------|---------------------------------------------------|
| `packages/webview-app/src/hooks/useProvingFlow.ts` | Optional: shared hook for register→disclose chain |
| `packages/webview-app/src/screens/tunnel/TunnelProviderScreen.tsx` | If tunnel needs its own Sumsub launch screen |
## Constraints
- **No provingMachine changes.** The engine is consumed as-is from
mobile-sdk-alpha. If behavior doesn't match expectations, file a separate
SDK Core issue.
- **Tunnel flow is the proving integration point.** Do not change the
non-tunnel `ProvingScreen` — that follows a different flow (external app
requests proof of an already-registered document).
- **Register then disclose.** The tunnel flow always runs both circuits in
sequence. Register creates the on-chain identity, disclose proves specific
attributes to the requesting party.
- **Sumsub provides the document.** NFC scanning is not part of this flow.
The `IDDocument` is constructed from Sumsub's KYC attestation, not from
passport chip data.
## Resolved Questions
1. **IDDocument shape from Sumsub attestation**`KycData` (from
`@selfxyz/common/utils/types`) is already a subtype of `IDDocument`.
Construct it directly from Sumsub's attestation:
```typescript
const kycData: KycData = {
documentType: deserializeApplicantInfo(attestation.serializedApplicantInfo).idType,
documentCategory: 'kyc',
mock: false,
signature: attestation.signature,
pubkey: attestation.pubkey,
serializedApplicantInfo: attestation.serializedApplicantInfo,
};
```
`deserializeApplicantInfo()` from `@selfxyz/common/utils/kyc/api` parses
the base64 blob into structured fields. The proving machine calls this
internally when it needs circuit inputs. This is the same pattern the RN
app uses (`app/src/hooks/useSumsubWebSocket.ts`).
2. **DSC circuit** — Not needed for KYC documents. The tunnel flow runs
`register → disclose` only. DSC is only relevant for NFC-scanned
passports where the Document Signer Certificate needs updating.
3. **Auto-confirm vs user gate** — Deferred. This is a design decision for
when the full Euclid screen set is integrated. For now, use
`userConfirmed: true` in the `init()` call to auto-confirm. The receipt
screen already shows disclosures before proving starts.
## Validation
```bash
# webview-app builds
cd packages/webview-app && yarn build
# Manual: launch tunnel flow in test app, verify:
# 1. Sumsub completes → document stored in keychain
# 2. Proving machine transitions through states
# 3. Proof generated successfully (or meaningful error)
# 4. Result screen shows real outcome
# 5. lifecycle.setResult() sends proof to host
```
## Definition of Done
- [ ] TunnelProvingScreen drives real provingMachine (no 3-second mock)
- [ ] Sumsub result stored as IDDocument in native keychain via secureStorage
- [ ] Register circuit runs to completion (or meaningful error state)
- [ ] Disclose circuit runs after register completes
- [ ] TunnelResultScreen shows real success/failure from proving state
- [ ] `lifecycle.setResult()` called with proof data on success
- [ ] Error/failure states show actionable UI
- [ ] `yarn build` passes for webview-app
- [ ] Backlog row added in SPEC.md
## Status Log
- 2026-03-24: Plan created.