---
phase: 02-stabilize-export-pipeline
plan: 03
subsystem: export-pipeline
tags:
  - meta-json-urls-array
  - tab-url-tracking
  - schema-amendment
  - p1-10-fix
  - d-p2-02
  - d-p2-03
  - dec-011-amendment-1
  - wave-2-green

# Dependency graph
requires:
  - phase: 02-stabilize-export-pipeline
    provides: "Plan 02-01 RED gate (tests/background/meta-json-urls-schema.test.ts pinning SessionMetadata urls field + createArchive meta.urls + tab-url-tracker module seam + F2 empty-tracker contract); tests/build/strict-meta-json-validation.test.ts pinning 8-field exact schema with EXPECTED_KEYS[schemaVersion]. Plan 02-02 Plan 02-02 Blob URL pipeline + extended test helpers for CREATE_DOWNLOAD_URL round-trip simulation (capturedArchiveBytes pattern)."
  - phase: 01-stabilize-video-pipeline
    provides: "Logger + chrome.* listener defensive-try/catch pattern (src/background/index.ts:bootstrap)"
provides:
  - "tab-url-tracker module (src/background/tab-url-tracker.ts) — initTabUrlTracker + getTabUrlsSeen + snapshotOpenTabs + clearTabUrlsSeen. Filters via positive-allow regex; deduplicates via Set; preserves first-seen ordering via array. 246 LOC incl. docstrings."
  - "SessionMetadata 8-field schema (replaces 7-field shape): schemaVersion + timestamp + urls + userAgent + extensionVersion + videoBufferSeconds + logDurationMinutes + totalEvents. JSON.stringify emits in insertion order per ECMA-262."
  - "meta.urls assembled in createArchive: snapshotOpenTabs() before getTabUrlsSeen() (DEC-011 Amendment 1 capability — captures tabs opened but never activated). Empty array IS the canonical representation of whole-desktop-no-tab sessions (F2)."
  - "Always-on charter preserved: createArchive does NOT call clearTabUrlsSeen — tracker accumulates across saves (Plan 01-09 Amendment 3 invariant)."
  - "REQ-meta-json-schema text amended to the new 8-field shape with breaking-change cutover documented; traceability table updated."
affects:
  - "Plan 02-04 (Wave 3 UAT harness extensions — A27 multi-tab strict-mode assertion now unblocked; A28 zip-layout pin orthogonal)"
  - "Any downstream meta.json consumer (UAT harness, future v2 SRV-* uploads, operator post-mortem tooling) — schema cutover surfaces here, ratified by tests/build/strict-meta-json-validation.test.ts EXPECTED_KEYS"

# Tech tracking
tech-stack:
  added: []
  patterns:
    - "Tier-1-gate-preserving test wiring: when an implementation module wants to expose ergonomic `_resetForTesting` / `_observeForTesting` hooks, the alternative is to drive the registered chrome.* callbacks directly via the test stub's `_callbacks` array. The tracker's `initTabUrlTracker()` registers its listeners on the stub; the test then calls `stub.tabs.onUpdated._callbacks.forEach(cb => cb(...))` to seed observations. Preserves the FORBIDDEN_HOOK_STRINGS inventory at its current size; module state resets via vitest's vi.resetModules() in beforeEach."
    - "Positive-allow URL filter regex: `/^(https?|chrome-extension):\\/\\//` covers the entire CONTEXT.md `<specifics>` allow-list in one anchor. Replaces the alternative long-form switch over schemes."
    - "Defensive snapshot-at-SAVE pattern: chrome.tabs.query({}) inside snapshotOpenTabs() folds every currently-open tab.url into the tracker's Set + firstSeenOrder. Dedup gates the array push; tabs already observed via onActivated/onUpdated stay where they are. Captures the operator-opened-but-never-activated tab gap that DEC-011 Amendment 1 enables."

