---
phase: 02-stabilize-export-pipeline
plan: 03
type: auto
wave: 2
depends_on: [01]
files_modified:
  - manifest.json
  - tests/i18n/manifest-i18n.test.ts
  - .planning/PROJECT.md
  - src/background/tab-url-tracker.ts
  - src/shared/types.ts
  - src/background/index.ts
  - .planning/REQUIREMENTS.md
autonomous: true
requirements:
  - REQ-meta-json-schema
tags:
  - meta-json-urls-array
  - tab-url-tracking
  - schema-amendment
  - p1-10-fix
  - d-p2-02
  - d-p2-03
must_haves:
  truths:
    - "src/background/tab-url-tracker.ts exists exporting getTabUrlsSeen(): string[] returning a deduplicated, first-seen-ordered, filtered (no chrome://, no about:; include chrome-extension://) list of tab URLs observed during the rolling 30s window."
    - "SessionMetadata.url: string is replaced by SessionMetadata.urls: string[] in src/shared/types.ts."
    - "createArchive in src/background/index.ts assembles meta.json with urls (not url), plus a new schemaVersion: '2' field, totalling exactly 8 fields per D-P2-03."
    - "Tests tests/background/meta-json-urls-schema.test.ts (4 tests) and tests/build/strict-meta-json-validation.test.ts (8 tests) flip RED→GREEN."
    - ".planning/REQUIREMENTS.md REQ-meta-json-schema text amended to reflect the new 8-field shape, with the breaking-change cutover documented inline."
    - "chrome.tabs.onActivated and chrome.tabs.onUpdated listeners registered in src/background/index.ts maintain the tracker's internal Set; pruning happens alongside the video segment ring buffer lifecycle (no separate timer)."
    - "Always-on charter preserved (Plan 01-09 Amendment 3): SAVE creates a zip with the current tracker state; tracker continues accumulating after SAVE."
    - "DEC-011 amended (Amendment 1, 2026-05-20): `tabs` permission ADDED to manifest.json by this plan. chrome.tabs.get(tabId).url is now reliably defined for all tabs in any window (not just activeTab). The tab-url-tracker enumerates chrome.tabs.query({}) at SAVE time as a defensive fallback to capture tabs the operator OPENED but never activated during the 30s window. tests/i18n/manifest-i18n.test.ts pins the new 8-entry permission set as a regression guard."
  artifacts:
    - path: "src/background/tab-url-tracker.ts"
      provides: "getTabUrlsSeen(): string[] + initTabUrlTracker(): void + clearTabUrlsSeen(): void"
      min_lines: 80
      contains: "getTabUrlsSeen|chrome\\.tabs\\.onActivated|chrome\\.tabs\\.onUpdated"
    - path: "src/shared/types.ts"
      provides: "SessionMetadata.urls: string[] (replaces url: string); new schemaVersion: string field"
      contains: "urls.*string\\[\\]|schemaVersion"
    - path: "src/background/index.ts"
      provides: "createArchive consumes getTabUrlsSeen for meta.urls + initTabUrlTracker at SW init"
      contains: "getTabUrlsSeen|schemaVersion"
    - path: ".planning/REQUIREMENTS.md"
      provides: "REQ-meta-json-schema amended for 8-field shape + breaking-change cutover note"
      contains: "schemaVersion|urls.*string\\[\\]"
    - path: "manifest.json"
      provides: "permissions array extended to include `tabs` (DEC-011 Amendment 1)"
      contains: "\"tabs\""
    - path: "tests/i18n/manifest-i18n.test.ts"
      provides: "regression-pin describe block for the 8-entry permission set including `tabs`"
      contains: "DEC-011 Amendment 1|EXPECTED_PERMISSIONS"
    - path: ".planning/PROJECT.md"
      provides: "DEC-011 row rewritten with Amendment 1 (2026-05-20) citing Phase 2 D-P2-02 as justification"
      contains: "Amendment 1"
  key_links:
    - from: "src/background/index.ts:initialize"
      to: "src/background/tab-url-tracker.ts:initTabUrlTracker"
      via: "initTabUrlTracker() called at SW init alongside chrome.runtime.onMessage listener"
      pattern: "initTabUrlTracker\\(\\)"
    - from: "src/background/index.ts:createArchive"
      to: "src/background/tab-url-tracker.ts:getTabUrlsSeen"
      via: "metadata.urls = getTabUrlsSeen()"
      pattern: "metadata\\.urls.*=.*getTabUrlsSeen"
    - from: "src/background/tab-url-tracker.ts"
      to: "chrome.tabs.onActivated + chrome.tabs.onUpdated"
      via: "addListener at module init; each event derives the URL via tab.url and inserts into the Set if it passes the filter"
      pattern: "chrome\\.tabs\\.on(Activated|Updated)\\.addListener"
