diff --git a/package.json b/package.json index 01c92bc..9afa2b7 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,9 @@ "typecheck": "tsc -p tsconfig.templates.json && tsc -p tsconfig.spec.json && tsc -p tsconfig.jsx.json", "syntaxcheck": "node --check skills/webmcpify/templates/webmcpify.js", "test": "node --test \"tests/*.test.mjs\"", - "check": "npm run typecheck && npm run syntaxcheck && npm test" + "check": "npm run typecheck && npm run syntaxcheck && npm test", + "proof:verify": "xvfb-run -a node proof/demo/run.mjs --verify", + "proof:record": "xvfb-run -a node proof/demo/run.mjs --record" }, "devDependencies": { "@playwright/test": "^1.54.0", diff --git a/proof/README.md b/proof/README.md new file mode 100644 index 0000000..fcf62f3 --- /dev/null +++ b/proof/README.md @@ -0,0 +1,62 @@ +# WebMCPify proof pack + +This is a sanitized, deterministic run against a local release-notes fixture. It +contains no customer data and performs no network mutation. The single candidate, +`set_release_filter`, wraps the same browser-local filter path used by the visible +buttons. + +## Reproduce the verification + +Requirements: Node 20+, dependencies from `npm ci`, Google Chrome 150+ (earlier +releases do not expose the native `document.modelContext` surface and the run +fails its first assertion), `xvfb-run`, and (for the derivative) ffmpeg. Chrome +is located through Playwright's `chrome` channel; set `CHROME_BIN` to point at a +specific binary. On a desktop session you can skip Xvfb and run +`node proof/demo/run.mjs --verify` directly. + +```sh +npm ci +npm run proof:verify +``` + +The command starts a loopback-only static server, opens system Google Chrome +headed under Xvfb with `--enable-features=WebMCP,WebMCPTesting`, proves that no +tool exists before approval/integration, triggers the bounded integration, then: + +1. enumerates `set_release_filter` through native `document.modelContext.getTools()`; +2. parses and compares its stringified schema and checks `readOnlyHint: false`; +3. executes `{ "category": "fix" }` through native `executeTool()`; +4. checks the result string and the visible UI delta; and +5. confirms an invalid enum resolves the runtime's bounded `ERROR:` convention + without changing UI state. + +## Reproduce the 63-second uncut recording + +```sh +npm run proof:record +ffmpeg -y -i proof/artifacts/webmcpify-proof-source.webm \ + -vf scale=854:-2 -c:v libx264 -preset slow -crf 28 -movflags +faststart \ + -an proof/artifacts/webmcpify-proof-480p.mp4 +sha256sum proof/artifacts/webmcpify-proof-source.webm \ + proof/artifacts/webmcpify-proof-480p.mp4 > proof/artifacts/SHA256SUMS +``` + +The recording is one continuous browser capture. The phase labels and log lines +are advanced by `proof/demo/run.mjs`; the gate is a real click, integration is a +real registration through the vendored runtime, and verification uses Chrome's +native production enumeration/execution surface. Pauses are intentional so the +workflow is legible at normal playback speed. + +## Sanitized before/after evidence + +- [`manifest.before.json`](manifest.before.json) is the inventory result at the + human gate. The client mutation is still `discovered` and no setup exists. +- [`manifest.after.json`](manifest.after.json) records approval, two bounded setup + paths, and successful native-Chrome verification. +- [`integration.patch`](integration.patch) is the complete conceptual app diff: + one module script plus one tool module; no server endpoint or unrelated UI path. + +The manifests contain only fixture paths, loopback URLs, and synthetic release +notes. Video output is reproducible but binary-identical hashes can vary with the +installed Chrome/ffmpeg builds; the checked-in `SHA256SUMS` identifies the review +artifacts in this revision. diff --git a/proof/artifacts/SHA256SUMS b/proof/artifacts/SHA256SUMS new file mode 100644 index 0000000..de9c97e --- /dev/null +++ b/proof/artifacts/SHA256SUMS @@ -0,0 +1,2 @@ +7e0ef201630a3231abf4b192ec48f17b78d2e9e6cd4f92b34182e49d6d41c7cd proof/artifacts/webmcpify-proof-source.webm +8edf979fc56c9df802c65554591377fab85f35de7baa06829a19d5ea2c3df75e proof/artifacts/webmcpify-proof-480p.mp4 diff --git a/proof/artifacts/webmcpify-proof-480p.mp4 b/proof/artifacts/webmcpify-proof-480p.mp4 new file mode 100644 index 0000000..793e538 Binary files /dev/null and b/proof/artifacts/webmcpify-proof-480p.mp4 differ diff --git a/proof/artifacts/webmcpify-proof-source.webm b/proof/artifacts/webmcpify-proof-source.webm new file mode 100644 index 0000000..683aae8 Binary files /dev/null and b/proof/artifacts/webmcpify-proof-source.webm differ diff --git a/proof/demo/app.js b/proof/demo/app.js new file mode 100644 index 0000000..0250e9a --- /dev/null +++ b/proof/demo/app.js @@ -0,0 +1,41 @@ +const notes = [...document.querySelectorAll('[data-category]')]; + +export function applyFilter(category) { + let visible = 0; + for (const note of notes) { + const show = category === 'all' || note.dataset.category === category; + note.hidden = !show; + if (show) visible++; + } + document.querySelectorAll('[data-filter]').forEach((button) => + button.setAttribute('aria-pressed', String(button.dataset.filter === category))); + document.querySelector('#visible-count').textContent = `${visible} release notes visible`; + return visible; +} + +document.querySelectorAll('[data-filter]').forEach((button) => + button.addEventListener('click', () => applyFilter(button.dataset.filter))); + +const log = document.querySelector('#log'); +window.proof = { + phase(name, title) { + document.querySelectorAll('#phases span').forEach((item) => { + const phases = ['inventory', 'gate', 'integrate', 'verify', 'audit']; + const current = phases.indexOf(name); + const itemIndex = phases.indexOf(item.dataset.phase); + item.className = itemIndex < current ? 'done' : itemIndex === current ? 'active' : ''; + }); + document.querySelector('#phase-label').textContent = name.toUpperCase(); + document.querySelector('#proof-title').textContent = title; + }, + line(text) { log.textContent += `${text}\n`; log.scrollTop = log.scrollHeight; }, + manifest(show = true) { document.querySelector('#manifest').hidden = !show; }, + gate() { document.querySelector('#gate').showModal(); }, + check(text) { document.querySelector('#checks').insertAdjacentHTML('beforeend', `
✓ ${text}
`); }, + chrome(version) { document.querySelector('#chrome').textContent = `Chrome ${version} · native document.modelContext`; }, +}; + +document.querySelector('#approve').addEventListener('click', () => { + document.querySelector('#gate').close(); + window.dispatchEvent(new CustomEvent('webmcpify:approved')); +}); diff --git a/proof/demo/index.html b/proof/demo/index.html new file mode 100644 index 0000000..2a3f56f --- /dev/null +++ b/proof/demo/index.html @@ -0,0 +1,59 @@ + + + + + + WebMCPify proof — safe release-notes fixture + + + +
+
webmcpify / reproducible proof
+ Chrome · native WebMCP +
+ +
+
+
SAFE SAMPLE APP · LOOPBACK ONLY
+

Release notes

+

A tiny existing UI with one browser-local action. No auth, customer data, or network writes.

+
+ + + +
+
+
Command palettefeature
+
Keyboard focus repairfix
+
Compact densityfeature
+
Offline retry repairfix
+
+

4 release notes visible

+
+
+
WAITING
+

Real workflow, one continuous capture

+ +
+

+    
+
+ +
HUMAN MANIFEST GATE
+

Approve one client-only tool?

+

set_release_filter changes only visible browser state through the existing filter path.

+ + +
+ + + + diff --git a/proof/demo/run.mjs b/proof/demo/run.mjs new file mode 100644 index 0000000..a9125c6 --- /dev/null +++ b/proof/demo/run.mjs @@ -0,0 +1,176 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { mkdir, readFile, rename, unlink } from 'node:fs/promises'; +import { dirname, extname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { chromium } from 'playwright'; + +const mode = process.argv.includes('--record') ? 'record' : 'verify'; +const root = dirname(fileURLToPath(import.meta.url)); +const repo = join(root, '..', '..'); +const artifacts = join(repo, 'proof', 'artifacts'); +const sourceVideo = join(artifacts, 'webmcpify-proof-source.webm'); +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, mode === 'record' ? ms : 20)); + +const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' }; +const server = createServer(async (request, response) => { + try { + const url = new URL(request.url, 'http://127.0.0.1'); + const requested = url.pathname === '/' ? 'index.html' : url.pathname.replace(/^\/+/, ''); + const path = requested.startsWith('skills/') + ? join(repo, requested) + : join(root, requested); + const allowed = path.startsWith(root) || path.startsWith(join(repo, 'skills')); + if (!allowed || requested.includes('..')) throw new Error('not found'); + const body = await readFile(path); + response.writeHead(200, { 'Content-Type': types[extname(path)] ?? 'application/octet-stream', 'Cache-Control': 'no-store' }); + response.end(body); + } catch { + response.writeHead(404).end('not found'); + } +}); + +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +const address = server.address(); +const baseUrl = `http://127.0.0.1:${address.port}`; +let browser; + +try { + await mkdir(artifacts, { recursive: true }); + browser = await chromium.launch({ + headless: false, + ...(process.env.CHROME_BIN ? { executablePath: process.env.CHROME_BIN } : { channel: 'chrome' }), + args: ['--enable-features=WebMCP,WebMCPTesting', '--disable-background-networking'], + }); + const context = await browser.newContext({ + viewport: { width: 1280, height: 720 }, + ...(mode === 'record' ? { recordVideo: { dir: artifacts, size: { width: 1280, height: 720 } } } : {}), + }); + const page = await context.newPage(); + const video = page.video(); + await page.goto(baseUrl, { waitUntil: 'networkidle' }); + + const chromeVersion = (await browser.version()).replace(/^Chrome\//, ''); + await page.evaluate((version) => window.proof.chrome(version), chromeVersion); + const nativeSurface = await page.evaluate(() => ({ + context: typeof document.modelContext, + enumerate: typeof document.modelContext?.getTools, + execute: typeof document.modelContext?.executeTool, + })); + assert.deepEqual(nativeSurface, { context: 'object', enumerate: 'function', execute: 'function' }); + assert.equal((await page.evaluate(() => document.modelContext.getTools())).length, 0); + + await page.evaluate(() => { + window.proof.phase('inventory', 'Inventory the existing app'); + window.proof.manifest(); + window.proof.line('$ webmcpify inventory proof/demo'); + }); + await delay(2500); + for (const line of [ + 'DETECT static ES modules · loopback · no auth', + 'AREA release-notes → existing applyFilter(category)', + 'CANDIDATE set_release_filter · client-only mutation', + 'SECURITY no server call · no personal data · no destructive action', + ]) { + await page.evaluate((text) => window.proof.line(text), line); + await delay(2300); + } + + await page.evaluate(() => { + window.proof.phase('gate', 'Stop for manifest approval'); + window.proof.line('GATE discovered → approval required'); + window.proof.gate(); + }); + await delay(6500); + await page.click('#approve'); + await page.evaluate(() => window.proof.line('APPROVED set_release_filter · no-commit fixture policy')); + await delay(3000); + + await page.evaluate(() => { + window.proof.phase('integrate', 'Integrate one bounded tool'); + window.proof.line('SETUP vendored zero-dependency runtime'); + window.dispatchEvent(new CustomEvent('webmcpify:integrate')); + }); + await delay(2600); + for (const line of [ + 'WIRE execute() → existing applyFilter(category)', + 'DIFF one module script + one tool module', + 'BUILD JavaScript syntax check passed', + ]) { + await page.evaluate((text) => window.proof.line(text), line); + await delay(2400); + } + + await page.waitForFunction(async () => (await document.modelContext.getTools()).some((tool) => tool.name === 'set_release_filter')); + await page.evaluate(() => window.proof.phase('verify', 'Verify through native Chrome')); + const tool = await page.evaluate(async () => (await document.modelContext.getTools()).find((item) => item.name === 'set_release_filter')); + assert(tool); + assert.equal(tool.annotations.readOnlyHint, false); + assert.equal(tool.annotations.untrustedContentHint, false); + assert.deepEqual(JSON.parse(tool.inputSchema), { + type: 'object', + properties: { category: { type: 'string', enum: ['all', 'feature', 'fix'] } }, + required: ['category'], + additionalProperties: false, + }); + await page.evaluate(() => { window.proof.check('tool enumerated'); window.proof.line('GETTOOLS set_release_filter found'); }); + await delay(2800); + await page.evaluate(() => { window.proof.check('schema + annotations match'); window.proof.line('SCHEMA parsed stringified JSON Schema · exact match'); }); + await delay(2800); + + const before = await page.locator('article:visible').count(); + const result = await page.evaluate(async () => { + const registered = (await document.modelContext.getTools()).find((item) => item.name === 'set_release_filter'); + return document.modelContext.executeTool(registered, JSON.stringify({ category: 'fix' })); + }); + const after = await page.locator('article:visible').count(); + assert.equal(before, 4); + assert.equal(after, 2); + assert.match(result, /2 release notes visible/); + await page.evaluate(() => { window.proof.check('valid call changed visible UI: 4 → 2'); window.proof.line('EXECUTE category=fix → 2 release notes visible'); }); + await delay(3300); + + const invalidResult = await page.evaluate(async () => { + const registered = (await document.modelContext.getTools()).find((item) => item.name === 'set_release_filter'); + return document.modelContext.executeTool(registered, JSON.stringify({ category: 'private' })); + }); + assert.match(invalidResult, /^ERROR:/); + assert.equal(await page.locator('article:visible').count(), 2); + await page.evaluate(() => { window.proof.check('invalid enum returned bounded error; UI unchanged'); window.proof.line('INVALID category=private → ERROR (no UI side effect)'); }); + await delay(3000); + await page.evaluate(async () => { + const registered = (await document.modelContext.getTools()).find((item) => item.name === 'set_release_filter'); + return document.modelContext.executeTool(registered, JSON.stringify({ category: 'all' })); + }); + assert.equal(await page.locator('article:visible').count(), 4); + await page.evaluate(() => { window.proof.check('cleanup restored all notes'); window.proof.line('CLEANUP category=all → fixture restored'); }); + await delay(3000); + + await page.evaluate(() => { + window.proof.phase('audit', 'Audit and package the evidence'); + window.proof.line('AUDIT every diff hunk maps to the approved tool/setup'); + }); + await delay(2500); + for (const text of [ + 'Sanitized manifest: discovered → approved → verified', + 'No server mutations or production side effects', + 'Source recording + 480p derivative + SHA-256 sums', + ]) { + await page.evaluate((value) => window.proof.check(value), text); + await delay(2400); + } + await page.evaluate(() => window.proof.line('DONE native Chrome verification green · 0 heal attempts')); + await delay(7000); + + await context.close(); + if (mode === 'record') { + const generated = await video.path(); + await unlink(sourceVideo).catch((error) => { if (error.code !== 'ENOENT') throw error; }); + await rename(generated, sourceVideo); + console.log(`recorded ${sourceVideo}`); + } + console.log(`proof verified in Chrome ${chromeVersion}: native getTools/executeTool, schema, annotations, UI delta, bounded invalid input, cleanup`); +} finally { + await browser?.close().catch(() => {}); + await new Promise((resolve) => server.close(resolve)); +} diff --git a/proof/demo/style.css b/proof/demo/style.css new file mode 100644 index 0000000..13e2985 --- /dev/null +++ b/proof/demo/style.css @@ -0,0 +1,32 @@ +:root { color-scheme: dark; --bg:#0d1110; --panel:#151b19; --line:#34413c; --ink:#f2f4ef; --muted:#9ba8a2; --green:#77e3a2; --amber:#ffc96b; } +* { box-sizing:border-box; } +[hidden] { display:none !important; } +body { margin:0; min-height:100vh; background:var(--bg); color:var(--ink); font:18px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; } +header { height:64px; padding:0 36px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); } +header span { color:var(--green); font-size:14px; } +nav { height:58px; padding:0 36px; display:flex; gap:10px; align-items:center; border-bottom:1px solid var(--line); } +nav span { padding:7px 12px; color:var(--muted); border:1px solid transparent; font-size:13px; } +nav span.active { color:var(--bg); background:var(--amber); border-color:var(--amber); } +nav span.done { color:var(--green); border-color:var(--green); } +main { display:grid; grid-template-columns:1fr 1fr; gap:24px; padding:28px 36px; } +section { min-height:560px; padding:28px; border:1px solid var(--line); background:var(--panel); } +.eyebrow { color:var(--amber); font-size:12px; letter-spacing:.12em; font-weight:800; } +h1 { margin:.3em 0 .15em; font:700 42px/1.1 system-ui,sans-serif; } +h2 { margin:.35em 0 .8em; font:700 27px/1.2 system-ui,sans-serif; } +.lede { color:var(--muted); font:16px/1.5 system-ui,sans-serif; } +.filters { display:flex; gap:8px; margin:25px 0 18px; } +button { padding:10px 15px; color:var(--ink); background:#202925; border:1px solid var(--line); font:700 14px inherit; cursor:pointer; } +button[aria-pressed="true"], #approve { color:var(--bg); background:var(--green); border-color:var(--green); } +article { display:flex; justify-content:space-between; padding:15px 2px; border-bottom:1px solid var(--line); font-family:system-ui,sans-serif; } +article small { color:var(--muted); } +#visible-count { color:var(--green); font-size:14px; } +#manifest { padding:18px; border:1px solid var(--amber); background:#101412; } +#manifest span { float:right; color:var(--muted); font-size:13px; } +#manifest p { margin:.65em 0 0; color:var(--muted); font-size:14px; } +#checks { margin-top:20px; } +.check { margin:8px 0; color:var(--green); } +pre { min-height:260px; margin-top:20px; padding:16px; overflow:hidden; white-space:pre-wrap; color:#cdd7d2; background:#090c0b; border:1px solid #27302d; font-size:13px; line-height:1.55; } +dialog { width:620px; color:var(--ink); background:#171e1b; border:2px solid var(--amber); box-shadow:0 24px 90px #000; padding:30px; } +dialog::backdrop { background:rgba(0,0,0,.72); } +dialog p, dialog li { font-family:system-ui,sans-serif; color:#c7d0cb; } +dialog #approve { margin-top:12px; } diff --git a/proof/demo/webmcp-tools.js b/proof/demo/webmcp-tools.js new file mode 100644 index 0000000..96024ad --- /dev/null +++ b/proof/demo/webmcp-tools.js @@ -0,0 +1,25 @@ +import { createToolScope } from '../../skills/webmcpify/templates/webmcpify.js'; +import { applyFilter } from './app.js'; + +const schema = { + type: 'object', + properties: { category: { type: 'string', enum: ['all', 'feature', 'fix'] } }, + required: ['category'], + additionalProperties: false, +}; + +window.addEventListener('webmcpify:integrate', () => { + createToolScope('proof-release-notes', [{ + name: 'set_release_filter', + description: 'Filters the visible synthetic release notes by category using the page existing filter path.', + inputSchema: schema, + annotations: { readOnlyHint: false, untrustedContentHint: false }, + execute: ({ category }) => { + if (!schema.properties.category.enum.includes(category)) { + return 'ERROR: category must be one of all, feature, or fix.'; + } + const count = applyFilter(category); + return `${count} release notes visible for ${category}.`; + }, + }]); +}, { once: true }); diff --git a/proof/integration.patch b/proof/integration.patch new file mode 100644 index 0000000..93f35dd --- /dev/null +++ b/proof/integration.patch @@ -0,0 +1,12 @@ +--- a/proof/demo/index.html ++++ b/proof/demo/index.html +@@ + ++ +--- /dev/null ++++ b/proof/demo/webmcp-tools.js +@@ ++import { createToolScope } from '../../skills/webmcpify/templates/webmcpify.js'; ++import { applyFilter } from './app.js'; ++// Registers only after the recorded human gate dispatches webmcpify:integrate. ++window.addEventListener('webmcpify:integrate', () => createToolScope(...), { once: true }); diff --git a/proof/manifest.after.json b/proof/manifest.after.json new file mode 100644 index 0000000..310e53c --- /dev/null +++ b/proof/manifest.after.json @@ -0,0 +1,58 @@ +{ + "webmcpify": 3, + "app": { + "stack": "static-es-modules", + "typescript": false, + "entry": "proof/demo/index.html", + "baseUrl": "http://127.0.0.1:", + "startCommand": "npm run proof:verify", + "authFixtures": {} + }, + "pipeline": { + "phase": "done", + "setup": { + "runtimeVendored": ["skills/webmcpify/templates/webmcpify.js"], + "harnessInstalled": ["proof/demo/run.mjs"], + "originTrialNoted": ["proof/README.md"] + }, + "baselineSha": "fixture-snapshot", + "baselineDirty": [], + "commitPolicy": "no-commit", + "commitWebmcpifyDir": true, + "blockers": [] + }, + "areas": [{ "id": "release-notes", "paths": ["proof/demo/app.js"], "status": "inventoried" }], + "tools": [{ + "id": "set_release_filter", + "area": "release-notes", + "kind": "imperative", + "mutating": "client", + "priority": 1, + "description": "Filters the visible synthetic release notes by category using the page's existing filter path.", + "inputSchema": { + "type": "object", + "properties": { "category": { "type": "string", "enum": ["all", "feature", "fix"] } }, + "required": ["category"], + "additionalProperties": false + }, + "annotations": { "readOnlyHint": false, "untrustedContentHint": false }, + "source": ["proof/demo/app.js:applyFilter"], + "route": "/", + "auth": ["none"], + "examples": { "valid": { "category": "fix" }, "invalid": { "category": "private" } }, + "expect": { "result": "2 release notes visible", "navigation": null, "ui": "only the two synthetic fix notes remain visible" }, + "cleanup": "execute the same UI path with category=all", + "status": "verified", + "approval": { "note": "Approved as the only client-only mutation in this safe fixture.", "at": "2026-07-22", "productionSideEffect": null }, + "attempts": 0, + "batchCommit": null, + "notes": "Chrome 150 native getTools/executeTool: schema, annotations, valid result, UI delta, cleanup, and bounded ERROR response for an invalid enum verified." + }], + "log": [ + "2026-07-22 inventory: release-notes area complete; one non-overlapping client-only candidate", + "2026-07-22 gate: set_release_filter approved; no-commit fixture policy", + "2026-07-22 integrate: one tool module wired to existing applyFilter path", + "2026-07-22 verify: native Chrome production surface passed", + "2026-07-22 audit: every conceptual diff hunk maps to set_release_filter or recorded setup" + ] +} diff --git a/proof/manifest.before.json b/proof/manifest.before.json new file mode 100644 index 0000000..6f10d3e --- /dev/null +++ b/proof/manifest.before.json @@ -0,0 +1,48 @@ +{ + "webmcpify": 3, + "app": { + "stack": "static-es-modules", + "typescript": false, + "entry": "proof/demo/index.html", + "baseUrl": "http://127.0.0.1:", + "startCommand": "npm run proof:verify", + "authFixtures": {} + }, + "pipeline": { + "phase": "gate", + "setup": { "runtimeVendored": [], "harnessInstalled": [], "originTrialNoted": [] }, + "baselineSha": "fixture-snapshot", + "baselineDirty": [], + "commitPolicy": null, + "commitWebmcpifyDir": null, + "blockers": [] + }, + "areas": [{ "id": "release-notes", "paths": ["proof/demo/app.js"], "status": "inventoried" }], + "tools": [{ + "id": "set_release_filter", + "area": "release-notes", + "kind": "imperative", + "mutating": "client", + "priority": 1, + "description": "Filters the visible synthetic release notes by category using the page's existing filter path.", + "inputSchema": { + "type": "object", + "properties": { "category": { "type": "string", "enum": ["all", "feature", "fix"] } }, + "required": ["category"], + "additionalProperties": false + }, + "annotations": { "readOnlyHint": false, "untrustedContentHint": false }, + "source": ["proof/demo/app.js:applyFilter"], + "route": "/", + "auth": ["none"], + "examples": { "valid": { "category": "fix" }, "invalid": { "category": "private" } }, + "expect": { "result": "2 release notes visible", "navigation": null, "ui": "only the two synthetic fix notes remain visible" }, + "cleanup": "execute the same UI path with category=all", + "status": "discovered", + "approval": null, + "attempts": 0, + "batchCommit": null, + "notes": "No data leaves the browser. No destructive, payment, auth, or server action exists." + }], + "log": ["2026-07-22 inventory: release-notes area complete; one non-overlapping client-only candidate"] +}