key-files:
  created:
    - "src/background/tab-url-tracker.ts (246 LOC; 4 exports: initTabUrlTracker, getTabUrlsSeen, snapshotOpenTabs, clearTabUrlsSeen)"
    - ".planning/phases/02-stabilize-export-pipeline/02-03-SUMMARY.md (this file)"
  modified:
    - "src/shared/types.ts (SessionMetadata 7→8 fields: url removed; urls + schemaVersion added; docstring cites D-P2-02 + D-P2-03)"
    - "src/background/index.ts (import tab-url-tracker; register initTabUrlTracker at module top-level alongside chrome.downloads.onChanged; createArchive metadata block rewritten — 8 fields, snapshotOpenTabs+getTabUrlsSeen, F2 empty-array emission with diagnostic logger.warn)"
    - "tests/background/meta-json-urls-schema.test.ts (Tests 3+4 rewired to drive chrome.tabs.onUpdated callbacks directly via the chrome stub's _callbacks array; Test 5 simplified — fresh-module-import-on-vi.resetModules gives the empty representation. Rule 3 deviation: preserves Tier-1 gate at 13 entries.)"
    - ".planning/REQUIREMENTS.md (REQ-meta-json-schema amended for 8-field shape + F2 empty-array permission; traceability table updated; footer line dated 2026-05-20)"

key-decisions:
  - "Tracker module exposes NO `_resetForTesting` / `_observeForTesting` hooks. Reason: vitest's __MOKOSH_UAT__: 'false' define would tree-shake gated test hooks the same way vite.config.ts does, so the conventional `if (__MOKOSH_UAT__)` pattern cannot work for unit tests. Exposing ungated hooks would leak names into the production bundle and grow the Tier-1 FORBIDDEN_HOOK_STRINGS inventory from 13 → 15. The chosen path (test code drives chrome.tabs.onUpdated callbacks directly via the stub's _callbacks array) preserves the 13-entry gate without sacrificing test ergonomics. Plan 02-01 SUMMARY explicitly anticipated this fallback."
  - "URL filter via positive-allow regex (^(https?|chrome-extension):\\/\\/) rather than switch-over-schemes. Single canonical anchor; easy to grep; matches the EXPECTED regex used by tests/build/strict-meta-json-validation.test.ts URL_SCHEME_ALLOW pin."
  - "snapshotOpenTabs as a separate async export rather than an inline chrome.tabs.query inside createArchive. Reason: keeps the tracker module self-contained (filter + dedup logic stays in one place); createArchive becomes a pure consumer (await snapshotOpenTabs → getTabUrlsSeen → done); test paths for snapshotOpenTabs become first-class (Plan 02-04 may pin this directly)."
  - "8th field name `schemaVersion` ratified per Plan 02-01 Task 3 planner pick. Value '2' marks the D-P2-02 url→urls cutover. tests/build/strict-meta-json-validation.test.ts EXPECTED_KEYS held this string since Plan 02-01; no lockstep change needed."
  - "Tracker initialization registered at module top-level alongside the other chrome.* listeners (chrome.downloads.onChanged + chrome.action.onClicked + chrome.runtime.onStartup + chrome.notifications.onClicked). Reason: matches the canonical listener-registration pattern; defensive try/catch wraps the call; idempotency handled inside the tracker module via the `initialized` flag (so the SW-respawn path that calls initialize() at runtime can't double-register)."
  - "createArchive does NOT call clearTabUrlsSeen. Always-on charter preserved per Plan 01-09 Amendment 3: SAVE creates a zip with the CURRENT tracker state; tracker continues accumulating across saves. The next save captures any tabs the operator activates AFTER the prior save."
  - "F2 empty-array emission: createArchive logs a diagnostic warn (`tabUrlsSeen is empty after snapshotOpenTabs — emitting meta.urls=[]`) but still emits the empty array. Whole-desktop-no-tab sessions are meaningful operator state; no fake extension-origin URL is inserted."

patterns-established:
  - "Tier-1-gate-preserving test wiring (see tech-stack.patterns entry above): when the natural test-ergonomic hook would leak into production, drive the underlying chrome.* listeners directly. Generalizes beyond tab-url-tracker — any future module that registers chrome.* listeners can be tested this way."
  - "Two-step SAVE-time URL capture: passive accumulation via chrome.tabs.onActivated + chrome.tabs.onUpdated listeners (always-on) PLUS active snapshot via chrome.tabs.query({}) at SAVE time (defensive fallback for never-activated tabs). Dedup + ordering invariants gate the combination; the active snapshot is purely additive."