---

<objective>
Replace SessionMetadata.url: string with SessionMetadata.urls: string[] per D-P2-02. The urls array
captures all tab URLs observed during the rolling recording window (operator's full multi-tab
bug-reproduction context, not just the active-at-save tab). Add a new schemaVersion field per
the planner-resolved 8th-field decision from Plan 02-01. Amend REQUIREMENTS.md REQ-meta-json-schema
text to reflect the breaking-change cutover.

Purpose: closes audit P1 #10 + makes the meta.json multi-tab-aware for the operator's typical workflow
(switching tabs across the 30s window when reproducing a bug). Maintains the always-on charter
(tracker keeps running after SAVE; no reset).

Output:
- New module src/background/tab-url-tracker.ts (~80-120 LOC).
- src/shared/types.ts SessionMetadata interface updated.
- src/background/index.ts createArchive + initialize updated.
- .planning/REQUIREMENTS.md REQ-meta-json-schema text amended.
- tests/background/meta-json-urls-schema.test.ts (4) + tests/build/strict-meta-json-validation.test.ts (8) flip RED→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/REQUIREMENTS.md
@.planning/STATE.md
@.planning/phases/02-stabilize-export-pipeline/02-CONTEXT.md
@.planning/phases/02-stabilize-export-pipeline/02-01-PLAN.md

# Source code under modification
@src/background/index.ts
@src/shared/types.ts

# Tests that flip GREEN under this plan
@tests/background/meta-json-urls-schema.test.ts
@tests/build/strict-meta-json-validation.test.ts

<interfaces>
<!-- Target shape for the tab-url-tracker module. Embedded so executor needs no codebase exploration. -->

src/background/tab-url-tracker.ts public API:
```typescript
/**
 * Initialize the tab-URL tracker. Registers chrome.tabs.onActivated +
 * chrome.tabs.onUpdated listeners that maintain an internal Set of URLs
 * observed during the SW's lifetime. Must be called once at SW init.
 *
 * Idempotent — calling twice is safe (defensive try/catch around listener
 * registration matches the pattern in src/background/index.ts:bootstrap).
 *
 * D-P2-02 binding: captures the operator's multi-tab context, not just
 * the active-at-save tab.
 */
export function initTabUrlTracker(): void;

/**
 * Return the deduplicated, first-seen-ordered, filtered list of tab URLs
 * observed since init OR since the last clearTabUrlsSeen() call.
 *
 * Filter (per CONTEXT.md `<specifics>` block):
 *   - INCLUDE: https://, http://, chrome-extension://
 *   - EXCLUDE: chrome://, about:, devtools://, file:// (low diagnostic value
 *     OR privacy-concerning local-fs paths)
 *
 * Dedup: each URL appears exactly once.
 * Order: first-seen-first.
 *
 * Always returns a NEW array (copy) — caller cannot mutate the internal Set.
 */
export function getTabUrlsSeen(): string[];

/**
 * Clear the internal Set. NOT called by saveArchive (always-on charter
 * preserved — SAVE creates a zip with the current state; tracker keeps
 * accumulating). Reserved for future use (e.g., manual session-reset).
 */
export function clearTabUrlsSeen(): void;
```

Updated SessionMetadata (src/shared/types.ts):
```typescript
export interface SessionMetadata {
  schemaVersion: string;             // NEW — '2' marks the D-P2-02 url→urls cutover
  timestamp: string;                  // ISO-8601 with Z
  urls: string[];                     // NEW — replaces `url: string`; non-empty per D-P2-03
  userAgent: string;
  extensionVersion: string;
  videoBufferSeconds: number;
  logDurationMinutes: number;
  totalEvents: number;
}
```

