Files
mokosh/.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-03-PLAN.md
Mark b3bfbf4a8d feat(03): plans 01-05 — Phase 3 SPEC §10 smoke + DOM/event-log verification
5 plans across 5 waves (Wave 2 sequential per RESEARCH Pitfall 6 file overlap):
- 03-01 Wave 1: rrweb DOM verification harness extension (A29; REQ-rrweb-dom-buffer; §10 #4)
- 03-02 Wave 2: event-log verification harness extension (A30; REQ-user-event-log; §10 #5)
- 03-03 Wave 3: §10 #8 password-filter PARTIAL verification (A31; D-P3-02 charter)
- 03-04 Wave 4: §10 #9 RAM ceiling best-effort + Page.metrics scaffolding (A32; D-P3-04)
- 03-05 Wave 5: §10 sweep VERIFICATION.md + REQUIREMENTS/ROADMAP/STATE marker flips
  (REQ-install-clean + REQ-rrweb-dom-buffer + REQ-user-event-log)

Each plan has:
- frontmatter (wave + depends_on + files_modified + autonomous + requirements + tags + must_haves)
- tasks with mandatory <read_first> + <acceptance_criteria> + concrete <action>
- <threat_model> block per security gate
- Validation map row(s) added to 03-VALIDATION.md (10 tasks total)

Expected UAT growth: 29/29 → 33/33 GREEN (A29-A32 + 03-05 docs).
Expected vitest baseline preserved: 171/171.
Expected Tier-1 FORBIDDEN_HOOK_STRINGS: 12 (A29+ ride production surfaces only).

ROADMAP.md Phase 3 entry replaces "Plans: TBD" with full 5-plan list.
VALIDATION.md status: planner_filled (nyquist_compliant: true; wave_0_complete: true).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:01:21 +02:00

610 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 03
slug: spec-10-smoke-verification-dom-event-log-verification
plan: 03
type: execute
wave: 3
depends_on:
- 01
- 02
files_modified:
- tests/uat/extension-page-harness.ts
- tests/uat/lib/harness-page-driver.ts
- tests/uat/harness.test.ts
autonomous: true
requirements: []
tags:
- uat-harness
- a31
- password-filter
- spec-10-8-partial
- approach-b
- negative-assertion
- charter-d-p3-02
user_setup: []
must_haves:
truths:
- "Typing a sentinel value into the probe-page password input does NOT cause that value to appear in logs/events.json (existing src/content/index.ts:82 filter fires)"
- "Counts of UserEvent entries whose .value field contains the sentinel string = 0"
- "Counts of UserEvent entries whose .target selector points at the password input = 0 (filter happens early-return BEFORE addUserEvent)"
- "UAT harness exits 0 with 31 + 1 = 32/32 assertions GREEN (A30 baseline preserved + new A31)"
artifacts:
- path: "tests/uat/extension-page-harness.ts"
provides: "assertA31 page-side orchestrator: types sentinel into #probe-password, runs setupFreshRecording + SAVE"
contains: "assertA31"
- path: "tests/uat/lib/harness-page-driver.ts"
provides: "driveA31 host-side: JSZip-parse logs/events.json + negative-assertion sentinel grep"
contains: "driveA31"
- path: "tests/uat/harness.test.ts"
provides: "driveA31 import + wrapped driver + drivers-array push entry"
contains: "driveA31"
key_links:
- from: "tests/uat/lib/harness-page-driver.ts driveA31"
to: "tests/uat/extension-page-harness.ts assertA31"
via: "page.evaluate(() => window.__mokoshHarness.assertA31())"
pattern: "harness.assertA31\\(\\)"
- from: "tests/uat/extension-page-harness.ts assertA31"
to: "src/content/index.ts:82 password filter (READ-ONLY VERIFICATION SUBJECT)"
via: "synthetic password-input event triggers setupInputLogging filter early-return BEFORE addUserEvent"
pattern: "if \\(target.type === 'password'\\)"
---
<objective>
Extend the UAT harness with A31 — empirical PARTIAL verification of SPEC
§10 #8. Per D-P3-02 locked decision: REQ-password-confidentiality is
Out of Scope v1; full rrweb v2 maskInputFn + data-sensitive guards are
deferred to Phase 4. Plan 03-03 confirms the EXISTING minimum filter at
`src/content/index.ts:82` (`if (target.type === 'password') return;`)
fires — sentinel string typed into the probe-page `<input type="password">`
MUST NOT appear in `logs/events.json`. This is a negative-assertion gate.
Purpose: Honors the charter literally — verify the existing
defense-in-depth without expanding scope; the PARTIAL mark in Plan
03-05 VERIFICATION.md will cite this A31 GREEN + the charter rationale.
Output: A31 assertion with 3 host-side checks (SAVE ack + sentinel
absent from events.json .value fields + zero events targeting the
password selector); UAT count 31 → 32 GREEN.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/REQUIREMENTS.md
@.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-CONTEXT.md
@.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-RESEARCH.md
@.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-PATTERNS.md
@.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-01-PLAN.md
@.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-02-PLAN.md
@src/content/index.ts
@src/shared/types.ts
<interfaces>
<!-- Key contracts the executor needs. Extracted from existing code. -->
From src/content/index.ts (lines 77-96; READ-ONLY — verification subject):
// setupInputLogging
function setupInputLogging() {
document.addEventListener('input', (event) => {
const target = event.target as HTMLInputElement;
// Пропускаем пароли (line 81 comment; "skip passwords")
if (target.type === 'password') { // LINE 82 — the filter
return; // LINE 83 — early-return BEFORE addUserEvent
}
const selector = getSelector(target);
addUserEvent({ // LINE 88 — never reached for password inputs
timestamp: Date.now(),
type: 'input',
target: selector,
value: target.value.substring(0, 200),
url: window.location.href
});
}, true);
}
From src/shared/types.ts (lines 124-131):
export interface UserEvent {
timestamp: number;
type: 'click' | 'input' | 'navigation' | 'js_error' | 'network_error';
target: string; // CSS selector
value?: string; // What the user typed (up to 200 chars; omitted for passwords)
url: string;
meta?: Record<string, unknown>;
}
From Plan 03-01: probe HTML in extension-page-harness.html provides
`<input type="password" id="probe-password" data-test="probe-password" />`.
The id-based selector means getSelector(target) returns `#probe-password`
for any logged event on this element (per src/content/index.ts:241-243).
</interfaces>
# Plan Anchors
- **Sequential wave assignment (per RESEARCH Pitfall 6 + file-overlap rule):** Plan 03-03 lives in wave 3
modifies the SAME 3 harness files as Plans 03-01 + 03-02. depends_on:
[01, 02] enforces sequential ordering.
- **Negative-assertion pattern (per RESEARCH Pattern 3 + Plan 02-04
A27.7/A27.8 absence-check precedent):** The contract is
`userEvents.filter(e => e.value contains SENTINEL).length === 0`.
GREEN A31 = filter fired; RED A31 = filter regressed.
- **Sentinel is a fixed test constant, not a real secret:** Per RESEARCH
Security Domain table, `secret-do-not-log-123` is a probe sentinel;
logging it would itself trigger an explicit RED. No PII.
- **Targeting via id `#probe-password`:** the production getSelector at
src/content/index.ts:240 returns `#${element.id}` when an id is
present, so any event on the password input would have
`target === '#probe-password'`. A31.3 asserts zero such entries.
- **Page-side approach with native dispatch:** set `.value` directly +
dispatch `Event('input', { bubbles: true })` → fires production
listener at line 78 → filter at line 82 early-returns.
- **Probe HTML reuse:** Plan 03-01 added the password input; Plan 03-03
reuses it; no new HTML.
- **PARTIAL mark scope (D-P3-02 — NOT in scope):** rrweb v2 maskInputFn,
data-sensitive guards, full §10 #8 closure deferred to Phase 4.
- **FORBIDDEN_HOOK_STRINGS lockstep:** A31 rides production listeners +
existing helpers. Tier-1 inventory stays at 12 entries.
</context>
<tasks>
<task type="auto" tdd="false">
<name>Task 1: Add assertA31 page-side orchestrator (sentinel-into-password trigger + SAVE)</name>
<files>tests/uat/extension-page-harness.ts</files>
<read_first>
- tests/uat/extension-page-harness.ts where Plan 03-02 added assertA30 (study its shape; new assertA31 appends after it)
- tests/uat/extension-page-harness.html (verify Plan 03-01 added the password input with id=probe-password)
- src/content/index.ts lines 77-96 (the exact filter being verified)
</read_first>
<behavior>
Adds module-local constants for A31 and the page-side assertA31
function that:
- Step 1: setupFreshRecording (clean event-log window)
- Step 2: settle one segment
- Step 3: set #probe-password.value to SENTINEL + dispatch input event (fires production listener; filter early-returns)
- Step 4: settle synchronous handler completion
- Step 5: dispatch SAVE_ARCHIVE
- Push A31.1 (SAVE ack)
- Registers assertA31 on declare global + __mokoshHarness object literal
- Updates trailing console.log to mention Plan 03-03: A31
</behavior>
<action>
1. Open `tests/uat/extension-page-harness.ts`. Locate the assertA30 block added by Plan 03-02 and the `getManifestVersion` declaration following it.
2. Insert the new assertA31 block AFTER assertA30 (BEFORE getManifestVersion). Use this concrete code:
```typescript
/* ─── Plan 03-03 — A31 (password-filter PARTIAL; SPEC §10 #8) ────────
*
* A31 — D-P3-02 PARTIAL: verify the existing minimum filter at
* src/content/index.ts:82 (`if (target.type === 'password') return;`)
* fires when the operator types into a password input.
* Negative-assertion contract: SENTINEL value MUST be absent
* from logs/events.json.
*
* Charter alignment (per CONTEXT.md D-P3-02 + REQUIREMENTS.md line 271):
* - REQ-password-confidentiality moved Out of Scope v1 per 2026-05-20
* charter "we don't care about privacy hardening. At least here."
* - Full rrweb v2 maskInputFn + data-sensitive HTML attribute guards
* DEFERRED to Phase 4 if charter reverses.
* - A31 verifies the EXISTING minimum (the line-82 filter) — does
* NOT expand scope.
*
* FORBIDDEN_HOOK_STRINGS impact: NONE. Tier-1 inventory stays at 12.
*/
/** SAVE_ARCHIVE dispatch timeout for A31 — matches A24/A25/A27/A29/A30. */
const A31_SAVE_ARCHIVE_TIMEOUT_MS = 15_000;
/** Pre-SAVE segment-settle window (10s rotation + 1s slack). */
const A31_SEGMENT_SETTLE_MS = 11_000;
/** Settle after sentinel-typing trigger so the synchronous handler completes. */
const A31_TRIGGER_SETTLE_MS = 500;
/** Fixed test sentinel — distinctive string the negative-assertion
* searches for in events.json. Per RESEARCH §"Security Domain":
* this is a probe sentinel, NOT a real secret; logging it would
* itself trigger an explicit RED. */
const A31_PASSWORD_SENTINEL = 'secret-do-not-log-123';
/** Production CSS selector returned by getSelector() at
* src/content/index.ts:241 for the password input (which has id).
* Drives A31.3 (target-absence check). */
const A31_PASSWORD_SELECTOR = '#probe-password';
/**
* A31 — Password-filter PARTIAL empirical (SPEC §10 #8 PARTIAL per D-P3-02).
*
* Types the sentinel string into the probe-page password input then
* SAVEs. Host-side driveA31 inspects logs/events.json and asserts
* absence of (a) the sentinel value and (b) any entries targeting
* the password selector.
*
* @returns AssertionResult with 1 page-side check (SAVE ack); host-side
* driveA31 appends 2 negative-assertion checks.
*/
async function assertA31(): Promise<AssertionResult> {
const result: AssertionResult = {
passed: false,
name: 'A31 — password filter fires (SPEC §10 #8 PARTIAL per D-P3-02)',
checks: [],
diagnostics: [],
};
try {
diag(result, 'Step 1: setupFreshRecording (A31 owns its recording — clean event-log window)');
const setupResp = await setupFreshRecording();
if (!setupResp.ok) {
throw new Error(`setupFreshRecording failed: ${setupResp.error ?? '(no error)'}`);
}
diag(result, 'Step 1 OK — REC state established');
diag(result, `Step 2: settle ${A31_SEGMENT_SETTLE_MS}ms for first segment rotation`);
await new Promise((r) => setTimeout(r, A31_SEGMENT_SETTLE_MS));
diag(result, `Step 3: type SENTINEL into ${A31_PASSWORD_SELECTOR} + dispatch input event`);
const pwInput = document.querySelector<HTMLInputElement>(A31_PASSWORD_SELECTOR);
if (pwInput === null) {
throw new Error(`A31 trigger failed: ${A31_PASSWORD_SELECTOR} not found in DOM (probe HTML regression from Plan 03-01)`);
}
pwInput.value = A31_PASSWORD_SENTINEL;
pwInput.dispatchEvent(new Event('input', { bubbles: true }));
diag(result, 'Step 3 OK — sentinel typed; production filter at src/content/index.ts:82 should have early-returned');
diag(result, `Step 4: settle ${A31_TRIGGER_SETTLE_MS}ms so synchronous handlers complete`);
await new Promise((r) => setTimeout(r, A31_TRIGGER_SETTLE_MS));
diag(result, 'Step 5: dispatch SAVE_ARCHIVE');
const ack = await sendMessageWithTimeout<{ success: boolean; error?: string }>(
{ type: 'SAVE_ARCHIVE' },
A31_SAVE_ARCHIVE_TIMEOUT_MS,
'SAVE_ARCHIVE (A31)',
);
diag(result, `Step 5 result: ${JSON.stringify(ack)}`);
result.checks.push({
name: 'A31.1: SAVE_ARCHIVE ack received with success=true',
expected: true,
actual: ack.success,
passed: ack.success === true,
});
result.passed = result.checks.every((c) => c.passed);
} catch (err) {
result.error = err instanceof Error ? err.message : String(err);
diag(result, `THREW: ${result.error}`);
}
return result;
}
```
3. In the `declare global { interface Window { __mokoshHarness: { ... } } }` block, AFTER the assertA30 entry from Plan 03-02 and BEFORE `getManifestVersion`, insert:
```typescript
// Plan 03-03 — password-filter PARTIAL (SPEC §10 #8 PARTIAL per D-P3-02)
assertA31: () => Promise<AssertionResult>;
```
4. In the `window.__mokoshHarness = { ... }` object literal, AFTER `assertA30,` and BEFORE `getManifestVersion,` insert:
```typescript
assertA31,
```
5. Update the closing `console.log(...)` line to append `+ Plan 03-03: A31`. Concrete replacement:
```typescript
console.log('[harness-page] ready — window.__mokoshHarness installed (Plan 01-13 Task 9: A1..A14 + Plan 01-10 Wave 3: A15..A17 + Plan 01-12 Wave 6: A18..A22 + Plan 01-14: A23 + Plan 02-04 Tasks 1-2: A24+A25 + Plan 02-04 Task 3: A26+A27+A28 + Plan 03-01: A29 + Plan 03-02: A30 + Plan 03-03: A31 + getManifestVersion)');
```
6. Run `npx tsc --noEmit`. Expected: clean.
</action>
<verify>
<automated>npx tsc --noEmit; T=$(grep -c "assertA31" tests/uat/extension-page-harness.ts); test "$T" -ge 3 &amp;&amp; S=$(grep -c "A31_PASSWORD_SENTINEL" tests/uat/extension-page-harness.ts); test "$S" -ge 2 &amp;&amp; grep -q "'secret-do-not-log-123'" tests/uat/extension-page-harness.ts</automated>
</verify>
<acceptance_criteria>
- `npx tsc --noEmit` exits 0.
- `grep -c 'assertA31' tests/uat/extension-page-harness.ts` returns >=3 (function definition + declare global entry + object literal entry).
- `grep -c 'A31_PASSWORD_SENTINEL' tests/uat/extension-page-harness.ts` returns >=2 (declaration + usage).
- `grep -c \"'secret-do-not-log-123'\" tests/uat/extension-page-harness.ts` returns exactly 1.
- Existing Plan 03-02 assertA30 entry still present.
</acceptance_criteria>
<done>
Page-side assertA31 types sentinel into the password input + SAVEs; registered on __mokoshHarness; ready for Task 2 host-side driveA31 negative-assertion.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 2: Add driveA31 host-side (sentinel-absence grep) + orchestrator wiring</name>
<files>tests/uat/lib/harness-page-driver.ts, tests/uat/harness.test.ts</files>
<read_first>
- tests/uat/lib/harness-page-driver.ts where Plan 03-02 added driveA30 (same UserEvent import; same 3-phase shape)
- tests/uat/harness.test.ts where Plan 03-02 added driveA30 wrapping + drivers entry
- .planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-PATTERNS.md §"Negative-assertion pattern (Plan 03-03 password sentinel grep)"
</read_first>
<behavior>
Host-side (`tests/uat/lib/harness-page-driver.ts`):
- The `UserEvent` type import added by Plan 03-02 is reused (no new import needed).
- Adds `export async function driveA31(page: Page, downloadsDir: string): Promise<AssertionRecord>`:
- Phase 1 — page.evaluate harness.assertA31()
- Phase 2 — findLatestZip; A31.0 if null
- Phase 3 — JSZip.loadAsync; read logs/events.json; A31.0a if absent
- Parse as UserEvent[]; A31.0b on JSON parse failure
- Push check A31.2: events whose .value contains SENTINEL = 0
(negative-assertion proves the filter fired)
- Push check A31.3: events with target === '#probe-password' = 0
(filter early-returns BEFORE addUserEvent)
- Diagnostic includes count of total userEvents + count of events that match #probe-password selector + count of events whose value contains sentinel
- Filter-pipeline form (no `continue`).
Orchestrator (`tests/uat/harness.test.ts`):
- Adds `driveA31,` to import block (after `driveA30,`).
- Adds `driveA31Wrapped` const after driveA30Wrapped.
- Adds `{ name: 'A31', drive: driveA31Wrapped },` to drivers array after the A30 entry.
- Updates orchestrator banner to append `, A31`.
</behavior>
<action>
1. Open `tests/uat/lib/harness-page-driver.ts`. At the end of the file (AFTER driveA30 added by Plan 03-02), append:
```typescript
/* ─── Plan 03-03 — driveA31 (password-filter PARTIAL host-side) ─────── */
/** Fixed test sentinel — same value as page-side A31_PASSWORD_SENTINEL.
* Negative-assertion driver searches events.json for its absence. */
const A31_PASSWORD_SENTINEL = 'secret-do-not-log-123';
/** Selector the production getSelector returns for #probe-password. */
const A31_PASSWORD_SELECTOR = '#probe-password';
/**
* Drive A31 (Plan 03-03 — SPEC §10 #8 PARTIAL per D-P3-02).
*
* Page-side assertA31 typed SENTINEL into the password input then
* SAVEd. Host-side asserts that:
* - the sentinel string is ABSENT from any UserEvent.value field
* (proves the line-82 filter early-returned before addUserEvent)
* - no UserEvent has target === '#probe-password' (proves the same
* filter via the orthogonal selector path)
*
* Filter-pipeline form (no `continue`) per CLAUDE.md Control Flow §.
*
* Checks (3 total):
* - A31.1: SAVE_ARCHIVE ack (page-side)
* - A31.2: 0 events contain SENTINEL in .value field
* - A31.3: 0 events have target === '#probe-password'
*
* @param page - The harness page from `launchHarnessBrowser`.
* @param downloadsDir - Absolute path to the per-run downloads directory.
* @returns AssertionRecord with 3 merged checks.
*/
export async function driveA31(
page: Page,
downloadsDir: string,
): Promise<AssertionRecord> {
// Phase 1 — page-side orchestration + SAVE.
const pageResult = await page.evaluate(async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose.
const harness = (window as any).__mokoshHarness;
const r: AssertionRecord = await harness.assertA31();
return r;
}) as AssertionRecord;
const mergedChecks: CheckRecord[] = pageResult.checks.slice();
const mergedDiagnostics: string[] = pageResult.diagnostics.slice();
// Phase 2 — locate the produced zip.
const zipPath = findLatestZip(downloadsDir);
if (zipPath === null) {
mergedChecks.push({
name: 'A31.0: at least one zip present in downloadsDir',
expected: '>=1 zip',
actual: 'no zip in downloadsDir',
passed: false,
});
return {
passed: false,
name: pageResult.name,
checks: mergedChecks,
diagnostics: mergedDiagnostics,
error: pageResult.error,
};
}
mergedDiagnostics.push(`A31 zipPath=${zipPath}`);
// Phase 3 — load + inspect logs/events.json.
const zipBytes = readFileSync(zipPath);
const zip = await JSZip.loadAsync(zipBytes);
const eventsFile = zip.file('logs/events.json');
mergedChecks.push({
name: 'A31.0a: logs/events.json entry exists in zip',
expected: true,
actual: eventsFile !== null,
passed: eventsFile !== null,
});
if (eventsFile === null) {
return {
passed: false,
name: pageResult.name,
checks: mergedChecks,
diagnostics: mergedDiagnostics,
error: pageResult.error,
};
}
const eventsRaw = await eventsFile.async('string');
let userEvents: UserEvent[] = [];
let parseErr: string | null = null;
try {
userEvents = JSON.parse(eventsRaw) as UserEvent[];
} catch (err) {
parseErr = err instanceof Error ? err.message : String(err);
}
if (parseErr !== null) {
mergedChecks.push({
name: 'A31.0b: logs/events.json parses as JSON',
expected: 'JSON.parse success',
actual: `<error: ${parseErr}>`,
passed: false,
});
return {
passed: false,
name: pageResult.name,
checks: mergedChecks,
diagnostics: mergedDiagnostics,
error: pageResult.error,
};
}
const eventsContainingSentinel = userEvents.filter(
(e) => typeof e.value === 'string' && e.value.includes(A31_PASSWORD_SENTINEL),
);
const eventsTargetingPassword = userEvents.filter(
(e) => e.target === A31_PASSWORD_SELECTOR,
);
mergedDiagnostics.push(`A31 userEvents.length=${userEvents.length}`);
mergedDiagnostics.push(
`A31 sentinel-containing count=${eventsContainingSentinel.length}, password-targeting count=${eventsTargetingPassword.length}`,
);
mergedChecks.push({
name: 'A31.2: 0 UserEvent entries contain the SENTINEL in their .value field (proves src/content/index.ts:82 filter fired)',
expected: 0,
actual: eventsContainingSentinel.length,
passed: eventsContainingSentinel.length === 0,
});
mergedChecks.push({
name: `A31.3: 0 UserEvent entries have target === '${A31_PASSWORD_SELECTOR}' (filter early-returns BEFORE addUserEvent)`,
expected: 0,
actual: eventsTargetingPassword.length,
passed: eventsTargetingPassword.length === 0,
});
const mergedPassed = mergedChecks.every((c) => c.passed);
return {
passed: mergedPassed,
name: pageResult.name,
checks: mergedChecks,
diagnostics: mergedDiagnostics,
error: pageResult.error,
};
}
```
2. Open `tests/uat/harness.test.ts`. In the import block from `./lib/harness-page-driver`, AFTER `driveA30,` and BEFORE `getManifestVersion,` add:
```typescript
// Plan 03-03 — password-filter PARTIAL (SPEC §10 #8 PARTIAL per D-P3-02)
driveA31,
```
3. AFTER the `driveA30Wrapped` const (added by Plan 03-02), add:
```typescript
// Plan 03-03 — driveA31 needs downloadsDir for host-side JSZip
// negative-assertion against logs/events.json.
const driveA31Wrapped: (page: import('puppeteer').Page) => Promise<AssertionRecord> =
(page) => driveA31(page, handles.downloadsDir);
```
4. In the drivers array, AFTER the `{ name: 'A30', ... }` entry, add:
```typescript
// Plan 03-03 A31: password-filter PARTIAL (SPEC §10 #8 PARTIAL per
// D-P3-02). Negative-assertion: types sentinel into password input;
// host-side asserts absence from logs/events.json (proves the
// existing src/content/index.ts:82 filter fires).
{ name: 'A31', drive: driveA31Wrapped },
```
5. Update the orchestrator banner line (line 268) to append `, A31`:
```typescript
process.stdout.write('Architecture: A0 pre-flight + extension-internal page driver (A1..A14, A15..A17, A18..A22, A23, A24, A25, A26, A27, A28, A29, A30, A31)\n');
```
6. Run `npx tsc --noEmit`. Expected: clean.
7. Run `HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat`. Expected: `32/32 GREEN`.
</action>
<verify>
<automated>npx tsc --noEmit; D=$(grep -c "driveA31" tests/uat/lib/harness-page-driver.ts); test "$D" -ge 2 &amp;&amp; H=$(grep -c "driveA31" tests/uat/harness.test.ts); test "$H" -ge 3 &amp;&amp; HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat</automated>
</verify>
<acceptance_criteria>
- `npx tsc --noEmit` exits 0.
- `grep -c 'driveA31' tests/uat/lib/harness-page-driver.ts` returns >=2.
- `grep -c 'driveA31' tests/uat/harness.test.ts` returns >=3.
- `grep -c 'secret-do-not-log-123' tests/uat/lib/harness-page-driver.ts` returns exactly 1.
- `HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat` exits 0 with stdout containing `UAT harness: 32/32 assertions passed`.
- `npm test -- --run tests/background/no-test-hooks-in-prod-bundle.test.ts` exits 0 (Tier-1 inventory stays at 12).
</acceptance_criteria>
<done>
UAT harness runs 32/32 GREEN with A31 verifying the §10 #8 PARTIAL
contract: typing into a password input produces zero events whose
value contains the sentinel and zero events targeting the password
selector. The existing src/content/index.ts:82 filter is the
verified mechanism; full masking remains Out of Scope v1 per D-P3-02.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Puppeteer host ↔ page realm | Test harness drives via page.evaluate; production filter at src/content/index.ts:82 runs inside the page content script |
| Page realm ↔ content script | Synthetic input event dispatched on #probe-password reaches the production listener at src/content/index.ts:78 |
| Filter ↔ event log | Negative-assertion contract: filter at line 82 prevents value from reaching userEvents[] which is what assembles logs/events.json |
| dist-test/ ↔ dist/ | Two-bundle separation: Plan 03-03 adds NO test-only symbols; production bundle invariant unchanged |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-03-01 | Information Disclosure | Sentinel value lands in event log despite filter | mitigate | A31 IS the negative-assertion mitigation — RED A31 means the filter regressed; the test enforces the invariant. Sentinel is not a real secret (RESEARCH §"Security Domain"); if it leaked, it would be visible in events.json which is logged but never transmitted (REQ-password-confidentiality Out of Scope v1 charter applies). |
| T-03-03-02 | Information Disclosure | Test-only hook surface leaking to production bundle | mitigate | A31 rides the production input listener; no `__MOKOSH_UAT__`-gated symbols. FORBIDDEN_HOOK_STRINGS unchanged at 12 entries. |
| T-03-03-03 | Tampering | A31 SAVE produces a zip in the per-run downloadsDir that contains the sentinel only IF the filter regressed | accept | The per-run downloadsDir is mkdtempSync'd by launchHarnessBrowser + cleaned by the test runner; no cross-run leakage. Sentinel is not a real secret. |
| T-03-03-04 | Repudiation | If A31 GREEN but the production filter actually broke, the assertion would mislead | mitigate | A31 checks two orthogonal paths: (a) sentinel-value absence and (b) password-selector-target absence. Both pass IFF the filter early-returns BEFORE addUserEvent. A regression in the filter would cause AT LEAST ONE of the two checks to RED. Defense-in-depth at the test layer. |
No new production surface; threat surface unchanged from Phase 2. The
existing src/content/index.ts:82 filter is the verification subject;
the PARTIAL mark in Plan 03-05 VERIFICATION.md explicitly carries the
charter rationale.
</threat_model>
<verification>
- `npx tsc --noEmit` exits 0.
- `HEADLESS=1 SKIP_PROD_REBUILD=0 npm run test:uat` exits 0 with 32/32 GREEN.
- `npm test -- --run tests/background/no-test-hooks-in-prod-bundle.test.ts` exits 0 (12 FORBIDDEN_HOOK_STRINGS × 0 hits each).
- A31 diagnostic line shows `sentinel-containing count=0, password-targeting count=0`.
</verification>
<success_criteria>
- A31 GREEN with 3 merged checks (SAVE ack + 2 negative-assertions).
- The existing src/content/index.ts:82 password filter is verified to
fire on synthetic input events into the probe-page password element.
- FORBIDDEN_HOOK_STRINGS unchanged at 12 entries.
- vitest baseline preserved (171/171 GREEN).
- Plan 03-05 VERIFICATION.md will mark §10 #8 as PARTIAL with explicit
charter citation (D-P3-02) and reference A31 GREEN as the existing-
minimum evidence.
</success_criteria>
<output>
After completion, create
`.planning/phases/03-spec-10-smoke-verification-dom-event-log-verification/03-03-SUMMARY.md`
documenting:
- Negative-assertion contract verified (2 orthogonal absence checks)
- D-P3-02 charter alignment (existing-minimum verification; not scope expansion)
- UAT 31 → 32 GREEN; Tier-1 inventory unchanged at 12
- Plan 03-04 wave dependency: SAME 3 harness files; SEQUENTIAL within Wave 2 per RESEARCH Pitfall 6
</output>