requirements-completed: []
# Per plan-checker output + execution context: REQ-meta-json-schema
# implementation lands here, but the requirement is marked Complete by
# Plan 02-04 after the UAT harness empirically validates the 8-field
# shape end-to-end. The traceability table entry was updated to
# "Pending (implementation landed via Plan 02-03; harness validation
# deferred to Plan 02-04)" — explicit forward link.

# Metrics
duration: 7min
completed: 2026-05-20
---

# Phase 2 Plan 03: meta.json urls[] + schemaVersion (D-P2-02 + D-P2-03)

**Replaces SessionMetadata.url:string with SessionMetadata.urls:string[]; adds schemaVersion as the 8th field; introduces src/background/tab-url-tracker.ts (4 exports) fed by chrome.tabs.onActivated + onUpdated + a SAVE-time chrome.tabs.query({}) snapshot.**

## Performance

- **Duration:** ~7 min
- **Started:** 2026-05-20T14:02:58Z
- **Completed:** 2026-05-20T14:09:52Z
- **Tasks:** 4/4 (Task 0 verification + 3 implementation)
- **Files created:** 2 (src/background/tab-url-tracker.ts + this SUMMARY)
- **Files modified:** 4 (src/shared/types.ts + src/background/index.ts + tests/background/meta-json-urls-schema.test.ts + .planning/REQUIREMENTS.md)

## Accomplishments

- **Audit P1 #10 closed.** meta.json now captures the operator's multi-tab context across the rolling 30 s recording window — not just the active-at-save tab. `meta.urls` is a deduplicated, first-seen-ordered, filtered Array; F2 empty-array contract preserved for whole-desktop-no-tab sessions.
- **8 Plan 02-01 RED tests flipped GREEN** across two files:
  - tests/background/meta-json-urls-schema.test.ts: Tests 1+2+3+4+5 (SessionMetadata source-text pin; createArchive meta.urls shape; tracker dedup/order; tracker URL filter; tracker empty-state F2 contract).
  - tests/build/strict-meta-json-validation.test.ts: Tests 1+3+8 (exact 8-field count; urls Array of filter-matching URLs with empty permitted; no extra fields beyond EXPECTED_KEYS).
- **D-P2-02 wire contract landed:** SessionMetadata interface (src/shared/types.ts) reshaped from 7 → 8 fields with a docstring citing D-P2-02 + D-P2-03 + Plan 02-01 Task 3 + F2.
- **D-P2-03 strict 8-field shape pinned:** createArchive emits schemaVersion: '2' + urls (Array, possibly empty) + the 6 unchanged fields in source-declaration order; JSON.stringify preserves insertion order per ECMA-262.
- **DEC-011 Amendment 1 verified in place:** manifest.json includes `"tabs"` (already landed in the plan-checker iteration-1 revision pass via commits `9dcfcf0` + `df8c086`); PROJECT.md DEC-011 row carries Amendment 1 prose; tests/i18n/manifest-i18n.test.ts permission-set describe block 12/12 GREEN.
- **Tier-1 FORBIDDEN_HOOK_STRINGS gate preserved at 13 entries.** The tracker module does NOT export `_resetForTesting` / `_observeForTesting` ergonomic hooks; tests drive chrome.tabs.onUpdated callbacks directly. Production bundle scan: zero matches for any test-hook string.

## Task Commits

Each task was committed atomically (--no-verify per parallel-executor mandate):

1. **Task 0: DEC-011 Amendment 1 verification** — NO-OP this executor; state landed at base commit `d3aa567` from plan-checker iteration-1 revision pass (commits `9dcfcf0` + `df8c086`).
2. **Task 1: src/background/tab-url-tracker.ts module + test wiring** — `7beb690` (feat).
3. **Task 2: SessionMetadata + createArchive + initialize wiring** — `78031e7` (feat).
4. **Task 3: REQUIREMENTS.md REQ-meta-json-schema amendment** — `af03556` (docs).

## Files Created/Modified

### Production source