Existing createArchive call site (src/background/index.ts:673-684):
```typescript
const metadata: SessionMetadata = {
  timestamp: new Date().toISOString(),
  url: new URL(chrome.runtime.getURL('')).origin,   // ← REPLACE
  userAgent: navigator.userAgent,
  extensionVersion: manifest.version,
  videoBufferSeconds: 30,
  logDurationMinutes: 10,
  totalEvents: rrwebEvents.length + userEvents.length
};
```

REQ-meta-json-schema (REQUIREMENTS.md:106-119) — verbatim text to amend:
```
- [ ] **REQ-meta-json-schema**: `meta.json` inside the archive conforms to the
  verbatim schema:
  ```json
  {
    "timestamp": "2025-05-15T14:32:10Z",
    "url": "https://...",                        ← REPLACE WITH urls: []
    "userAgent": "Chrome/...",
    "extensionVersion": "1.0.0",
    "videoBufferSeconds": 30,
    "logDurationMinutes": 10,
    "totalEvents": 143
  }
  ```
  All fields required. Binding: CON-meta-json-schema.
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="false">
  <name>Task 0: DEC-011 Amendment 1 — add `tabs` to manifest + sync PROJECT.md + i18n permission-pin test</name>
  <files>manifest.json, .planning/PROJECT.md, tests/i18n/manifest-i18n.test.ts</files>
  <action>
    Per plan-checker iteration 1 BLOCKER B1 resolution (user-approved Option A: honor D-P2-02
    "all tabs visible" verbatim by adding `tabs` permission to manifest.json).

    1. **manifest.json:** extend the `permissions` array to include `"tabs"`. The locked
       post-amendment set has exactly 8 entries (alphabetic order is NOT required; the
       i18n test pins membership via set-equality, not order):
       ```json
       "permissions": [
         "desktopCapture",
         "activeTab",
         "tabs",
         "downloads",
         "scripting",
         "storage",
         "offscreen",
         "notifications"
       ]
       ```
       NOTE: this commit may have already been applied during the revision pass — verify before
       editing. If `grep -c '"tabs"' manifest.json` returns a value matching the post-amendment
       state, treat this step as a no-op verification and proceed.

    2. **.planning/PROJECT.md DEC-011 row:** rewrite the row to embed Amendment 1
       (2026-05-20) referencing Phase 2 D-P2-02 as justification. The amended row must:
       (a) Cite SPEC §7 as the original source and DEC-003 Amendment as Phase 01 delta.
       (b) State Amendment 1 ADDS `tabs` for D-P2-02 meta.urls feature.
       (c) List the current locked set verbatim (8 permissions + host_permissions).
       (d) Acknowledge audit T-1-02 ("unused permissions expand attack surface") and
           explicitly override it: the permission is USED by the meta.urls feature, so
           it is not unused.
       (e) Set status to `locked (Phase 1, post-Amendment 1)`.

    3. **tests/i18n/manifest-i18n.test.ts:** add a new describe block
       `"Plan 02-03 DEC-011 Amendment 1: manifest.json permissions include \"tabs\" (D-P2-02)"`
       containing TWO assertions:
       (a) `manifest.permissions.includes('tabs') === true`
       (b) Set-equality between `manifest.permissions` (sorted) and the locked 8-entry
           EXPECTED_PERMISSIONS constant (sorted). Both lengths must match.
       Pre-existing test blocks (`Plan 01-12: ...`) MUST remain GREEN unchanged.

    The amendment closes plan-checker iteration 1 BLOCKER B1 and unblocks Plan 02-04 A27's
    strict-mode (both-URLs-required) assertion — see Plan 02-04 Task 3 amendment in the same
    iteration 1 pass.
  </action>
  <verify>
    <automated>grep -c '"tabs"' manifest.json ; grep -c 'Amendment 1' .planning/PROJECT.md ; npx vitest run tests/i18n/manifest-i18n.test.ts 2>&1 | tail -10</automated>
  </verify>
  <done>
    manifest.json permissions includes `"tabs"` exactly once. PROJECT.md DEC-011 row carries
    Amendment 1 prose (≥1 grep hit for "Amendment 1"). tests/i18n/manifest-i18n.test.ts new
    describe block GREEN (2/2 assertions pass). Atomic commit shared with the rest of the
    iteration 1 revision pass — see commit conventions in the orchestrator brief.
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 1: Create src/background/tab-url-tracker.ts module</name>
  <files>src/background/tab-url-tracker.ts</files>
  <behavior>
    - Module exports initTabUrlTracker, getTabUrlsSeen, clearTabUrlsSeen per the interface block.
    - Internal state: `let tabUrlsSeen: Set<string> = new Set()` AND `let firstSeenOrder: string[] = []` (Set for O(1) dedup; array for ordering).
    - On chrome.tabs.onActivated: query the activated tab via chrome.tabs.get(activeInfo.tabId); if tab.url passes filter, addUrl(tab.url).
    - On chrome.tabs.onUpdated: when changeInfo.url is present AND passes filter, addUrl(changeInfo.url). (Filters out the cascade of intermediate events that fire during page load — `changeInfo.status === 'complete'` is NOT required because we want to capture URL transitions even for SPA-style routing).
    - addUrl helper: if URL not in Set, push to firstSeenOrder array AND add to Set; idempotent.
    - Filter helper: accept https://, http://, chrome-extension://; reject chrome://, about:, devtools://, file://, blob:, data:.
    - Defense-in-depth: all chrome.* listener registrations wrapped in try/catch (matches src/background/index.ts:bootstrap pattern for chrome.notifications + chrome.action listeners).
    - getTabUrlsSeen returns a slice (copy) of firstSeenOrder — caller cannot mutate internal state.
    - clearTabUrlsSeen empties both the Set and the array.
    - module-init guard: `let initialized = false`; initTabUrlTracker sets to true; subsequent calls return early with a logger.warn for idempotency.
  </behavior>
  <action>
    Create `src/background/tab-url-tracker.ts` with the exact public API from the interfaces block.

    Use the Logger pattern from src/shared/logger.ts (existing pattern at recorder.ts:78 / index.ts:49):
    ```typescript
    import { Logger } from '../shared/logger';
    const logger = new Logger('TabUrlTracker');
    ```

    The filter implementation:
    ```typescript
    function passesFilter(url: string): boolean {
      // Per .planning/phases/02-stabilize-export-pipeline/02-CONTEXT.md <specifics>:
      //   INCLUDE: https://, http://, chrome-extension://
      //   EXCLUDE: chrome://, about:, devtools://, file://, blob:, data:
      return /^(https?|chrome-extension):\/\//.test(url);
    }
    ```

    Module structure (top → bottom):
    1. Logger import + instantiation.
    2. State: `let tabUrlsSeen: Set<string>`, `let firstSeenOrder: string[]`, `let initialized: boolean`.
    3. passesFilter helper.
    4. addUrl helper (internal).
    5. initTabUrlTracker (public) — registers listeners with defensive try/catch.
    6. getTabUrlsSeen (public).
    7. clearTabUrlsSeen (public).

    Per D-P2-02 + CONTEXT.md `<specifics>`: the operator's primary tab at SAVE time should always
    be in the array if it has a valid URL. The chrome.tabs.onActivated path covers this — the active
    tab fires onActivated whenever the operator switches to it (including the first activation when
    the SW starts up). Combined with chrome.tabs.onUpdated, this captures both tab-switch events
    and in-tab navigation events. No explicit "snapshot active tab at SAVE time" is needed (the
    tracker has already captured it via onActivated).

    NOTE on `tabs` permission (post DEC-011 Amendment 1, 2026-05-20): the new task in this plan
    AMENDS manifest.json to include the `tabs` permission. With `tabs` granted, chrome.tabs.get(tabId)
    reliably populates the `.url` field for any tab in any window — closing the limitation noted in
    Plan 01-13 SUMMARY Known Limitations item 3. The tracker therefore captures URL transitions for
    BOTH active and inactive tabs (the `chrome.tabs.onUpdated` listener already fires for any tab's
    URL change regardless of active state). A diagnostic logger.warn fires only if `.url` is still
    missing after the permission is granted (shouldn't happen — defensive only).

    SAVE-time defensive enumeration (per F2 + DEC-011 Amendment 1 capability): in addition to the
    event-driven Set maintenance, the tracker exposes a `snapshotOpenTabs(): Promise<void>` helper
    that invokes `chrome.tabs.query({})` and folds every returned tab.url (that passes the filter)
    into the internal Set + firstSeenOrder. createArchive (Task 2 below) calls this helper BEFORE
    reading `getTabUrlsSeen()` so any tab the operator OPENED during the 30s window but never
    activated (so the onActivated listener never fired for it) is still captured. This is purely
    additive — duplicates dedup; order preserves first-seen-first; no behavior change for
    already-observed tabs.

    Per F2 (plan-checker iteration 1): the empty-tracker case is NO LONGER a sentinel-URL fallback.
    If after `snapshotOpenTabs()` the tracker is STILL empty (e.g., whole-desktop recording with NO
    browser tabs open at SAVE time — operator captured a non-Chrome window via desktopCapture only),
    that is a MEANINGFUL state and is represented faithfully as `urls: []` (empty array). The strict-
    validation rule in `tests/build/strict-meta-json-validation.test.ts` Test 3 was relaxed in Plan
    02-01 iteration 1 to PERMIT empty urls[] for this case; Plan 02-01's RED tests pin the
    empty-tracker → `meta.urls === []` contract explicitly. NO fake extension-origin URL is
    inserted.
  </action>
  <verify>
    <automated>npx tsc --noEmit 2>&1 | head -20</automated>
  </verify>
  <done>
    File exists. tsc clean. Three exports per public API. Defensive try/catch wraps chrome.tabs.*
    listener registrations. Atomic commit:
    `feat(02-03): tab-url-tracker — chrome.tabs.onActivated + onUpdated → urls[] with dedup + filter (D-P2-02)`.
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Update SessionMetadata type + meta.json assembly + tracker wiring</name>
  <files>src/shared/types.ts, src/background/index.ts</files>
  <behavior>
    - src/shared/types.ts SessionMetadata: REPLACE `url: string` with `urls: string[]`; ADD `schemaVersion: string` as the first field. Total 8 fields.
    - src/background/index.ts:
      (a) import { initTabUrlTracker, snapshotOpenTabs, getTabUrlsSeen } from './tab-url-tracker';
      (b) call initTabUrlTracker() inside the existing `initialize()` function (line 957) under defensive try/catch matching the chrome.notifications.onClicked pattern.
      (c) createArchive (line 673-684) updated. Per F2 (plan-checker iteration 1) the empty-tracker case is NOT a sentinel-URL fallback; it is `urls: []`:
          ```typescript
          const manifest = chrome.runtime.getManifest();
          // DEC-011 Amendment 1: with `tabs` permission granted, snapshot every currently-open
          // tab's URL at SAVE time as a defensive fallback. This captures tabs the operator
          // OPENED during the 30s window but never activated (so the onActivated listener
          // never fired for them). Dedup + order preservation is handled by the tracker.
          try {
            await snapshotOpenTabs();
          } catch (err) {
            logger.warn('snapshotOpenTabs failed at SAVE time (continuing with tracker state):', err);
          }
          const urls = getTabUrlsSeen();
          if (urls.length === 0) {
            // Meaningful state: whole-desktop recording with NO browser tabs open. Per F2
            // resolution, this is represented faithfully as urls:[] — NO fake extension-origin
            // URL inserted. tests/background/meta-json-urls-schema.test.ts pins this contract.
            logger.warn('createArchive: tabUrlsSeen is empty after snapshotOpenTabs — emitting meta.urls=[] (whole-desktop session with no browser tabs)');
          }
          const metadata: SessionMetadata = {
            schemaVersion: '2',
            timestamp: new Date().toISOString(),
            urls,
            userAgent: navigator.userAgent,
            extensionVersion: manifest.version,
            videoBufferSeconds: 30,
            logDurationMinutes: 10,
            totalEvents: rrwebEvents.length + userEvents.length
          };
          ```
    - Object key ordering matches the SessionMetadata interface declaration order (TypeScript object
      literal preserves source order; meta.json output is JSON.stringify(metadata, null, 2) → keys
      in insertion order per ECMA-262).
    - Always-on charter preserved: createArchive does NOT call clearTabUrlsSeen() — the tracker
      continues accumulating across saves (next save captures any tabs activated after this one).
  </behavior>
  <action>
    1. Edit `src/shared/types.ts` SessionMetadata interface (lines 102-111):
       - DELETE `url: string;`.
       - INSERT `schemaVersion: string;` as the first field.
       - INSERT `urls: string[];` in the position previously occupied by `url`.
       - Final ordering: schemaVersion, timestamp, urls, userAgent, extensionVersion,
         videoBufferSeconds, logDurationMinutes, totalEvents (8 fields).
       - Add a docstring block above the interface citing D-P2-02 (the url→urls cutover) +
         D-P2-03 (schemaVersion + 8-field exact rule) + the planner-resolved Plan 02-01 Task 3
         decision to make schemaVersion the 8th field.

    2. Edit `src/background/index.ts`:
       - Add import: `import { initTabUrlTracker, snapshotOpenTabs, getTabUrlsSeen } from './tab-url-tracker';` near
         the existing import block (line 1-11).
       - In `initialize()` (line 957), add a defensive try/catch block calling initTabUrlTracker()
         right after the existing chrome.notifications.onClicked listener registration (search for
         that pattern; mirror the surrounding try/catch shape).
       - Replace the metadata-construction block in createArchive (line 673-684) with the new
         8-field shape per the behavior block above.

    3. Per D-P2-02 + D-P2-03:
       - schemaVersion: '2' marks the breaking-change cutover. Future schema bumps increment.
       - urls is the operator's multi-tab context.
       - The non-empty fallback (extension-origin URL) guarantees REQ-meta-json-schema strict-
         validation passes even on a freshly-spawned SW with no tab activity.

    Per the planner-resolved decision in Plan 02-01 Task 3: the 8th field name `schemaVersion`
    was a tentative planner pick. If plan-checker / implementer surfaces a stronger candidate
    name, BOTH this plan AND tests/build/strict-meta-json-validation.test.ts EXPECTED_KEYS must
    change in lockstep (matching the Plan 01-14 lockstep-change pattern for FORBIDDEN_HOOK_STRINGS).
  </action>
  <verify>
    <automated>npx tsc --noEmit 2>&1 | head -20 ; npm run build 2>&1 | tail -10 ; npx vitest run tests/background/meta-json-urls-schema.test.ts tests/build/strict-meta-json-validation.test.ts 2>&1 | tail -30</automated>
  </verify>
  <done>
    tsc clean. npm run build clean. SessionMetadata has exactly 8 fields. createArchive emits
    8-field meta.json with schemaVersion='2' + urls (non-empty via fallback). All 4 tests in
    meta-json-urls-schema.test.ts GREEN. All 8 tests in strict-meta-json-validation.test.ts GREEN.
    Atomic commit:
    `feat(02-03): meta.json — urls[] + schemaVersion (D-P2-02 + D-P2-03; replaces url:string)`.
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 3: Amend REQUIREMENTS.md REQ-meta-json-schema for the new 8-field shape</name>
  <files>.planning/REQUIREMENTS.md</files>
  <behavior>
    - REQ-meta-json-schema entry (lines 106-119) updated to reflect the new 8-field schema verbatim.
    - Inline comment block citing D-P2-02 + D-P2-03 + Plan 02-03 as the cutover provenance.
    - Acceptance criteria preserved: all fields required; types correct; timestamp ISO-8601 with Z.
    - Add new acceptance criteria: urls is non-empty string[]; schemaVersion === '2'.
    - Traceability table entry for REQ-meta-json-schema status update: "Pending → Complete pending Plan 02-04 harness validation" (Plan 02-04 may flip this to Complete; this plan only ships the implementation + amended text).
  </behavior>
  <action>
    Edit `.planning/REQUIREMENTS.md`:

    1. Replace lines 106-119 (REQ-meta-json-schema block) with the new 8-field schema:

    ```markdown
    - [ ] **REQ-meta-json-schema**: `meta.json` inside the archive conforms to the
      verbatim schema (D-P2-02 + D-P2-03 cutover; replaces the 7-field `url:string`
      shape per audit P1 #10 amendment 2026-05-20):
      ```json
      {
        "schemaVersion": "2",
        "timestamp": "2025-05-15T14:32:10Z",
        "urls": ["https://example.com/", "https://app.example.com/dashboard"],
        "userAgent": "Chrome/...",
        "extensionVersion": "1.0.0",
        "videoBufferSeconds": 30,
        "logDurationMinutes": 10,
        "totalEvents": 143
      }
      ```
      All 8 fields required. Acceptance:
        - `schemaVersion === '2'` (marks the D-P2-02 url→urls cutover; future schema bumps increment)
        - `timestamp` ISO-8601 with `Z` suffix
        - `urls` is a non-empty `string[]` of URLs matching `/^(https?|chrome-extension):\/\//` (per CONTEXT.md `<specifics>` filter rules — exclude chrome://, about:, devtools://, file://)
        - `urls` is deduplicated; ordering is first-seen-first across the rolling recording window
        - `extensionVersion` matches semver
        - `totalEvents` is a non-negative integer
        - exactly 8 keys; no extras
      Binding: CON-meta-json-schema (this REQ-text supersedes the original CON-meta-json-schema 7-field shape).
    ```

    2. Update the Traceability table entry (line 225) for REQ-meta-json-schema:
       FROM: `| REQ-meta-json-schema | Phase 3 (originally) → **Phase 2** (renumbered) | Pending |`
       TO: `| REQ-meta-json-schema | Phase 2 | Pending (implementation landed via Plan 02-03; harness validation deferred to Plan 02-04) |`

    3. Append a `*Updated YYYY-MM-DD*` footer line at the bottom of the file matching the existing
       footer pattern (line 249 area). Date: 2026-05-20.

    Per D-P2-02 + D-P2-03: this is the canonical schema-amendment Phase 2 ships. The 8-field shape
    becomes the new baseline for all downstream consumers (UAT harness, future v2 SRV-* uploads,
    operator post-mortem tooling).
  </action>
  <verify>
    <automated>grep -c "schemaVersion" /home/parf/projects/work/repremium/.planning/REQUIREMENTS.md ; grep -c "urls.*string\[\]" /home/parf/projects/work/repremium/.planning/REQUIREMENTS.md</automated>
  </verify>
  <done>
    REQUIREMENTS.md REQ-meta-json-schema block reflects 8-field shape. Traceability table updated.
    Footer line appended with 2026-05-20 date. Atomic commit:
    `docs(02-03): REQUIREMENTS — REQ-meta-json-schema amended for 8-field shape with urls[] + schemaVersion`.
  </done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| tab URL strings → meta.json | Tab URLs may contain sensitive operator state (per CONTEXT.md `<decisions>` D-P2-02 "Privacy note"); the "log is internal" charter accepts this. |
| chrome.tabs.* event source | Chrome platform → SW; trustworthy per MV3 contract. |
| tab-url-tracker internal Set → getTabUrlsSeen consumer | Module returns a COPY (slice); caller cannot mutate internal state. |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-03-01 | Information Disclosure | URLs with embedded credentials (https://user:pass@host/) leak into meta.json | accept | Charter shift 2026-05-20 ("log is internal") explicitly accepts this. REQ-password-confidentiality moved to Out of Scope v1. No mitigation in v1; v2/Phase 4 candidate. |
| T-02-03-02 | Information Disclosure | chrome-extension://<id>/ URLs reveal extension presence | accept | The extension is installed unpacked locally; presence is already self-evident from chrome://extensions. INCLUDE per CONTEXT.md `<specifics>` filter. |
| T-02-03-03 | Tampering | Malicious extension overrides chrome.tabs.* events | mitigate | Extension permissions are user-granted at install time. Defensive try/catch wraps every chrome.* call so malformed events don't crash the SW. |
| T-02-03-04 | Denial of Service | Unbounded tab-url Set growth in long-running SW | mitigate | URL set is bounded by O(unique tabs operator visits per SW lifetime). Operationally ≤500 even in heavy use. No pruning needed within v1 budget; SW idle teardown (~30s) clears state. Phase 4 hardening can add pruning if production telemetry surfaces unbounded growth. |
| T-02-03-05 | Repudiation | Tracker silently drops URLs when `tabs` permission absent (chrome.tabs.get returns undefined .url) | mitigate | Diagnostic logger.warn at every dropped event so post-mortem investigation can identify the permission gap. Phase 4 hardening adds `tabs` permission per CONTEXT.md `<deferred>` item. |
</threat_model>

<verification>
- `npx tsc --noEmit` → clean.
- `npm run build` → clean.
- `npx vitest run tests/background/meta-json-urls-schema.test.ts` → 4/4 GREEN.
- `npx vitest run tests/build/strict-meta-json-validation.test.ts` → 8/8 GREEN.
- `npx vitest run` (full suite) → previously RED tests from Plan 02-01 all GREEN. Net: 153 + 15 = 168 GREEN (or whatever the exact count is post-Plan-02-01 + Plan-02-02).
- `npx vitest run tests/background/no-test-hooks-in-prod-bundle.test.ts` → 12 FORBIDDEN_HOOK_STRINGS unchanged → GREEN.
- `npm run test:uat` → 24/24 GREEN preserved (no harness changes in this plan; A13's meta.json shape check
  may detect the schemaVersion + urls fields — verify A13 still GREEN; if it depends on the old `url` field, Plan 02-04 addresses).
- `grep -c "schemaVersion" .planning/REQUIREMENTS.md` ≥ 2 (in REQ block + acceptance bullet).
- `grep -c "url: string" src/shared/types.ts` → 0 (the legacy field is gone).
- Always-on charter check: `grep -n "clearTabUrlsSeen" src/background/index.ts` → 0 hits (createArchive does NOT clear; tracker keeps accumulating after SAVE).
- DEC-011 Amendment 1 verification: `grep -c '"tabs"' manifest.json` ≥ 1; `npx vitest run tests/i18n/manifest-i18n.test.ts` GREEN (pre-existing 10 tests + 2 new permission-set tests = 12/12 GREEN); `grep -c 'Amendment 1' .planning/PROJECT.md` ≥ 1.
</verification>

<success_criteria>
1. tests/background/meta-json-urls-schema.test.ts 4/4 GREEN (including the F2-pinned empty-tracker → `meta.urls === []` contract).
2. tests/build/strict-meta-json-validation.test.ts 8/8 GREEN (Test 3 relaxed to PERMIT empty urls[] per F2 resolution).
3. UAT harness 24/24 GREEN preserved (or A13 amended in lockstep if its meta.json assertions depend on the old `url` field — verify before commit).
4. .planning/REQUIREMENTS.md REQ-meta-json-schema reflects the 8-field shape with breaking-change cutover documented.
5. Always-on charter preserved: tab-url-tracker continues accumulating after SAVE; no createArchive-time clearTabUrlsSeen call.
6. Tier-1 FORBIDDEN_HOOK_STRINGS = 12 (unchanged).
7. DEC-011 Amendment 1 LANDED: manifest.json permissions includes `"tabs"`; PROJECT.md DEC-011 row carries Amendment 1 prose; tests/i18n/manifest-i18n.test.ts permission-set describe block GREEN. The `tabs` permission gap noted in Plan 01-13 SUMMARY Known Limitations item 3 is CLOSED by this plan, not deferred.
8. createArchive empty-tracker fallback REMOVED (F2): emits `urls: []` + logger.warn for whole-desktop-no-tab sessions; NO fake extension-origin URL inserted.
</success_criteria>

<output>
After completion, create `.planning/phases/02-stabilize-export-pipeline/02-03-SUMMARY.md`
documenting:
- New module src/background/tab-url-tracker.ts (LOC + public API summary) — 4 exports incl. snapshotOpenTabs (DEC-011 Amendment 1)
- SessionMetadata field-count delta (7 → 8) + ordering rationale
- 8th field `schemaVersion` decision: planner-suggested in Plan 02-01 Task 3, ratified here
- Filter rules verbatim from CONTEXT.md `<specifics>`
- DEC-011 Amendment 1 (2026-05-20): `tabs` permission ADDED; tests/i18n/manifest-i18n.test.ts pins the 8-entry set; PROJECT.md DEC-011 row rewritten with Amendment 1 prose
- F2 resolution: empty-tracker case emits `urls: []` (no fake extension-origin fallback)
- Forward link: Plan 02-04 A27 strict-mode (both URLs required) is unblocked by Amendment 1
</output>
