diff --git a/docs/superpowers/plans/2026-06-16-v0.2.1-evidence-layer.md b/docs/superpowers/plans/2026-06-16-v0.2.1-evidence-layer.md
new file mode 100644
index 0000000..29198da
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-16-v0.2.1-evidence-layer.md
@@ -0,0 +1,1955 @@
+# v0.2.1 Evidence Layer Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build the next practical slice of the deterministic website action topology engine: richer evidence capture, context-scoped locator evidence, replay verification, state-local risk skip records, and a v0.2 graph contract.
+
+**Architecture:** Keep the repository as a small Playwright-based topology engine. Add a typed evidence layer around the existing `snapshotPage`, `generateLocators`, and `exploreUrl` flow; do not add LLM planning, natural-language execution, product UI, rrweb alignment, full BFS crawling, or a general browser-agent platform. Preserve JSON artifact output while making every state, element, locator, edge, skip, and failure more explainable.
+
+**Tech Stack:** TypeScript, Playwright, Node built-in test runner through `tsx --test`, JSON artifacts, Markdown schema docs.
+
+---
+
+## Design Inputs
+
+- `docs/design/action-topology-engine-v0.2.md`: source of truth for scope, state-local elements, evidence levels, risk categories, parameter surfaces, state lifecycle, action edges, failures, and artifact versioning.
+- `docs/design/no-llm-topology-engine.md`: first slice remains deterministic and no-LLM.
+- `docs/source/2026-06-16-browser-automation-crawling-research.md`: near-term direction is capture evidence layer, locator context ranking, replay verification, conservative explore policy, and non-evasive anti-bot handling.
+- `AGENTS.md`: repository must remain focused on topology capture/extraction/locator verification/action edges/graph artifacts.
+
+## Implementation Guardrails
+
+- Every `ActionableElement` must remain state-local. Element IDs must include state identity or otherwise encode state locality; never collapse elements across states with a global DOM-only ID.
+- Every explored `ActionEdge` must be replayed from its declared `fromStateId`. In v0.2.1 this means each candidate starts from a fresh page at the entry URL before the action runs.
+- `SkippedAction` is reserved for risk-policy refusals. Hidden, disabled, obscured, duplicate-locator, and other not-actionable cases must be recorded as `FailedObservation`, not as risk skips.
+- Newly discovered states must have their own high-risk actions recorded as state-local `SkippedAction` entries, even though v0.2.1 does not recursively explore those new states.
+- Locator context scopes in v0.2.1 are evidence for ranking and disambiguation. They are not compiled into scoped locators unless a task explicitly adds a scoped descriptor.
+- v0.2.1 effect records are snapshot-diff based: URL, DOM hash, visible text hash, and actionable inventory hash. Network, storage, download, and dialog event buffers are reserved for a later slice unless explicitly added.
+- `crawlSessionId` is a unique run identifier, not a deterministic fingerprint. Deterministic comparison uses `inputFingerprint`.
+- `openedByEdgeId` means first observed by that edge in this run, not the only possible predecessor.
+
+## Current Baseline
+
+- `npm run typecheck`: passes.
+- `npm run build`: passes.
+- `npm run scan -- --url ./examples/simple.html --out artifacts/baseline-simple-scan.json`: writes 1 state, 3 elements, 0 edges.
+- `npm run example`: writes 3 states, 12 elements, 2 edges.
+
+## File Structure
+
+- Modify `package.json`: add a `test` script using existing `tsx`.
+- Modify `src/types.ts`: versioned graph types, evidence records, richer metadata, risk policy records, locator verification records, context anchors, parameter surfaces, state-local skipped actions, failed observations.
+- Modify `src/snapshot.ts`: collect state evidence, element context, form/parameter surface, actionability facts, language/viewport, and stable state signatures.
+- Modify `src/locator.ts`: keep Playwright user-facing locators first, add context-scope evidence, strengthen verification from uniqueness-only to identity checks.
+- Modify `src/risk.ts`: replace simple low/medium/high classification internals with risk categories and policy decisions while keeping `riskLevel` compatibility.
+- Modify `src/engine.ts`: build v0.2 metadata, record state-local skipped high-risk actions, record failed observations, pass `openedByEdgeId`, dedupe state-local elements, and replay each candidate from a clean entry state.
+- Create `src/metadata.ts`: central constants and runtime metadata builder.
+- Create `src/effects.ts`: compare before/after snapshots into typed snapshot-diff effect records.
+- Create `tests/fixtures/context-risk.html`: deterministic fixture with repeated row actions, dialog actions, form fields, high-risk actions, and local effects.
+- Create `tests/risk.test.ts`: unit tests for risk category and default explore policy.
+- Create `tests/locator.test.ts`: unit tests for locator ordering and context metadata.
+- Create `tests/integration.test.ts`: Playwright-backed graph tests for scan and explore on the fixture.
+- Modify `schemas/topology-graph.schema.md`: document v0.2 fields and compatibility notes.
+- Modify `README.md`: update scope summary, commands, and output model.
+
+---
+
+### Task 1: Test Harness and Deterministic Fixture
+
+**Files:**
+- Modify: `package.json`
+- Create: `tests/fixtures/context-risk.html`
+- Create: `tests/risk.test.ts`
+- Create: `tests/locator.test.ts`
+- Create: `tests/integration.test.ts`
+
+- [ ] **Step 1: Add the test script**
+
+Modify `package.json` scripts to include `test` without adding a new dependency:
+
+```json
+{
+ "scripts": {
+ "build": "tsc -p tsconfig.json",
+ "typecheck": "tsc --noEmit -p tsconfig.json",
+ "test": "tsx --test tests/**/*.test.ts",
+ "scan": "tsx src/cli.ts scan",
+ "explore": "tsx src/cli.ts explore",
+ "example": "tsx src/cli.ts explore --url ./examples/simple.html --out artifacts/simple-graph.json --max-actions 8"
+ }
+}
+```
+
+- [ ] **Step 2: Create the fixture**
+
+Create `tests/fixtures/context-risk.html`:
+
+```html
+
+
+
+
+
+ Topology Context Risk Fixture
+
+
+
+
Accounts
+
+
+
+
Customers
+
+
+
Name
Status
Action
+
+
+
+
Acme Co
+
Active
+
+
+
+
Beta LLC
+
Trial
+
+
+
+
+
+
+
+
+
+
+
+
Settings
+
+
+
+
+
+
+
Customer editor
+
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 3: Write the initial risk tests**
+
+Create `tests/risk.test.ts`:
+
+```ts
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { classifyClickRisk, isLowRiskClickable } from "../src/risk.js";
+import type { ActionableElement } from "../src/types.js";
+
+function element(overrides: Partial): ActionableElement {
+ return {
+ elementId: "el_test",
+ stateId: "state_test",
+ tag: "button",
+ role: "button",
+ accessibleName: "Open settings",
+ text: "Open settings",
+ label: null,
+ placeholder: null,
+ attributes: {},
+ bbox: { x: 0, y: 0, width: 100, height: 30 },
+ framePath: [],
+ shadowPath: [],
+ interactability: { visible: true, enabled: true, editable: false, receivesEvents: true },
+ locators: [],
+ ...overrides,
+ };
+}
+
+test("risk classifier blocks destructive and submission terms", () => {
+ assert.equal(classifyClickRisk(element({ accessibleName: "Delete account" })), "high");
+ assert.equal(classifyClickRisk(element({ accessibleName: "提交订单" })), "high");
+ assert.equal(classifyClickRisk(element({ attributes: { type: "submit" }, text: "Submit search" })), "high");
+});
+
+test("risk classifier treats editable controls as medium", () => {
+ assert.equal(classifyClickRisk(element({ tag: "input", role: "textbox", accessibleName: "Query" })), "medium");
+ assert.equal(classifyClickRisk(element({ tag: "select", role: "combobox", accessibleName: "Segment" })), "medium");
+});
+
+test("default explorer only clicks low-risk visible clickable elements", () => {
+ assert.equal(isLowRiskClickable(element({ accessibleName: "Open settings" })), true);
+ assert.equal(isLowRiskClickable(element({ accessibleName: "Delete account" })), false);
+ assert.equal(isLowRiskClickable(element({ interactability: { visible: true, enabled: false, editable: false, receivesEvents: true } })), false);
+});
+```
+
+- [ ] **Step 4: Write locator tests against the current API**
+
+Create `tests/locator.test.ts`:
+
+```ts
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { generateLocators } from "../src/locator.js";
+import type { RawActionableElement } from "../src/types.js";
+
+function raw(overrides: Partial): RawActionableElement {
+ return {
+ uid: "button_0_Open settings",
+ tag: "button",
+ role: "button",
+ accessibleName: "Open settings",
+ text: "Open settings",
+ label: null,
+ placeholder: null,
+ attributes: { "data-testid": "open-settings", id: "openSettings" },
+ bbox: { x: 0, y: 0, width: 100, height: 30 },
+ visibility: { visible: true, enabled: true, editable: false, receivesEvents: true },
+ ...overrides,
+ };
+}
+
+test("locator generation prioritizes user-facing Playwright locators before CSS fallbacks", () => {
+ const candidates = generateLocators("el_test", raw({}));
+ assert.equal(candidates[0].strategy, "role");
+ assert.equal(candidates[0].framework, "playwright");
+ assert.equal(candidates.some((candidate) => candidate.strategy === "testId"), true);
+ assert.equal(candidates.at(-1)?.strategy, "css");
+});
+
+test("locator generation keeps CSS as fallback for id and name", () => {
+ const candidates = generateLocators(
+ "el_input",
+ raw({
+ tag: "input",
+ role: "textbox",
+ accessibleName: "Query",
+ text: null,
+ label: "Query",
+ placeholder: "Search customers",
+ attributes: { id: "queryInput", name: "q" },
+ }),
+ );
+ assert.equal(candidates.some((candidate) => candidate.expression.includes("#queryInput")), true);
+ assert.equal(candidates.some((candidate) => candidate.expression.includes("[name=")), true);
+});
+```
+
+- [ ] **Step 5: Write integration tests that define the v0.2 behavioral baseline**
+
+Create `tests/integration.test.ts`:
+
+```ts
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import path from "node:path";
+import { exploreUrl, scanUrl } from "../src/engine.js";
+
+const fixturePath = path.resolve("tests/fixtures/context-risk.html");
+
+test("scan captures a state-local inventory without clicking high-risk actions", async () => {
+ const graph = await scanUrl({ url: fixturePath });
+ assert.equal(graph.states.length, 1);
+ assert.equal(graph.edges.length, 0);
+ assert.equal(graph.elements.some((element) => element.accessibleName === "Open settings"), true);
+ assert.equal(graph.elements.some((element) => element.accessibleName === "Delete account"), false);
+});
+
+test("explore records low-risk edges and keeps high-risk submit actions out of default execution", async () => {
+ const graph = await exploreUrl({ url: fixturePath, maxActions: 8 });
+ assert.equal(graph.edges.some((edge) => edge.riskLevel === "low" && edge.toStateId), true);
+ assert.equal(
+ graph.edges.some((edge) => {
+ const element = graph.elements.find((item) => item.elementId === edge.elementId);
+ return element?.accessibleName === "Delete account";
+ }),
+ false,
+ );
+ assert.equal(graph.states.length >= 2, true);
+});
+```
+
+- [ ] **Step 6: Run the new tests before implementation changes**
+
+Run:
+
+```bash
+npm run test
+```
+
+Expected: current API unit tests pass. Integration assertions that target later v0.2 fields are introduced in later tasks, so this first test batch should establish the fixture and baseline without requiring v0.2 fields yet. If the shell reports no matching test files, verify that the files above are saved under `tests/`.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add package.json tests
+git commit -m "test: add topology evidence baseline fixtures"
+```
+
+---
+
+### Task 2: Versioned Graph Contract and Metadata
+
+**Files:**
+- Modify: `src/types.ts`
+- Create: `src/metadata.ts`
+- Modify: `src/engine.ts`
+- Modify: `tests/integration.test.ts`
+
+- [ ] **Step 1: Add failing assertions for v0.2 metadata**
+
+Extend `tests/integration.test.ts` with this test:
+
+```ts
+test("graph metadata records generation context and policy versions", async () => {
+ const graph = await scanUrl({ url: fixturePath });
+ assert.equal(graph.schemaVersion, "0.2");
+ assert.equal(graph.metadata.engineVersion, "0.2.0");
+ assert.equal(graph.metadata.runtimeContext.browserName, "chromium");
+ assert.equal(graph.metadata.runtimeContext.viewport.width, 1440);
+ assert.equal(graph.metadata.runtimeContext.viewport.height, 1000);
+ assert.equal(graph.metadata.riskPolicyVersion, "risk-policy-v0.2");
+ assert.equal(graph.metadata.locatorScoringVersion, "locator-scoring-v0.2");
+ assert.equal(typeof graph.metadata.crawlSessionId, "string");
+ assert.equal(graph.metadata.inputUrl, fixturePath);
+ assert.equal(typeof graph.metadata.inputFingerprint, "string");
+});
+```
+
+- [ ] **Step 2: Update graph types**
+
+In `src/types.ts`, add these definitions near the top:
+
+```ts
+export const SCHEMA_VERSION = "0.2" as const;
+export const ENGINE_VERSION = "0.2.0" as const;
+
+export type GraphMode = "scan" | "explore";
+export type EvidenceLevel = "observed" | "derived" | "verified" | "heuristic" | "external";
+export type StateLifecycle = "observed" | "fingerprinted" | "verified" | "stale" | "deprecated" | "conflicted";
+
+export interface EvidenceRecord {
+ level: EvidenceLevel;
+ source: string;
+ description: string;
+}
+
+export interface RuntimeContext {
+ browserName: "chromium";
+ viewport: { width: number; height: number };
+ locale: string;
+ timezoneId: string | null;
+ authContext: "anonymous" | "storage_state";
+}
+```
+
+Update `TopologyGraph` metadata in `src/types.ts`:
+
+```ts
+export interface TopologyGraph {
+ schemaVersion: typeof SCHEMA_VERSION;
+ metadata: {
+ artifactVersion: string;
+ inputUrl: string;
+ entryUrl: string;
+ generatedAt: string;
+ engineVersion: typeof ENGINE_VERSION;
+ mode: GraphMode;
+ crawlSessionId: string;
+ inputSeed: string;
+ inputFingerprint: string;
+ runtimeContext: RuntimeContext;
+ site: {
+ baseUrl: string;
+ entryUrl: string;
+ };
+ riskPolicyVersion: "risk-policy-v0.2";
+ normalizationRuleVersion: "normalization-v0.2";
+ locatorScoringVersion: "locator-scoring-v0.2";
+ };
+ states: StateNode[];
+ elements: ActionableElement[];
+ edges: ActionEdge[];
+ skippedActions: SkippedAction[];
+ failedObservations: FailedObservation[];
+}
+```
+
+Add temporary compatibility type definitions in `src/types.ts`; later tasks enrich their fields:
+
+```ts
+export interface SkippedAction {
+ skippedActionId: string;
+ stateId: string;
+ elementId: string;
+ actionType: ActionType;
+ riskLevel: "low" | "medium" | "high";
+ reason: string;
+ evidence: EvidenceRecord[];
+ createdAt: string;
+}
+
+export interface FailedObservation {
+ failedObservationId: string;
+ stateId: string;
+ elementId: string | null;
+ actionType: ActionType | null;
+ reason:
+ | "locator_not_found"
+ | "locator_not_unique"
+ | "element_hidden"
+ | "element_disabled"
+ | "element_obscured"
+ | "state_precondition_missing"
+ | "frame_inaccessible"
+ | "shadow_root_closed"
+ | "navigation_timeout"
+ | "network_error"
+ | "permission_denied"
+ | "auth_required"
+ | "captcha_or_challenge"
+ | "risk_policy_blocked"
+ | "effect_not_observed"
+ | "unexpected_state";
+ message: string;
+ evidence: EvidenceRecord[];
+ createdAt: string;
+}
+```
+
+- [ ] **Step 3: Create metadata builder**
+
+Create `src/metadata.ts`:
+
+```ts
+import { ENGINE_VERSION, SCHEMA_VERSION, type GraphMode, type RuntimeContext, type TopologyGraph } from "./types.js";
+import { shortHash } from "./hash.js";
+
+const DEFAULT_VIEWPORT = { width: 1440, height: 1000 } as const;
+
+export function defaultViewport(): { width: number; height: number } {
+ return { ...DEFAULT_VIEWPORT };
+}
+
+export function buildRuntimeContext(storageState?: string): RuntimeContext {
+ return {
+ browserName: "chromium",
+ viewport: defaultViewport(),
+ locale: "en-US",
+ timezoneId: null,
+ authContext: storageState ? "storage_state" : "anonymous",
+ };
+}
+
+export function buildEmptyGraph(args: {
+ inputUrl: string;
+ entryUrl: string;
+ mode: GraphMode;
+ storageState?: string;
+}): TopologyGraph {
+ const generatedAt = new Date().toISOString();
+ const crawlSessionId = `crawl_${shortHash([args.entryUrl, args.mode, generatedAt])}`;
+ const inputFingerprint = `input_${shortHash([
+ args.inputUrl,
+ args.mode,
+ "risk-policy-v0.2",
+ "normalization-v0.2",
+ "locator-scoring-v0.2",
+ ])}`;
+ return {
+ schemaVersion: SCHEMA_VERSION,
+ metadata: {
+ artifactVersion: "topology-graph-v0.2",
+ inputUrl: args.inputUrl,
+ entryUrl: args.entryUrl,
+ generatedAt,
+ engineVersion: ENGINE_VERSION,
+ mode: args.mode,
+ crawlSessionId,
+ inputSeed: args.inputUrl,
+ inputFingerprint,
+ runtimeContext: buildRuntimeContext(args.storageState),
+ site: {
+ baseUrl: baseUrl(args.entryUrl),
+ entryUrl: args.entryUrl,
+ },
+ riskPolicyVersion: "risk-policy-v0.2",
+ normalizationRuleVersion: "normalization-v0.2",
+ locatorScoringVersion: "locator-scoring-v0.2",
+ },
+ states: [],
+ elements: [],
+ edges: [],
+ skippedActions: [],
+ failedObservations: [],
+ };
+}
+
+function baseUrl(value: string): string {
+ try {
+ const url = new URL(value);
+ if (url.protocol === "file:") return url.protocol;
+ return `${url.protocol}//${url.host}`;
+ } catch {
+ return "unknown";
+ }
+}
+```
+
+- [ ] **Step 4: Use metadata builder in `engine.ts`**
+
+In `src/engine.ts`, import:
+
+```ts
+import { buildEmptyGraph, defaultViewport } from "./metadata.js";
+```
+
+Replace inline `TopologyGraph` construction in `scanUrl`:
+
+```ts
+const graph = buildEmptyGraph({
+ inputUrl: options.url,
+ entryUrl: page.url(),
+ mode: "scan",
+ storageState: options.storageState,
+});
+graph.states.push(snapshot.state);
+graph.elements.push(...snapshot.elements);
+```
+
+Replace inline `TopologyGraph` construction in `exploreUrl`:
+
+```ts
+const graph = buildEmptyGraph({
+ inputUrl: options.url,
+ entryUrl: page.url(),
+ mode: "explore",
+ storageState: options.storageState,
+});
+graph.states.push(entrySnapshot.state);
+graph.elements.push(...entrySnapshot.elements);
+```
+
+Update `newContext` viewport:
+
+```ts
+viewport: defaultViewport(),
+```
+
+- [ ] **Step 5: Run focused verification**
+
+Run:
+
+```bash
+npm run test
+npm run typecheck
+```
+
+Expected: tests pass and TypeScript reports no errors.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/types.ts src/metadata.ts src/engine.ts tests/integration.test.ts
+git commit -m "feat: version topology graph metadata"
+```
+
+---
+
+### Task 3: State and Element Evidence Capture
+
+**Files:**
+- Modify: `src/types.ts`
+- Modify: `src/snapshot.ts`
+- Modify: `tests/integration.test.ts`
+
+- [ ] **Step 1: Add failing assertions for state and element evidence**
+
+Extend `tests/integration.test.ts`:
+
+```ts
+test("scan records state lifecycle, language, context anchors, and parameter surfaces", async () => {
+ const graph = await scanUrl({ url: fixturePath });
+ const state = graph.states[0];
+ assert.equal(state.lifecycle, "fingerprinted");
+ assert.equal(state.language, "en");
+ assert.equal(state.viewport.width, 1440);
+
+ const query = graph.elements.find((element) => element.accessibleName === "Query");
+ assert.ok(query);
+ assert.equal(query.parameterSurface?.name, "q");
+ assert.equal(query.parameterSurface?.required, true);
+ assert.equal(query.parameterSurface?.maxLength, 40);
+ assert.equal(query.context.form?.name, "Search customers");
+
+ const edit = graph.elements.find((element) => element.accessibleName === "Edit");
+ assert.ok(edit);
+ assert.equal(edit.context.row?.text.includes("Acme Co") || edit.context.row?.text.includes("Beta LLC"), true);
+});
+```
+
+- [ ] **Step 2: Extend types for context and parameters**
+
+Add to `src/types.ts`:
+
+```ts
+export interface ContextAnchor {
+ kind: "dialog" | "form" | "section" | "row" | "landmark" | "listitem";
+ name: string | null;
+ text: string | null;
+}
+
+export interface ElementContext {
+ dialog: ContextAnchor | null;
+ form: ContextAnchor | null;
+ section: ContextAnchor | null;
+ row: ContextAnchor | null;
+ landmark: ContextAnchor | null;
+ listitem: ContextAnchor | null;
+}
+
+export interface ParameterSurface {
+ name: string | null;
+ inputType: string | null;
+ required: boolean;
+ readonly: boolean;
+ disabled: boolean;
+ pattern: string | null;
+ min: string | null;
+ max: string | null;
+ maxLength: number | null;
+ options: Array<{ label: string; value: string; disabled: boolean; selected: boolean }>;
+}
+```
+
+Extend `StateNode`:
+
+```ts
+lifecycle: StateLifecycle;
+urlCanonicalKey: string;
+language: string | null;
+viewport: { width: number; height: number };
+evidence: EvidenceRecord[];
+```
+
+Extend `ActionableElement`:
+
+```ts
+context: ElementContext;
+parameterSurface: ParameterSurface | null;
+evidence: EvidenceRecord[];
+```
+
+Extend `RawActionableElement` with the same `context`, `parameterSurface`, and `evidence` fields.
+
+- [ ] **Step 3: Update test object factories for the new required fields**
+
+In `tests/risk.test.ts`, add these default fields to the object returned by `element()` before `...overrides`:
+
+```ts
+context: {
+ dialog: null,
+ form: null,
+ section: null,
+ row: null,
+ landmark: null,
+ listitem: null,
+},
+parameterSurface: null,
+evidence: [],
+```
+
+In `tests/locator.test.ts`, add these default fields to the object returned by `raw()` before `...overrides`:
+
+```ts
+context: {
+ dialog: null,
+ form: null,
+ section: null,
+ row: null,
+ landmark: null,
+ listitem: null,
+},
+parameterSurface: null,
+evidence: [],
+```
+
+- [ ] **Step 4: Populate state evidence in `snapshotPage`**
+
+Update the type import at the top of `src/snapshot.ts`:
+
+```ts
+import type {
+ ActionableElement,
+ ContextAnchor,
+ ElementContext,
+ RawActionableElement,
+ SnapshotResult,
+ StateNode,
+} from "./types.js";
+```
+
+In `snapshotPage`, set the new `StateNode` fields:
+
+```ts
+const viewport = raw.viewport;
+const state: StateNode = {
+ stateId: `state_${shortHash(stateHash)}`,
+ url: raw.url,
+ route: raw.route,
+ title: raw.title,
+ frameContext: [],
+ stateHash,
+ domHash: stableHash(raw.dom),
+ visibleTextHash: stableHash(raw.visibleText),
+ actionableSignatureHash: stableHash(raw.elements.map(actionableSignature)),
+ modalStack: raw.modalStack,
+ openedByEdgeId,
+ createdAt,
+ lifecycle: "fingerprinted",
+ urlCanonicalKey: raw.urlCanonicalKey,
+ language: raw.language,
+ viewport,
+ evidence: [
+ { level: "observed", source: "browser.location", description: "URL and route captured from the page" },
+ { level: "derived", source: "snapshot.hash", description: "State hashes derived from DOM, visible text, and actionable signatures" },
+ ],
+};
+```
+
+- [ ] **Step 5: Extend browser snapshot shape**
+
+In `src/snapshot.ts`, extend `BrowserSnapshot`:
+
+```ts
+interface BrowserSnapshot {
+ url: string;
+ route: string;
+ title: string;
+ dom: string;
+ visibleText: string;
+ modalStack: string[];
+ language: string | null;
+ viewport: { width: number; height: number };
+ urlCanonicalKey: string;
+ elements: RawActionableElement[];
+}
+```
+
+In `collectPageSnapshot`, return:
+
+```ts
+language: normalizeNullable(document.documentElement.lang || navigator.language || ""),
+viewport: { width: window.innerWidth, height: window.innerHeight },
+urlCanonicalKey: canonicalUrl(location.href),
+```
+
+Add browser-side helper:
+
+```ts
+function canonicalUrl(value: string): string {
+ const url = new URL(value);
+ url.hash = "";
+ const params = Array.from(url.searchParams.entries())
+ .filter(([key]) => !/^utm_|^fbclid$|^gclid$/.test(key))
+ .sort(([left], [right]) => left.localeCompare(right));
+ url.search = "";
+ for (const [key, item] of params) url.searchParams.append(key, item);
+ return url.toString();
+}
+```
+
+- [ ] **Step 6: Populate element context and parameter surface**
+
+In `toRawElement`, add:
+
+```ts
+const context = getElementContext(element);
+const parameterSurface = getParameterSurface(element);
+```
+
+Return these fields:
+
+```ts
+context,
+parameterSurface,
+evidence: [
+ { level: "observed", source: "dom", description: "Element was visible and matched actionable selector" },
+ { level: "derived", source: "accessibility", description: "Role, name, label, and placeholder were normalized from DOM evidence" },
+],
+```
+
+Add browser-side helpers in `collectPageSnapshot`:
+
+```ts
+function getElementContext(element: HTMLElement): ElementContext {
+ return {
+ dialog: anchor(element.closest("[role='dialog'], dialog[open], [aria-modal='true']"), "dialog"),
+ form: anchor(element.closest("form"), "form"),
+ section: anchor(element.closest("section, article, main"), "section"),
+ row: anchor(element.closest("tr, [role='row']"), "row"),
+ landmark: anchor(element.closest("nav, aside, header, footer, main, [role='navigation'], [role='main'], [role='complementary']"), "landmark"),
+ listitem: anchor(element.closest("li, [role='listitem']"), "listitem"),
+ };
+}
+
+function anchor(element: HTMLElement | null, kind: ContextAnchor["kind"]): ContextAnchor | null {
+ if (!element) return null;
+ return {
+ kind,
+ name: getAccessibleName(element) || element.getAttribute("aria-label") || element.id || null,
+ text: normalizeNullable((element.innerText || element.textContent || "").slice(0, 240)),
+ };
+}
+
+function getParameterSurface(element: HTMLElement): ParameterSurface | null {
+ const tag = element.tagName.toLowerCase();
+ if (!["input", "select", "textarea"].includes(tag) && !element.isContentEditable) return null;
+ const input = element as HTMLInputElement;
+ const select = element as HTMLSelectElement;
+ return {
+ name: input.name || element.getAttribute("name"),
+ inputType: tag === "input" ? (input.type || "text").toLowerCase() : tag,
+ required: Boolean((input as HTMLInputElement).required),
+ readonly: Boolean((input as HTMLInputElement).readOnly),
+ disabled: Boolean((input as HTMLInputElement).disabled),
+ pattern: input.getAttribute("pattern"),
+ min: input.getAttribute("min"),
+ max: input.getAttribute("max"),
+ maxLength: Number.isFinite(input.maxLength) && input.maxLength >= 0 ? input.maxLength : null,
+ options:
+ tag === "select"
+ ? Array.from(select.options).map((option) => ({
+ label: normalizeText(option.label || option.textContent || ""),
+ value: option.value,
+ disabled: option.disabled,
+ selected: option.selected,
+ }))
+ : [],
+ };
+}
+```
+
+- [ ] **Step 7: Generate state-local element IDs and carry new raw fields into `ActionableElement`**
+
+Replace the current `elementId` assignment with a state-local ID that cannot collapse elements across states:
+
+```ts
+const elementId = `el_${shortHash([
+ state.stateId,
+ rawElement.uid,
+ rawElement.tag,
+ rawElement.role,
+ rawElement.accessibleName,
+ rawElement.context.dialog?.name,
+ rawElement.context.form?.name,
+ rawElement.context.row?.text,
+ rawElement.attributes["data-testid"] || rawElement.attributes["data-test-id"] || rawElement.attributes["data-test"],
+])}`;
+```
+
+The `state.stateId` entry is required. Do not replace it with a DOM-only selector or a global ordinal.
+
+In the `elements.push` block inside `snapshotPage`, add:
+
+```ts
+context: rawElement.context,
+parameterSurface: rawElement.parameterSurface,
+evidence: rawElement.evidence,
+```
+
+- [ ] **Step 8: Run verification**
+
+Run:
+
+```bash
+npm run test
+npm run typecheck
+```
+
+Expected: all tests pass and the generated graph contains state lifecycle and element context fields.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add src/types.ts src/snapshot.ts tests/risk.test.ts tests/locator.test.ts tests/integration.test.ts
+git commit -m "feat: capture state and element evidence"
+```
+
+---
+
+### Task 4: Context Evidence for Locator Ranking and Identity Verification
+
+**Files:**
+- Modify: `src/types.ts`
+- Modify: `src/locator.ts`
+- Modify: `tests/locator.test.ts`
+- Modify: `tests/integration.test.ts`
+
+- [ ] **Step 1: Add failing locator context assertions**
+
+Extend `tests/locator.test.ts`:
+
+```ts
+test("locator candidates carry context-scope evidence for duplicated actions", () => {
+ const candidates = generateLocators(
+ "el_edit",
+ raw({
+ uid: "button_4_Edit",
+ tag: "button",
+ role: "button",
+ accessibleName: "Edit",
+ text: "Edit",
+ attributes: {},
+ context: {
+ dialog: null,
+ form: null,
+ section: { kind: "section", name: "Customers", text: "Customers Acme Co Active Edit" },
+ row: { kind: "row", name: null, text: "Acme Co Active Edit" },
+ landmark: null,
+ listitem: null,
+ },
+ }),
+ );
+ assert.equal(candidates[0].scopes.some((scope) => scope.kind === "row"), true);
+});
+```
+
+Extend `tests/integration.test.ts`:
+
+```ts
+test("duplicated row actions are verified with semantic locator evidence", async () => {
+ const graph = await scanUrl({ url: fixturePath });
+ const editButtons = graph.elements.filter((element) => element.accessibleName === "Edit");
+ assert.equal(editButtons.length, 2);
+ for (const edit of editButtons) {
+ assert.equal(edit.locators.some((locator) => locator.verified && locator.identity?.sameAccessibleName), true);
+ assert.equal(edit.locators.some((locator) => locator.scopes.some((scope) => scope.kind === "row")), true);
+ }
+});
+```
+
+This task adds scope evidence to locator candidates. It does not compile row/form/dialog scopes into executable scoped locators in v0.2.1.
+
+- [ ] **Step 2: Extend locator candidate types**
+
+In `src/types.ts`, add:
+
+```ts
+export interface LocatorScopeDescriptor {
+ kind: ContextAnchor["kind"];
+ name: string | null;
+ text: string | null;
+}
+
+export interface LocatorIdentityEvidence {
+ sameTag: boolean;
+ sameRole: boolean;
+ sameAccessibleName: boolean;
+ sameText: boolean;
+ sameVisibility: boolean;
+}
+```
+
+Extend `LocatorCandidate`:
+
+```ts
+scopes: LocatorScopeDescriptor[];
+identity: LocatorIdentityEvidence | null;
+evidence: EvidenceRecord[];
+```
+
+- [ ] **Step 3: Add context-scope evidence to generated locators**
+
+In `generateLocators`, define:
+
+```ts
+const scopes = contextScopes(raw);
+```
+
+Add `scopes` and `evidence` to every candidate object before `locatorId` is added:
+
+```ts
+scopes,
+evidence: [
+ { level: "derived", source: "locator.generator", description: "Locator candidate generated from state-local element semantics" },
+],
+```
+
+Add helper:
+
+```ts
+function contextScopes(raw: RawActionableElement): LocatorScopeDescriptor[] {
+ const contexts = [raw.context.dialog, raw.context.row, raw.context.form, raw.context.section, raw.context.landmark, raw.context.listitem];
+ return contexts
+ .filter((context): context is LocatorScopeDescriptor => Boolean(context && (context.name || context.text)))
+ .map((context) => ({ kind: context.kind, name: clean(context.name) ?? null, text: clean(context.text) ?? null }));
+}
+```
+
+When mapping final candidates, set:
+
+```ts
+identity: null,
+```
+
+- [ ] **Step 4: Verify locator identity, not only count**
+
+Change `verifyLocators` signature:
+
+```ts
+export async function verifyLocators(page: Page, raw: RawActionableElement, locators: LocatorCandidate[]): Promise {
+```
+
+Update caller in `snapshotPage`:
+
+```ts
+const locators = await verifyLocators(page, rawElement, generateLocators(elementId, rawElement));
+```
+
+In `verifyLocators`, after `matchCount`, evaluate semantic identity when `matchCount >= 1`. Use the same normalization rules as `snapshot.ts`: explicit ARIA label, `aria-labelledby`, associated label, title/alt, normalized visible text, and inferred native role.
+
+```ts
+const resolved = resolveLocator(page, locator.descriptor).first();
+const identity = matchCount > 0 ? await resolved.evaluate((element, expected) => {
+ const html = element as HTMLElement;
+ const tag = html.tagName.toLowerCase();
+ const text = normalizeNullable(html.innerText || html.textContent || "");
+ const accessibleName = getAccessibleName(html);
+ const inferredRole = html.getAttribute("role") || inferRole(html);
+ const visible = isVisible(html);
+ return {
+ sameTag: tag === expected.tag,
+ sameRole: expected.role ? inferredRole === expected.role : true,
+ sameAccessibleName: expected.accessibleName ? accessibleName === expected.accessibleName : true,
+ sameText: expected.text ? text === expected.text : true,
+ sameVisibility: visible === expected.visible,
+ };
+
+ function inferRole(item: HTMLElement): string | null {
+ const itemTag = item.tagName.toLowerCase();
+ if (itemTag === "button") return "button";
+ if (itemTag === "a" && item.hasAttribute("href")) return "link";
+ if (itemTag === "select") return "combobox";
+ if (itemTag === "textarea") return "textbox";
+ if (itemTag === "input") {
+ const type = ((item as HTMLInputElement).type || "text").toLowerCase();
+ if (["button", "submit", "reset"].includes(type)) return "button";
+ if (type === "checkbox") return "checkbox";
+ if (type === "radio") return "radio";
+ return "textbox";
+ }
+ return null;
+ }
+
+ function getAccessibleName(item: HTMLElement): string | null {
+ const ariaLabel = item.getAttribute("aria-label");
+ if (ariaLabel) return normalizeNullable(ariaLabel);
+ const labelledBy = item.getAttribute("aria-labelledby");
+ if (labelledBy) {
+ const labelledText = labelledBy
+ .split(/\s+/)
+ .map((id) => document.getElementById(id)?.innerText || document.getElementById(id)?.textContent || "")
+ .join(" ");
+ if (labelledText.trim()) return normalizeNullable(labelledText);
+ }
+ const label = getLabel(item);
+ if (label) return label;
+ const title = item.getAttribute("title");
+ if (title) return normalizeNullable(title);
+ const alt = item.getAttribute("alt");
+ if (alt) return normalizeNullable(alt);
+ return normalizeNullable(item.innerText || item.textContent || "");
+ }
+
+ function getLabel(item: HTMLElement): string | null {
+ const id = item.getAttribute("id");
+ if (id) {
+ const explicit = document.querySelector(`label[for="${CSS.escape(id)}"]`);
+ if (explicit) return normalizeNullable(explicit.innerText || explicit.textContent || "");
+ }
+ const implicit = item.closest("label");
+ if (implicit) return normalizeNullable(implicit.innerText || implicit.textContent || "");
+ return null;
+ }
+
+ function isVisible(item: HTMLElement): boolean {
+ const style = getComputedStyle(item);
+ if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity) === 0) return false;
+ const rect = item.getBoundingClientRect();
+ return rect.width > 0 && rect.height > 0;
+ }
+
+ function normalizeText(value: string): string {
+ return value.replace(/\s+/g, " ").trim();
+ }
+
+ function normalizeNullable(value: string): string | null {
+ const normalized = normalizeText(value);
+ return normalized || null;
+ }
+}, {
+ tag: raw.tag,
+ role: raw.role,
+ accessibleName: raw.accessibleName,
+ text: raw.text,
+ visible: raw.visibility.visible,
+}) : null;
+```
+
+Compute verification:
+
+```ts
+const identityMatches = identity ? Object.values(identity).every(Boolean) : false;
+const verified = matchCount === 1 && identityMatches;
+```
+
+Update score:
+
+```ts
+score: verified ? locator.score : Math.max(0.1, locator.score - (matchCount === 1 ? 0.2 : 0.35)),
+identity,
+evidence: [
+ ...locator.evidence,
+ { level: verified ? "verified" : "observed", source: "locator.verify", description: `Locator matched ${matchCount} element(s)` },
+],
+```
+
+- [ ] **Step 5: Run verification**
+
+Run:
+
+```bash
+npm run test
+npm run typecheck
+```
+
+Expected: duplicated `Edit` elements have context scopes, and verified locators require both uniqueness and semantic identity.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/types.ts src/locator.ts tests/locator.test.ts tests/integration.test.ts
+git commit -m "feat: verify locators with context evidence"
+```
+
+---
+
+### Task 5: Risk Policy and State-Local Skip Records
+
+**Files:**
+- Modify: `src/types.ts`
+- Modify: `src/risk.ts`
+- Modify: `src/engine.ts`
+- Modify: `tests/risk.test.ts`
+- Modify: `tests/integration.test.ts`
+
+- [ ] **Step 1: Add failing tests for risk policy decisions and skip records**
+
+Extend `tests/risk.test.ts`:
+
+```ts
+import { classifyActionRisk, defaultExploreDecision } from "../src/risk.js";
+
+test("risk policy exposes category, level, and reasons", () => {
+ const decision = defaultExploreDecision(element({ accessibleName: "Authorize payment" }), "click");
+ assert.equal(decision.allowed, false);
+ assert.equal(decision.risk.category, "payment_sensitive");
+ assert.equal(decision.risk.level, "high");
+ assert.equal(decision.reason.includes("payment_sensitive"), true);
+});
+
+test("risk policy treats UI reveal as default explorable", () => {
+ const risk = classifyActionRisk(element({ accessibleName: "Open settings" }), "click");
+ assert.equal(risk.category, "ui_reveal");
+ assert.equal(defaultExploreDecision(element({ accessibleName: "Open settings" }), "click").allowed, true);
+});
+```
+
+Extend `tests/integration.test.ts`:
+
+```ts
+test("explore records state-local skipped high-risk actions with evidence", async () => {
+ const graph = await exploreUrl({ url: fixturePath, maxActions: 8 });
+ assert.equal(graph.skippedActions.some((skip) => skip.reason.includes("form_submit")), true);
+
+ const deleteElement = graph.elements.find((element) => element.accessibleName === "Delete account");
+ assert.ok(deleteElement);
+ const deleteSkip = graph.skippedActions.find((skip) => skip.elementId === deleteElement.elementId);
+ assert.ok(deleteSkip);
+ assert.equal(deleteSkip.stateId, deleteElement.stateId);
+ assert.equal(deleteSkip.riskCategory, "destructive");
+ assert.equal(deleteSkip.riskLevel, "high");
+
+ assert.equal(graph.skippedActions.some((skip) => skip.reason === "allowed:ui_reveal"), false);
+ assert.equal(graph.failedObservations.every((failure) => failure.message.length > 0), true);
+});
+
+test("disabled low-risk controls are failed observations, not risk skips", async () => {
+ const graph = await exploreUrl({ url: fixturePath, maxActions: 8 });
+ const disabled = graph.elements.find((element) => element.accessibleName === "Open disabled panel");
+ assert.ok(disabled);
+ assert.equal(graph.skippedActions.some((skip) => skip.elementId === disabled.elementId), false);
+ assert.equal(
+ graph.failedObservations.some(
+ (failure) => failure.elementId === disabled.elementId && failure.reason === "element_disabled",
+ ),
+ true,
+ );
+});
+```
+
+- [ ] **Step 2: Add risk types**
+
+In `src/types.ts`, add:
+
+```ts
+export type RiskLevel = "low" | "medium" | "high";
+export type RiskCategory =
+ | "read_only"
+ | "navigation"
+ | "ui_reveal"
+ | "input_edit"
+ | "form_submit"
+ | "data_mutation"
+ | "destructive"
+ | "auth_sensitive"
+ | "payment_sensitive"
+ | "unknown";
+
+export interface RiskClassification {
+ level: RiskLevel;
+ category: RiskCategory;
+ reasons: string[];
+ evidence: EvidenceRecord[];
+}
+
+export interface RiskPolicyDecision {
+ allowed: boolean;
+ risk: RiskClassification;
+ reason: string;
+}
+```
+
+Update `ActionEdge.riskLevel` and `SkippedAction.riskLevel` to use `RiskLevel`, and add `riskCategory: RiskCategory` to both.
+
+- [ ] **Step 3: Implement richer risk classification**
+
+Replace `src/risk.ts` internals with this API while keeping `classifyClickRisk` and `isLowRiskClickable` exports:
+
+```ts
+import type { ActionType, ActionableElement, RiskClassification, RiskPolicyDecision } from "./types.js";
+
+const HIGH_RISK_TERMS = [
+ "delete", "remove", "destroy", "drop", "logout", "sign out",
+ "pay", "payment", "purchase", "checkout",
+ "submit", "confirm", "authorize",
+ "删除", "移除", "注销", "退出", "支付", "购买", "提交", "确认", "授权",
+];
+
+const DESTRUCTIVE_TERMS = ["delete", "remove", "destroy", "drop", "删除", "移除"];
+const PAYMENT_TERMS = ["pay", "payment", "purchase", "checkout", "支付", "购买"];
+const AUTH_TERMS = ["authorize", "login", "logout", "sign out", "授权", "注销", "退出"];
+const SUBMIT_TERMS = ["submit", "confirm", "提交", "确认"];
+
+export function classifyActionRisk(element: ActionableElement, actionType: ActionType): RiskClassification {
+ const joined = riskText(element);
+ const reasons: string[] = [];
+ if (containsAny(joined, DESTRUCTIVE_TERMS)) {
+ reasons.push("matched destructive action term");
+ return risk("high", "destructive", reasons);
+ }
+ if (containsAny(joined, PAYMENT_TERMS)) {
+ reasons.push("matched payment-sensitive action term");
+ return risk("high", "payment_sensitive", reasons);
+ }
+ if (containsAny(joined, AUTH_TERMS)) {
+ reasons.push("matched auth-sensitive action term");
+ return risk("high", "auth_sensitive", reasons);
+ }
+ if (containsAny(joined, SUBMIT_TERMS) || element.attributes.type === "submit") {
+ reasons.push("matched form submission action term or submit type");
+ return risk("high", "form_submit", reasons);
+ }
+ if (actionType === "fill" || element.tag === "input" || element.tag === "textarea" || element.tag === "select") {
+ reasons.push("editable control captured but not default-explored");
+ return risk("medium", "input_edit", reasons);
+ }
+ if (element.tag === "a" || element.role === "link" || element.attributes.href) {
+ reasons.push("link or href navigation");
+ return risk("low", "navigation", reasons);
+ }
+ if (element.role === "button" || element.role === "tab" || element.role === "menuitem" || element.attributes["aria-haspopup"] || element.attributes["aria-expanded"]) {
+ reasons.push("button-like UI reveal candidate");
+ return risk("low", "ui_reveal", reasons);
+ }
+ if (HIGH_RISK_TERMS.some((term) => joined.includes(term.toLowerCase()))) {
+ reasons.push("matched high-risk fallback term");
+ return risk("high", "unknown", reasons);
+ }
+ reasons.push("no low-risk semantic evidence");
+ return risk("high", "unknown", reasons);
+}
+
+export function defaultExploreDecision(element: ActionableElement, actionType: ActionType): RiskPolicyDecision {
+ const risk = classifyActionRisk(element, actionType);
+ const allowed = risk.level === "low" && ["navigation", "ui_reveal", "read_only"].includes(risk.category);
+ return {
+ allowed,
+ risk,
+ reason: allowed ? `allowed:${risk.category}` : `blocked:${risk.category}`,
+ };
+}
+
+export function classifyClickRisk(element: ActionableElement): "low" | "medium" | "high" {
+ return classifyActionRisk(element, "click").level;
+}
+
+export function isLowRiskClickable(element: ActionableElement): boolean {
+ if (!element.interactability.visible || !element.interactability.enabled || !element.interactability.receivesEvents) return false;
+ const role = element.role;
+ const clickable = element.tag === "button" || element.tag === "a" || role === "button" || role === "link" || role === "tab" || role === "menuitem";
+ return clickable && defaultExploreDecision(element, "click").allowed;
+}
+
+function risk(level: RiskClassification["level"], category: RiskClassification["category"], reasons: string[]): RiskClassification {
+ return {
+ level,
+ category,
+ reasons,
+ evidence: [{ level: "heuristic", source: "risk.policy", description: reasons.join("; ") }],
+ };
+}
+
+function riskText(element: ActionableElement): string {
+ return [element.accessibleName, element.text, element.label, element.placeholder, element.attributes.href, element.attributes.type]
+ .filter(Boolean)
+ .join(" ")
+ .toLowerCase();
+}
+
+function containsAny(value: string, terms: string[]): boolean {
+ return terms.some((term) => value.includes(term.toLowerCase()));
+}
+```
+
+- [ ] **Step 4: Record state-local risk skips and actionability failures in `exploreUrl`**
+
+In `src/engine.ts`, import:
+
+```ts
+import { defaultExploreDecision } from "./risk.js";
+import type { ActionableElement, SnapshotResult } from "./types.js";
+```
+
+Add these helpers in `src/engine.ts`:
+
+```ts
+function recordSkippedActionsForState(graph: TopologyGraph, snapshot: SnapshotResult): void {
+ for (const element of snapshot.elements) {
+ const decision = defaultExploreDecision(element, "click");
+ if (decision.allowed) continue;
+ graph.skippedActions = upsertById(
+ graph.skippedActions,
+ {
+ skippedActionId: `skip_${shortHash([snapshot.state.stateId, element.elementId, "click", decision.reason])}`,
+ stateId: snapshot.state.stateId,
+ elementId: element.elementId,
+ actionType: "click",
+ riskLevel: decision.risk.level,
+ riskCategory: decision.risk.category,
+ reason: decision.reason,
+ evidence: decision.risk.evidence,
+ createdAt: new Date().toISOString(),
+ },
+ "skippedActionId",
+ );
+ }
+}
+
+function recordActionabilityFailure(graph: TopologyGraph, snapshot: SnapshotResult, element: ActionableElement): void {
+ const reason = !element.interactability.enabled
+ ? "element_disabled"
+ : !element.interactability.visible
+ ? "element_hidden"
+ : "element_obscured";
+
+ graph.failedObservations = upsertById(
+ graph.failedObservations,
+ {
+ failedObservationId: `fail_${shortHash([snapshot.state.stateId, element.elementId, "click", reason])}`,
+ stateId: snapshot.state.stateId,
+ elementId: element.elementId,
+ actionType: "click",
+ reason,
+ message: `Element is not actionable for default click exploration: ${reason}`,
+ evidence: [{ level: "observed", source: "element.interactability", description: reason }],
+ createdAt: new Date().toISOString(),
+ },
+ "failedObservationId",
+ );
+}
+
+function isActionableForClick(element: ActionableElement): boolean {
+ const role = element.role;
+ const clickable = element.tag === "button" || element.tag === "a" || role === "button" || role === "link" || role === "tab" || role === "menuitem";
+ return clickable && element.interactability.visible && element.interactability.enabled && element.interactability.receivesEvents;
+}
+```
+
+Replace candidate selection:
+
+```ts
+recordSkippedActionsForState(graph, entrySnapshot);
+
+const lowRiskCandidates: ActionableElement[] = [];
+for (const element of entrySnapshot.elements) {
+ const decision = defaultExploreDecision(element, "click");
+ if (!decision.allowed) continue;
+ if (!isActionableForClick(element)) {
+ recordActionabilityFailure(graph, entrySnapshot, element);
+ continue;
+ }
+ lowRiskCandidates.push(element);
+}
+const candidates = lowRiskCandidates.slice(0, options.maxActions ?? 10);
+```
+
+Do not insert `allowed:*` decisions into `skippedActions`. A `SkippedAction` means the risk policy blocked execution.
+
+Update edge risk fields:
+
+```ts
+const riskDecision = defaultExploreDecision(element, "click");
+```
+
+Set on edge:
+
+```ts
+riskLevel: riskDecision.risk.level,
+riskCategory: riskDecision.risk.category,
+```
+
+- [ ] **Step 5: Record risk skips for newly discovered states and failed observations for action errors**
+
+After every successful `afterSnapshot`, call:
+
+```ts
+recordSkippedActionsForState(graph, afterSnapshot);
+```
+
+This records `Delete account` as a destructive skip only in the dialog-open state where it is visible.
+
+In the `catch` block around click execution, add:
+
+```ts
+graph.failedObservations.push({
+ failedObservationId: `fail_${shortHash([entrySnapshot.state.stateId, element.elementId, String(error)])}`,
+ stateId: entrySnapshot.state.stateId,
+ elementId: element.elementId,
+ actionType: "click",
+ reason: "unexpected_state",
+ message: error instanceof Error ? error.message : String(error),
+ evidence: [{ level: "observed", source: "playwright.click", description: "Action execution failed during default exploration" }],
+ createdAt: new Date().toISOString(),
+});
+```
+
+- [ ] **Step 6: Run verification**
+
+Run:
+
+```bash
+npm run test
+npm run typecheck
+npm run example
+```
+
+Expected: tests pass, example still avoids `Delete account`, and graph JSON includes `skippedActions`.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/types.ts src/risk.ts src/engine.ts tests/risk.test.ts tests/integration.test.ts
+git commit -m "feat: record risk policy decisions"
+```
+
+---
+
+### Task 6: Replay-Based Exploration and Effect Records
+
+**Files:**
+- Create: `src/effects.ts`
+- Modify: `src/engine.ts`
+- Modify: `src/types.ts`
+- Modify: `tests/integration.test.ts`
+
+- [ ] **Step 1: Add failing tests for edge preconditions and effects**
+
+Extend `tests/integration.test.ts`:
+
+```ts
+test("explore records edge effects and opened-by state relationship", async () => {
+ const graph = await exploreUrl({ url: fixturePath, maxActions: 8 });
+ const openSettingsEdge = graph.edges.find((edge) => {
+ const element = graph.elements.find((candidate) => candidate.elementId === edge.elementId);
+ return element?.accessibleName === "Open settings";
+ });
+ assert.ok(openSettingsEdge);
+ assert.equal(openSettingsEdge.effects.some((effect) => effect.type === "visibleText" || effect.type === "dom"), true);
+ assert.equal(graph.states.some((state) => state.openedByEdgeId === openSettingsEdge.edgeId), true);
+});
+
+test("each v0.2.1 explored edge is replayed from the entry state", async () => {
+ const graph = await exploreUrl({ url: fixturePath, maxActions: 8 });
+ const entryStateId = graph.states[0].stateId;
+ for (const edge of graph.edges) {
+ assert.equal(edge.fromStateId, entryStateId);
+ }
+});
+```
+
+- [ ] **Step 2: Add effect helper**
+
+Create `src/effects.ts`:
+
+```ts
+import { shortHash } from "./hash.js";
+import type { EffectRecord, SnapshotResult } from "./types.js";
+
+export function diffSnapshotEffects(args: {
+ beforeUrl: string;
+ afterUrl: string;
+ beforeSnapshot: SnapshotResult;
+ afterSnapshot: SnapshotResult;
+}): EffectRecord[] {
+ const effects: EffectRecord[] = [];
+ if (args.beforeUrl !== args.afterUrl) {
+ effects.push({ type: "url", before: args.beforeUrl, after: args.afterUrl, description: "URL changed after action" });
+ }
+ if (args.beforeSnapshot.state.domHash !== args.afterSnapshot.state.domHash) {
+ effects.push({
+ type: "dom",
+ before: args.beforeSnapshot.state.domHash,
+ after: args.afterSnapshot.state.domHash,
+ description: "DOM hash changed after action",
+ });
+ }
+ if (shortHash(args.beforeSnapshot.visibleText) !== shortHash(args.afterSnapshot.visibleText)) {
+ effects.push({
+ type: "visibleText",
+ before: shortHash(args.beforeSnapshot.visibleText),
+ after: shortHash(args.afterSnapshot.visibleText),
+ description: "Visible text changed after action",
+ });
+ }
+ if (args.beforeSnapshot.state.actionableSignatureHash !== args.afterSnapshot.state.actionableSignatureHash) {
+ effects.push({
+ type: "dom",
+ before: args.beforeSnapshot.state.actionableSignatureHash,
+ after: args.afterSnapshot.state.actionableSignatureHash,
+ description: "Actionable inventory changed after action",
+ });
+ }
+ return effects;
+}
+```
+
+- [ ] **Step 3: Create a clean replay page for every candidate**
+
+In `src/engine.ts`, add this helper:
+
+```ts
+async function createReplayPage(context: BrowserContext, entryUrl: string): Promise {
+ const page = await context.newPage();
+ await page.goto(entryUrl, { waitUntil: "domcontentloaded" });
+ await page.waitForLoadState("networkidle").catch(() => undefined);
+ return page;
+}
+```
+
+Inside the candidate loop, create a fresh page for the candidate and snapshot the declared from-state before acting:
+
+```ts
+const actionPage = await createReplayPage(context, entryUrl);
+const beforeUrl = actionPage.url();
+const beforeSnapshot = await snapshotPage(actionPage);
+const edgeId = `edge_${shortHash([beforeSnapshot.state.stateId, element.elementId, "click"])}`;
+```
+
+Every candidate must use a new `actionPage`. Do not reuse a page mutated by a previous candidate.
+
+- [ ] **Step 4: Use replay-safe edge IDs before clicking**
+
+Pass the edge id into the after snapshot:
+
+```ts
+const afterSnapshot = await snapshotPage(actionPage, edgeId);
+```
+
+Set `openedByEdgeId` via `snapshotPage`, keep `toStateId = afterSnapshot.state.stateId`, and set the edge source to the actual pre-action state:
+
+```ts
+fromStateId: beforeSnapshot.state.stateId,
+```
+
+After the successful `afterSnapshot`, record state-local high-risk skips for the newly discovered state without recursively exploring that state:
+
+```ts
+recordSkippedActionsForState(graph, afterSnapshot);
+```
+
+- [ ] **Step 5: Replace local diff helper with `diffSnapshotEffects`**
+
+In `src/engine.ts`, import:
+
+```ts
+import { diffSnapshotEffects } from "./effects.js";
+```
+
+Replace:
+
+```ts
+effects.push(...diffEffects(beforeUrl, beforeSnapshot.visibleText, actionPage.url(), afterSnapshot.visibleText));
+```
+
+with:
+
+```ts
+effects.push(
+ ...diffSnapshotEffects({
+ beforeUrl,
+ afterUrl: actionPage.url(),
+ beforeSnapshot,
+ afterSnapshot,
+ }),
+);
+```
+
+Remove the old `diffEffects` function from `engine.ts`.
+
+- [ ] **Step 6: Add trial actionability check before real click**
+
+Before real click:
+
+```ts
+const target = resolveLocator(actionPage, locator.descriptor).first();
+await target.click({ timeout: 2000, trial: true });
+await target.click({ timeout: 2000 });
+```
+
+If trial fails, the existing catch block records a failed observation without mutating the page.
+
+- [ ] **Step 7: Dedupe graph elements by state-local ID**
+
+Add helper in `engine.ts`:
+
+```ts
+function upsertManyById, K extends keyof T>(items: T[], incoming: T[], key: K): T[] {
+ return incoming.reduce((current, item) => upsertById(current, item, key), items);
+}
+```
+
+Replace:
+
+```ts
+graph.elements.push(...afterSnapshot.elements);
+```
+
+with:
+
+```ts
+graph.elements = upsertManyById(graph.elements, afterSnapshot.elements, "elementId");
+```
+
+This is safe only because Task 3 made `elementId` state-local. Do not upsert elements across states unless their `elementId` already encodes state identity.
+
+- [ ] **Step 8: Run verification**
+
+Run:
+
+```bash
+npm run test
+npm run typecheck
+npm run build
+npm run example
+```
+
+Expected: tests pass, build succeeds, example emits states with `openedByEdgeId` for first-observed action-created states, and every v0.2.1 edge starts from the entry state.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add src/effects.ts src/engine.ts src/types.ts tests/integration.test.ts
+git commit -m "feat: verify explored edges by replay"
+```
+
+---
+
+### Task 7: Schema, README, and Artifact Sanity Checks
+
+**Files:**
+- Modify: `schemas/topology-graph.schema.md`
+- Modify: `README.md`
+- Modify: `tests/integration.test.ts`
+
+- [ ] **Step 1: Add artifact sanity assertions**
+
+Extend `tests/integration.test.ts`:
+
+```ts
+test("explore artifact keeps all referenced states and elements resolvable", async () => {
+ const graph = await exploreUrl({ url: fixturePath, maxActions: 8 });
+ const stateIds = new Set(graph.states.map((state) => state.stateId));
+ const elementIds = new Set(graph.elements.map((element) => element.elementId));
+ for (const edge of graph.edges) {
+ assert.equal(stateIds.has(edge.fromStateId), true);
+ if (edge.toStateId) assert.equal(stateIds.has(edge.toStateId), true);
+ assert.equal(elementIds.has(edge.elementId), true);
+ }
+ for (const skip of graph.skippedActions) {
+ assert.equal(stateIds.has(skip.stateId), true);
+ assert.equal(elementIds.has(skip.elementId), true);
+ }
+});
+```
+
+- [ ] **Step 2: Update schema document**
+
+Update `schemas/topology-graph.schema.md` so the top section says:
+
+```markdown
+# Topology Graph Schema
+
+This document describes the JSON shape emitted by the engine. It is a practical schema note, not yet a formal JSON Schema file.
+
+## `TopologyGraph`
+
+- `schemaVersion`: currently `0.2`.
+- `metadata.artifactVersion`: currently `topology-graph-v0.2`.
+- `metadata.inputUrl`: raw URL or file path supplied by the caller.
+- `metadata.entryUrl`: entry URL after Playwright navigation.
+- `metadata.mode`: `scan` or `explore`.
+- `metadata.crawlSessionId`: unique identifier for this graph generation session.
+- `metadata.inputFingerprint`: deterministic hash of input URL, mode, and policy/scoring versions.
+- `metadata.runtimeContext`: browser engine, viewport, locale, timezone, and auth context.
+- `metadata.site`: base URL and entry URL.
+- `metadata.riskPolicyVersion`: currently `risk-policy-v0.2`.
+- `metadata.normalizationRuleVersion`: currently `normalization-v0.2`.
+- `metadata.locatorScoringVersion`: currently `locator-scoring-v0.2`.
+- `states`: page states.
+- `elements`: actionable elements bound to a state.
+- `edges`: observed actions from one state to another.
+- `skippedActions`: state-local actions that default exploration refused to execute because risk policy blocked them.
+- `failedObservations`: failed locator/action/effect observations recorded as evidence.
+```
+
+Add sections for:
+
+```markdown
+## Evidence Levels
+
+- `observed`: directly captured browser fact.
+- `derived`: deterministic rule output from observed facts.
+- `verified`: re-resolved or replay-verified claim.
+- `heuristic`: conservative inference such as risk classification.
+- `external`: human or downstream annotation.
+```
+
+```markdown
+## `StateNode`
+
+- `stateId`: stable id derived from URL, normalized state hashes, and actionable signatures.
+- `lifecycle`: `observed`, `fingerprinted`, `verified`, `stale`, `deprecated`, or `conflicted`.
+- `url`, `route`, `urlCanonicalKey`, `title`, `language`, `viewport`.
+- `stateHash`, `domHash`, `visibleTextHash`, `actionableSignatureHash`.
+- `modalStack`, `frameContext`.
+- `openedByEdgeId`: first edge that observed this state in the current run; not the only possible predecessor.
+- `evidence`: observed and derived state facts.
+```
+
+```markdown
+## `ActionableElement`
+
+- `elementId`: state-local element id. It must encode state identity and must not be treated as a global DOM node id.
+- `stateId`: owning state.
+- `tag`, `role`, `accessibleName`, `text`, `label`, `placeholder`.
+- `attributes`: selected stable attributes such as test id, id, name, type, href, and ARIA fields.
+- `bbox`, `framePath`, `shadowPath`, `interactability`.
+- `context`: nearest dialog, form, section, row, landmark, and list item anchors.
+- `parameterSurface`: input/select/textarea constraints, options, required state, pattern, min/max, and maxLength.
+- `locators`: scored locator candidates.
+- `evidence`: observed and derived element facts.
+```
+
+```markdown
+## `LocatorCandidate`
+
+- `descriptor`: structured Playwright locator descriptor.
+- `expression`: human-readable locator expression.
+- `score`, `reason`, `verified`, `matchCount`, `failureCount`, `lastVerifiedAt`.
+- `scopes`: context-scope evidence such as row, dialog, form, or section anchors. In v0.2.1 this is evidence, not executable scoping.
+- `identity`: semantic identity checks for tag, role, accessible name, text, and visibility.
+- `evidence`: generation and verification facts.
+```
+
+```markdown
+## `ActionEdge`
+
+- `fromStateId`, `toStateId`, `elementId`, `actionType`.
+- `actionParams`, `preconditions`, `parameterSpecs`.
+- `effects`: snapshot-diff effects observed after replay.
+- `riskLevel`, `riskCategory`.
+- `confidence`, `createdAt`.
+```
+
+```markdown
+## `EffectRecord`
+
+- `type`: `url`, `dom`, `visibleText`, `network`, `download`, `dialog`, or `storage`.
+- v0.2.1 emits snapshot-diff effects for URL, DOM hash, visible text hash, and actionable inventory hash.
+- Network, download, dialog, and storage event buffers are reserved for later slices unless explicitly implemented.
+```
+
+```markdown
+## `SkippedAction`
+
+- `skippedActionId`, `stateId`, `elementId`, `actionType`.
+- `riskLevel`, `riskCategory`.
+- `reason`: policy decision string such as `blocked:destructive`.
+- `evidence`: risk-policy evidence explaining why the action was skipped.
+- Hidden, disabled, obscured, duplicate-locator, and timeout cases are not `SkippedAction`; they are `FailedObservation`.
+```
+
+```markdown
+## `FailedObservation`
+
+- `failedObservationId`, `stateId`, optional `elementId`, optional `actionType`.
+- `reason`: machine-readable failure code.
+- `message`: diagnostic message.
+- `evidence`: observed facts explaining the failure.
+```
+
+- [ ] **Step 3: Update README output model**
+
+Update `README.md` current scope bullets to include:
+
+```markdown
+- Capture reproducibility metadata: browser, viewport, locale, auth mode, policy versions, and crawl session id.
+- Record state-local skipped default-exploration actions when risk policy blocks them.
+- Record failed observations instead of turning failed actions into successful edges.
+```
+
+Update key objects:
+
+```markdown
+- `SkippedAction`: risk-policy default exploration refusal with risk category, reason, state id, element id, and evidence.
+- `FailedObservation`: locator/action/effect failure with machine-readable reason and diagnostic evidence.
+```
+
+- [ ] **Step 4: Run full verification**
+
+Run:
+
+```bash
+npm run typecheck
+npm run test
+npm run build
+npm run scan -- --url ./examples/simple.html --out artifacts/simple-scan-v0.2.json
+npm run example
+```
+
+Expected:
+
+- TypeScript passes with no errors.
+- Tests pass.
+- Build writes `dist/`.
+- Scan writes a v0.2 artifact with `skippedActions: []` and `failedObservations: []`.
+- Example writes a v0.2 explore artifact with low-risk edges and skipped high-risk actions.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add schemas/topology-graph.schema.md README.md tests/integration.test.ts
+git commit -m "docs: document topology graph v0.2 contract"
+```
+
+---
+
+## Final Verification Gate
+
+After all tasks are complete, run:
+
+```bash
+npm run typecheck
+npm run test
+npm run build
+npm run scan -- --url ./examples/simple.html --out artifacts/simple-scan-v0.2.json
+npm run example
+git status --short
+```
+
+Expected command results:
+
+- `npm run typecheck`: exits 0.
+- `npm run test`: exits 0.
+- `npm run build`: exits 0.
+- Static scan writes a graph with one entry state and no action edges.
+- Example exploration writes a graph with low-risk action edges, skipped high-risk actions, and no default execution of destructive/submission/payment/auth-sensitive actions.
+- `git status --short` shows only intended changes before the final commit, or is clean after the final commit.
+
+## Explicit Non-Scope
+
+- Do not add LLM calls, model selection, natural-language task execution, or semantic planning.
+- Do not add rrweb ingestion, skill generation, or browser-agent orchestration.
+- Do not add stealth, anti-bot bypass, captcha solving, or unauthorized access handling.
+- Do not introduce database storage before the JSON graph shape stabilizes.
+- Do not make CSS/XPath primary locator strategies; they remain fallback candidates.
+- Do not implement full BFS crawling or multi-step task planning in v0.2.1. It records first-hop explored edges and discovered state-local evidence only.
+
+## Self-Review
+
+- Spec coverage: The plan covers v0.2 design requirements for state-local elements, evidence levels, parameter surfaces, risk classification, failed observations, artifact metadata, context-scope locator evidence, and action effects.
+- Boundary check: The plan stays inside the topology engine and does not add LLM, product UI, rrweb, script generation, or a general crawler.
+- Type consistency: New graph fields are added first in `src/types.ts`, then populated by metadata, snapshot, locator, risk, and engine modules.
+- Testability: Every behavioral change has a unit or integration assertion and a verification command.