- **`src/background/tab-url-tracker.ts`** (new; 246 LOC incl. docstrings)
  - 4 exports: `initTabUrlTracker(): void`, `getTabUrlsSeen(): string[]`, `snapshotOpenTabs(): Promise<void>`, `clearTabUrlsSeen(): void`.
  - Module state: `tabUrlsSeen: Set<string>` (O(1) dedup) + `firstSeenOrder: string[]` (append-only order) + `initialized: boolean` (double-registration guard).
  - URL filter: positive-allow regex `^(https?|chrome-extension):\/\//`.
  - `initTabUrlTracker` registers chrome.tabs.onActivated + chrome.tabs.onUpdated listeners with defensive try/catch. onActivated uses Promise.resolve over chrome.tabs.get(tabId) for MV3-style async + older-stub compat. onUpdated prefers changeInfo.url, falls back to tab.url; NOT gated on status==='complete' (captures SPA-style routing).
  - `snapshotOpenTabs` invokes chrome.tabs.query({}); each tab.url that passes the filter is folded into the tracker via the same addUrl gate (idempotent — already-observed tabs stay where they are).
  - `getTabUrlsSeen` returns a `.slice()` so callers cannot mutate internal state.
  - `clearTabUrlsSeen` empties both state vars (NOT called by createArchive; reserved for future session-reset).

- **`src/shared/types.ts`** (+30 lines net incl. docstring)
  - `SessionMetadata` interface: 7 fields → 8 fields. `url: string` REMOVED; `urls: string[]` + `schemaVersion: string` ADDED. `schemaVersion` is the first field (source-declaration order = JSON emission order per ECMA-262).
  - Docstring cites D-P2-02 + D-P2-03 + Plan 02-01 Task 3 + F2 empty-array permission.

- **`src/background/index.ts`** (+~55 lines net)
  - Import `{ initTabUrlTracker, snapshotOpenTabs, getTabUrlsSeen }` from `./tab-url-tracker` (top-level import block).
  - `initTabUrlTracker()` registration at module top-level under defensive try/catch, alongside chrome.downloads.onChanged (Plan 02-02 precedent for D-P2-* feature registration).
  - `createArchive`: metadata block rewritten. New flow:
    1. `await snapshotOpenTabs()` inside its own try/catch (continue on failure with whatever state the tracker already had).
    2. `const urls = getTabUrlsSeen()`.
    3. If `urls.length === 0` → diagnostic `logger.warn` for F2 whole-desktop-no-tab sessions; emission proceeds verbatim.
    4. Metadata literal: 8 fields in source order — `schemaVersion: '2'`, `timestamp`, `urls`, `userAgent`, `extensionVersion`, `videoBufferSeconds: 30`, `logDurationMinutes: 10`, `totalEvents`.
  - No changes to PortMessageType variants from Plan 02-02 (CREATE_DOWNLOAD_URL / DOWNLOAD_URL / REVOKE_DOWNLOAD_URL stay intact).
  - No changes to downloadArchive or chrome.downloads.onChanged from Plan 02-02.

### Tests

- **`tests/background/meta-json-urls-schema.test.ts`** (Tests 3 + 4 + 5 rewired)
  - **Test 3 (dedup/order):** Now imports tracker + calls `initTabUrlTracker()` on a chrome stub, then dispatches synthetic chrome.tabs.onUpdated events via `stub.tabs.onUpdated._callbacks.forEach(cb => cb(...))`. The first 'A' establishes order; 'B' appends; the repeated 'A' is deduplicated.
  - **Test 4 (URL filter):** Same driver pattern; emits 4 events (https://, chrome://, about:, chrome-extension://) and asserts only https + chrome-extension:// pass through.
  - **Test 5 (F2 empty-tracker):** Imports tracker on fresh module graph (vi.resetModules in beforeEach), fires no events, asserts `getTabUrlsSeen()` returns `[]`. No hook calls needed.
  - The `_resetForTesting` / `_observeForTesting` skeletons + the `expect.fail` dynamic-import-missing-module RED paths are deleted (the module exists now; the RED gate is closed).

### Planning / Requirements

- **`.planning/REQUIREMENTS.md`** (REQ-meta-json-schema block + traceability table + footer)
  - REQ block: 8-field schema verbatim with `schemaVersion: '2'`, ISO-8601 timestamp, `urls` filter regex + F2 empty-array permission, dedup + order rules, semver, non-negative integer, exactly-8-keys, binding note preserving original CON-meta-json-schema 7-field provenance.
  - Traceability table: "Phase 3 (originally) → **Phase 2** (renumbered) | Pending" → "Phase 2 | Pending (implementation landed via Plan 02-03; harness validation deferred to Plan 02-04)".
  - Footer line dated 2026-05-20 with the REQ-meta-json-schema amendment citation; prior Plan 01-10 closure entry preserved as "Earlier update".

## Decisions Made

See `key-decisions` in the frontmatter (7 decisions covering test-hook gate preservation, URL filter regex shape, snapshotOpenTabs as separate export, schemaVersion field-name ratification, top-level listener registration, no-clear-on-save always-on charter, F2 empty-array diagnostic warn).

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 3 - Blocking] tab-url-tracker test ergonomics required choice between two API shapes**

- **Found during:** Task 1 design (pre-implementation analysis of the Plan 02-01 RED test contract).
- **Issue:** Plan 02-01 RED Tests 3+4+5 in tests/background/meta-json-urls-schema.test.ts use optional `t._resetForTesting()` + `t._observeForTesting(url)` hooks via `typeof === 'function'` guards. If absent, Tests 3+4 wedge in their current text because:
  * Test 3 expects `getTabUrlsSeen()` to equal `['https://a.example.com', 'https://b.example.com']` (with no observations seeded — i.e., the test's `if` branches are skipped — `getTabUrlsSeen()` returns `[]` → assertion fails).
  * Test 4 expects `getTabUrlsSeen()` to equal `['https://example.com', 'chrome-extension://abc/popup.html']` (same failure mode).
  * Test 5 (F2 empty case) is unaffected — already asserts `getTabUrlsSeen() === []`.
- **Architecture choices considered:**
  * **(A) Expose `_resetForTesting` + `_observeForTesting` as ungated module exports.** Pros: minimal test-code change. Cons: leaks two new strings into the production bundle, growing the Tier-1 FORBIDDEN_HOOK_STRINGS inventory from 13 → 15. Also requires lockstep tests/uat/harness.test.ts edits (per the Plan 01-14 lockstep-change pattern). Plan execution_context addendum explicitly notes this option but suggests preserving 13 entries if possible.
  * **(B) Expose hooks gated by `__MOKOSH_UAT__` Vite-define-token.** Pros: tree-shakes from production builds (same pattern as src/test-hooks/offscreen-hooks.ts). Cons: vitest.config.ts sets `__MOKOSH_UAT__: 'false'` for the unit-test run too (Plan 01-11 SUMMARY rationale: vitest defaults to MODE='test' but the unit suite uses vi.fn() stubs and would clobber). Gated hooks would tree-shake out of the unit-test runtime AS WELL AS production — so the tests would still fail. Verified by reading vitest.config.ts line 15.
  * **(C) Drive chrome.tabs.onUpdated callbacks directly via the chrome stub's `_callbacks` array.** Pros: preserves Tier-1 gate at 13 entries; production bundle stays test-hook-clean; test code becomes more honest (exercises the SAME contract the production tracker listens for). Cons: each test must build a chrome stub + install on globalThis.chrome + import the tracker + call `initTabUrlTracker()` + fire synthetic events. ~6 extra lines per test.
- **Fix (Option C — Plan 02-01 SUMMARY-anticipated fallback):** Tests 3+4 in tests/background/meta-json-urls-schema.test.ts rewired to drive chrome.tabs.onUpdated callbacks via `stub.tabs.onUpdated._callbacks.forEach(cb => cb(tabId, { url }, { url }))`. Test 5 simplified to `await import` + immediate assertion (vi.resetModules in beforeEach guarantees fresh module state). The `_resetForTesting` / `_observeForTesting` typeof-guarded skeletons and the dynamic-import-missing-module `expect.fail` paths are deleted (the module now exists).
- **Files modified:** tests/background/meta-json-urls-schema.test.ts.
- **Verification:**
  * tests/background/meta-json-urls-schema.test.ts: 5/5 GREEN (was 0/5; Tests 1+2 flipped via Task 2's createArchive amendment, Tests 3+4+5 flipped via Task 1).
  * Tier-1 grep gate: 13/13 GREEN unchanged.
  * Production bundle scan: `grep -l "_resetForTesting\|_observeForTesting" dist/ -r` → zero matches.
- **Why this is Rule 3 not Rule 4:** Plan 02-01 SUMMARY explicitly anticipated this fallback ("OPTIONAL contract... if absent, the tests need to wire chrome.tabs.onUpdated callbacks directly"). Plan 02-03 PLAN.md `<action>` block Task 1 + execution_context addendum both treat the test-hook-vs-direct-callback choice as implementer discretion. No architectural change; no scope creep beyond what the upstream planner authorized.
- **Committed in:** `7beb690` (part of Task 1 commit; tracker module + test rewiring landed together since they are the two sides of the same contract bridge — same composition as Plan 02-02's Task 3 commit `79964e6` Rule 3 deviation).

---

**Total deviations:** 1 auto-fixed (Rule 3 — blocking; Plan 02-01 SUMMARY-anticipated fallback).

**Impact on plan:** None on scope. The deviation chose the API shape that preserved the Tier-1 gate inventory at 13 entries (success_criteria item 7 in the prompt) and matched Plan 02-01 SUMMARY's "tests need to wire chrome.tabs.onUpdated callbacks directly" forward-link.

## Issues Encountered

None. The plan-checker passed Plan 02-03 cleanly (iteration 2 GREEN), the test contract from Plan 02-01 was unambiguous after the F2 + 8th-field resolutions, and the test infrastructure landed by Plan 02-02 (CREATE_DOWNLOAD_URL round-trip simulation, capturedArchiveBytes pattern) carried through without modification.

## User Setup Required

None. The migration is fully internal — schema-breaking for downstream meta.json consumers (UAT harness Plan 02-04 + future SRV-* uploads), but no operator-facing configuration change. The `tabs` permission was already granted via DEC-011 Amendment 1 in commit `9dcfcf0`.

## Next Phase Readiness

- **Plan 02-04 (next, Wave 3 — UAT harness extensions A24+):** ready. A27's multi-tab strict-mode assertion (length >= 2; both URLs present) is now empirically reachable — the operator opens 2 tabs during a recording session, hits SAVE, the harness extracts meta.json from the produced zip and verifies `urls.length >= 2`. A28's zip-layout pin (exactly 5 entries) is orthogonal and unblocked by Plan 02-02 + 02-03 closure together.
- **Phase 2 closure outlook:** after Plan 02-04 lands the UAT extensions and the operator empirical checkpoint flips ACK, REQ-meta-json-schema can be flipped to Complete (this plan's traceability-table forward link primes that flip).

## Verification Reconciliation

| Plan §verification gate | Result |
|---|---|
| `npx tsc --noEmit` | clean |
| `npm run build` | clean (378.82 kB SW chunk; +0.96 kB vs Plan 02-02 baseline for the new tab-url-tracker module) |
| `npx vitest run tests/background/meta-json-urls-schema.test.ts` | **5/5 GREEN** (was 0/5) |
| `npx vitest run tests/build/strict-meta-json-validation.test.ts` | **8/8 GREEN** (was 5/8) |
| `npx vitest run` (full suite) | **171/171 GREEN** (was 163/171; +8 GREEN net — exactly the Plan 02-03 territory) |
| `npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts` | **13/13 GREEN** (unchanged; Tier-1 gate inventory preserved at 13 entries) |
| `npx vitest run tests/i18n/manifest-i18n.test.ts` | **12/12 GREEN** (DEC-011 Amendment 1 permission-set pin) |
| `grep -c "schemaVersion" .planning/REQUIREMENTS.md` | **3** (≥2 required) |
| `grep -c '"tabs"' manifest.json` | **1** (DEC-011 Amendment 1 already landed) |
| `grep -c "Amendment 1" .planning/PROJECT.md` | **3** (DEC-011 row Amendment 1 prose) |
| `grep -c "url: string" src/shared/types.ts` | **2** (1 in UserEvent — unrelated; 1 in docstring explaining the migration. **SessionMetadata.url removed verbatim.**) |
| Always-on charter check (`grep -n "clearTabUrlsSeen" src/background/index.ts`) | **1 hit** (docstring comment EXPLICITLY noting it is NOT called — the always-on charter is documented at the call site) |
| `await import(...)` in src/background/index.ts | **0** (Plan 01-11 SUMMARY invariant preserved) |

## Tier-1 grep gate inventory + rationale

**Choice: preserve 13 entries** (no growth from this plan).

Rationale: the `_resetForTesting` / `_observeForTesting` ergonomic hooks the Plan 02-01 RED tests SUGGESTED would have grown FORBIDDEN_HOOK_STRINGS from 13 → 15. Two options to avoid that:

1. **`__MOKOSH_UAT__`-gated hooks** (the established Plan 01-11 pattern) — REJECTED. vitest.config.ts sets `__MOKOSH_UAT__: 'false'` for unit tests (Plan 01-11 SUMMARY note 6), so gated hooks would tree-shake out of the unit-test runtime too. Tests 3+4 would still fail.
2. **Drive chrome.tabs.onUpdated callbacks directly via the stub** — CHOSEN. Plan 02-01 SUMMARY anticipated this fallback verbatim. Production bundle stays test-hook-clean; the tracker module's public API contains only the 4 production functions; FORBIDDEN_HOOK_STRINGS holds at 13.

Verification: `grep -l "_resetForTesting\|_observeForTesting" dist/ -r` → 0 matches. `npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts` → 13/13 GREEN.

## Self-Check: PASSED

### Files created (verified via stat at SUMMARY commit time)

- FOUND: src/background/tab-url-tracker.ts (246 LOC)
- FOUND: .planning/phases/02-stabilize-export-pipeline/02-03-SUMMARY.md (this file)

### Files modified (verified via git diff)

- FOUND: src/shared/types.ts (SessionMetadata 7 → 8 fields)
- FOUND: src/background/index.ts (import + initTabUrlTracker registration + createArchive metadata rewrite)
- FOUND: tests/background/meta-json-urls-schema.test.ts (Tests 3+4+5 rewired)
- FOUND: .planning/REQUIREMENTS.md (REQ-meta-json-schema + traceability + footer)

### Commits exist (verified via git log)

- FOUND: 7beb690 feat(02-03): tab-url-tracker — chrome.tabs.onActivated + onUpdated → urls[] with dedup + filter (D-P2-02)
- FOUND: 78031e7 feat(02-03): meta.json — urls[] + schemaVersion (D-P2-02 + D-P2-03; replaces url:string)
- FOUND: af03556 docs(02-03): REQUIREMENTS — REQ-meta-json-schema amended for 8-field shape with urls[] + schemaVersion

### Success criteria reconciliation (from prompt)

1. [x] All 4 tasks in plan executed (Task 0 verification + Tasks 1-3 implementation).
2. [x] Each task committed individually (--no-verify per parallel-execution mandate).
3. [x] 8 RED tests (5 in meta-json-urls-schema + 3 in strict-meta-json-validation) now GREEN; full vitest 171/171 GREEN.
4. [x] 163 pre-existing GREEN tests still GREEN (no regression — full-suite delta is +8 GREEN net, not -anything).
5. [x] manifest.json includes `"tabs"` in permissions array (already landed via plan-checker iteration-1 revision pass commits `9dcfcf0` + `df8c086` before base `d3aa567`; verified GREEN).
6. [x] .planning/PROJECT.md captures DEC-011 Amendment 1 (verified GREEN via `grep -c "Amendment 1" .planning/PROJECT.md` → 3).
7. [x] tests/i18n/manifest-i18n.test.ts updated for tabs permission (already landed; 12/12 GREEN).
8. [x] Tier-1 grep gate inventory documented (13 entries; rationale in dedicated section above).
9. [x] No modifications to PortMessageType variants from 02-02 (verified via diff: `src/shared/types.ts` line range around PortMessageType union untouched).
10. [x] No modifications to downloadArchive or chrome.downloads.onChanged logic from 02-02 (verified via diff: lines 1174-1208 untouched).
11. [x] SUMMARY.md created in plan directory (this file).
12. [x] No modifications to STATE.md or ROADMAP.md (worktree mode invariant; STATE.md left untouched per parallel-execution mandate).

---
*Phase: 02-stabilize-export-pipeline*
*Completed: 2026-05-20*
