Skip to content

Proj/analytics system tool mode#726

Open
bao-byterover wants to merge 150 commits into
mainfrom
proj/analytics-system-tool-mode
Open

Proj/analytics system tool mode#726
bao-byterover wants to merge 150 commits into
mainfrom
proj/analytics-system-tool-mode

Conversation

@bao-byterover
Copy link
Copy Markdown
Collaborator

Summary

  • Problem: ByteRover CLI had no first-party analytics pipeline. Daemon
    lifecycle, CLI invocations, MCP sessions, curate/query funnels,
    HITL review, settings, version-control, and onboarding signals were
    all invisible to product / on-call.
  • Why it matters: without a funnel for usage and a failure-mode signal
    for the daemon, we cannot prioritise the next round of polish,
    catch silent regressions in agent flows, or measure feature
    adoption per release.
  • What changed: shipped the full analytics-system project, M1 through
    M15.8. New brv analytics enable | disable | status commands, a
    WebUI Configuration > Analytics panel, durable JSONL queue, batched
    HTTP sender with exponential backoff and identity-transition
    flushes, a 47-event catalogue with strict Zod schemas, a privacy
    fixture that statically rejects forbidden field names, daemon-side
    lifecycle hooks for tasks / curate / query, and producer-side
    wiring for daemon_start / cli_invocation / mcp_session_start /
    mcp_session_ended / mcp_tool_called.
  • What did NOT change (scope boundary): no analytics emit code lands
    under src/agent/ (layering rule preserved). No producer for
    onboarding_auto_setup_started / onboarding_completed yet,
    pending an agent-team signal (spec at
    agent-tmp/m15-8-onboarding-signal-spec.md). No space_switched
    producer (waits on the snapshot-removal project). The cli_metadata
    outgoing helper currently covers only 6 of around 80 oclif
    commands; the daemon middleware emits for all that participate.

Type of change

  • Bug fix
  • New feature
  • Refactor (no behavior change)
  • Documentation
  • Test
  • Chore (build, dependencies, CI)

Scope (select all touched areas)

  • TUI / REPL
  • Agent / Tools
  • LLM Providers
  • Server / Daemon
  • Shared (constants, types, transport events)
  • CLI Commands (oclif)
  • Hub / Connectors
  • Cloud Sync
  • CI/CD / Infra

Also touched: WebUI Configuration > Analytics panel + analytics API
(not in the template list).

Linked issues

  • Closes ENG-3001 (M15.8 deferred-events wiring)
  • Closes 38 prior sub-tickets across M1 through M15.7 of the
    analytics-system project. Representative anchors: ENG-2611 (analytics
    GlobalConfig field), ENG-2628 (analytics:track transport event),
    ENG-2686 (per-event schema catalogue), ENG-2649 (e2e harness),
    ENG-2772 (lifecycle-hook frontmatter harvest), ENG-2807 (M13.3
    cli_metadata sweep batches 1+2), ENG-2961 (M15.1 foundation),
    ENG-2967 (M15.4 HITL + settings + reset), ENG-2963 (M15.5 WebUI
    session lifecycle), ENG-2964 (M15.6 tool-mode analytics).
  • Related: agent-team follow-up for onboarding_auto_setup_started /
    onboarding_completed signal, spec at
    agent-tmp/m15-8-onboarding-signal-spec.md. Not filed yet.

Root cause (bug fixes only, otherwise write N/A)

  • Root cause: N/A. This is a feature project, not a bug fix.
  • Why this was not caught earlier: N/A.

Test plan

  • Coverage added:
    • Unit test
    • Integration test
    • Manual verification only (the e2e harness runs against
      dev-beta; manual smoke validates real-backend send + sent
      status in the JSONL queue)
  • Test file(s): 88 test files added or modified under
    test/unit/server/infra/analytics/,
    test/unit/shared/analytics/,
    test/unit/infra/process/,
    test/unit/infra/client/,
    test/integration/infra/process/analytics-hook-lifecycle-wiring.test.ts,
    test/e2e/analytics/dev-beta.e2e.ts, plus webui +
    oclif-command tests.
  • Key scenario(s) covered: JSONL queue durability with crash mid-write,
    bounded-queue drop-oldest semantics, HTTP sender 2xx + 5xx + 400
    • offline, exponential backoff with reachability counter,
      auth-transition force-flush, batched flush (30s tick + 20-event
      threshold + shutdown drain), wire-side per-event Zod validation,
      privacy fixture (no forbidden field name across any event),
      daemon lifecycle hook drains in-flight per-op processing
      before terminal emit, WebUI session start + end with reconnect
      orphan-end, MCP session start + end with handshake-timing race,
      mcp_tool_called for success + error + cancellation,
      cli_invocation middleware fires on cli_metadata-bearing requests
      and bails on malformed metadata, idempotent middleware attach.

User-visible changes

  • New commands: brv analytics enable, brv analytics disable,
    brv analytics status (with --format json for scripting).
  • New WebUI panel under Configuration > General > Analytics:
    enable / disable toggle with first-run disclosure dialogue,
    status line surfacing queue depth + last successful flush +
    backoff state.
  • First-run disclosure: clear opt-in prompt. Default is OFF; no data
    is collected unless the user accepts.
  • brv login and 5 other oclif commands now send cli_metadata so
    the daemon can emit cli_invocation per command. No prompt
    difference to the user.
  • No change to existing commands' output, exit codes, or behavior
    when analytics is disabled.

Evidence

  • Failing test before, passing after: see per-milestone commits;
    M15.5 added 75 ClientManager session tests; M15.6 added the
    lifecycle-wiring integration test; M15.8 added 31 new cases
    (client-manager-mcp-analytics + analytics-hook-mcp-tool-called +
    task-router-client-identity-snapshot + cli-invocation-middleware).
  • npm test on the merge head: 9726 passing, 16 pending, 0 failing (run during M15.8 verification).
  • git grep "from.*shared/analytics/emit" src/oclif src/agent src/server/infra/mcp returns empty (daemon-only instrumentation
    rule holds).
  • Privacy fixture: green. Every event schema in ALL_EVENT_SCHEMAS
    rejects every name on FORBIDDEN_FIELD_NAMES.

Checklist

  • Tests added or updated and passing (npm test): 9726 / 16
    pending / 0 failing as of the M15.8 verification step.
  • Lint passes (npm run lint): 0 errors, 279 warnings
    (warnings are pre-existing repo noise, none introduced).
  • Type check passes (npm run typecheck): root TS check passes;
    the WebUI sub-project check (tsc -p src/webui/tsconfig.json)
    reports 6 errors in 4 files about TopicViewerProps missing
    breadcrumb / index / related. These pre-date this branch
    (confirmed by git stash rerun on base) and originate from a
    separate adopt-callback-based-topic-viewer commit waiting on a
    @campfirein/byterover-packages bump. Flagged under Risks
    below; not a regression introduced by this PR.
  • Build succeeds (npm run build): not re-verified in this PR
    session because build:ui is gated on the same WebUI
    typecheck above. Verify after the topic-viewer package bump
    lands.
  • Commits follow Conventional Commits format: every commit on the
    branch uses feat: [ENG-XXXX] ... or fix: [ENG-XXXX] ....
  • Documentation updated (if applicable): CLAUDE.md was kept in
    sync as milestones landed (analytics commands, env vars,
    e2e instructions, settings keys, billing-line). Spot-check
    before merging.
  • No breaking changes (or clearly documented above): opt-in,
    default-off feature. No public command shape changed. The
    IAnalyticsClient interface is daemon-internal.
  • Branch is up to date with main: last merge from main was
    commit 0ef90884f Merge branch 'main' into proj/analytics-system-tool-mode. Verify zero diff vs.
    origin/main before pressing merge.

Risks and mitigations

  • Risk: WebUI typecheck errors on TopicViewerProps block the husky
    pre-push hook and any CI step that runs the full npm run typecheck. Six errors across context-detail-panel.tsx,
    root-index-detail.tsx, curate-tool-mode-sections.tsx,
    task-detail-sections.tsx.

    • Mitigation: not introduced by this PR. Resolves once
      @campfirein/byterover-packages is bumped to the version that
      exports the callback-based TopicViewer API (or build with
      BRV_UI_SOURCE=submodule after git submodule update --init --recursive). Coordinate with the shared-UI submodule owner
      before merging.
  • Risk: large surface area (253 files, around 26k LoC, 108 commits)
    raises review burden.

    • Mitigation: the work landed in 39 small per-milestone PRs into
      the project branch (each independently reviewed and merged).
      This PR is the integration-to-main step. Skim the commit list
      rather than the line-by-line diff.
  • Risk: analytics enabled in error could ship PII.

    • Mitigation: opt-in default-off, a runtime FORBIDDEN_FIELD_NAMES
      redactor with a fixture that fails CI if any event schema
      declares a name on the list, file paths are stripped to
      project-relative or sentinel'd, and the disclosure UX makes the
      opt-in explicit in both CLI and WebUI.
  • Risk: backend outage produces unbounded queue growth.

    • Mitigation: bounded queue with drop-oldest semantics, a JSONL
      cap (JsonlCapFullError), exponential backoff with a
      reachability counter, and brv analytics status surfaces the
      operational state for on-call.
  • Risk: agent processes acquire stale analytics state mid-task.

    • Mitigation: per-agent settings snapshot at fork time
      (agent-settings-snapshot.ts); per-task identity snapshot on
      TaskInfo (clientType + clientName) survives mid-task
      client disconnect.
  • Risk: M15.8 onboarding events stay empty until the agent team
    ships a skill:invoked / skill:completed signal.

    • Mitigation: gap is disclosed in this PR and in the agent-team
      spec at agent-tmp/m15-8-onboarding-signal-spec.md. Backend
      funnel queries should not yet reference these events.

Adds a typed analytics: boolean field to the GlobalConfig domain entity
with safe migration for legacy configs (defaults to false when absent
to preserve the opt-in promise across upgrades). Widens toJson() return
to a typed GlobalConfigJson shape. Type guard rejects non-boolean values
when the analytics key is structurally present.

Tests moved to test/unit/server/core/domain/entities/global-config.test.ts
and extended with the seven scenarios required by the ticket.
Adds the read-only brv analytics status oclif command, which prints
whether CLI analytics is enabled or disabled. Output is "Analytics:
enabled" or "Analytics: disabled" by default; --format json emits the
shape {"analytics": "enabled" | "disabled"} via the repo's standard
writeJsonResponse envelope.

Routing follows login / logout / locations: oclif uses withDaemonRetry
to emit a globalConfig:get transport event (auto-spawns the daemon).
The daemon-side GlobalConfigHandler re-reads the on-disk config every
call (no in-memory cache); on a fresh install it seeds a stable
deviceId via GlobalConfig.create(randomUUID()) so device identity is
available from the first read.

Help text describes what telemetry is collected and links to a
placeholder privacy policy URL pending M1.5.
Adds two oclif commands that toggle the analytics flag in GlobalConfig:
brv analytics enable and brv analytics disable. Output is "Analytics
enabled" / "Analytics disabled" on a state change, or "Analytics
already enabled" / "Analytics already disabled" when the flag was
already in the target state (idempotent, exits 0).

Routing matches M1.2: oclif uses withDaemonRetry to emit the new
globalConfig:setAnalytics transport event. The daemon-side
GlobalConfigHandler gains a SET_ANALYTICS listener that reads the
current config (or seeds a fresh one with a stable deviceId if absent),
applies the new value via GlobalConfig.fromJson round-trip (the entity
is immutable with a private constructor), and writes back. The
idempotent path skips the file write entirely when previous state
matches the requested value, so a disable on a fresh install does not
create a stub config file.

M1.3 ships without the disclosure prompt - the enable command flips
the bit immediately. M1.4 (ENG-2618) follows with the disclosure UX
wrapper. Reviewers should expect that follow-up.

Adds 13 new tests covering all 7 ticket Test plan scenarios:
4 deterministic command-level tests for enable/disable success and
idempotent paths, 1 transport-contract assertion per command, plus
3 handler-level integration tests for SET-then-GET state coherence
and concurrent-write last-writer-wins semantics.
Wraps M1.3's bit-flip path so brv analytics enable shows a disclosure
that the user must accept before the flag flips. CI / non-interactive
use is supported via --yes.

Flow: the command first reads current state via globalConfig:get. If
analytics is already enabled, it prints "Analytics already enabled"
and exits 0 with no prompt and no write. Otherwise it loads the
disclosure markdown, prints it, and either accepts implicitly (--yes),
prompts via @inquirer/prompts confirm (interactive TTY), or refuses
with a clear error (non-TTY without --yes). On accept, the existing
globalConfig:setAnalytics path flips the bit; on reject, "Analytics
not enabled" prints and the command exits 0 without writing.

Disclosure copy lives in src/server/templates/sections/analytics-disclosure.md
with lorem ipsum bodies per hoang's request in the Linear ticket.
Section headers (what / surfaces / where / cross-device alias / how to
disable / privacy policy) are load-bearing for tests and remain stable;
PM and legal will replace bodies before the M1 release. Privacy policy
URL lives in src/shared/constants/privacy.ts as a placeholder pending
the M1.5 docs page; reviewers should update it once the canonical URL
is finalized.

A TODO(M2) marker is preserved in enable.ts so that when IAnalyticsClient
lands the first event sent after enable will be analytics_enabled
itself (industry practice).

Adds 8 tests covering all 7 ticket scenarios: interactive accept,
interactive reject, --yes bypass, already-enabled short-circuit,
non-TTY refusal with non-zero exit, disclosure section coverage, and
privacy URL constant shape.

PM/legal sign-off and the milestone disclosure-file link are manual
steps to record at PR / milestone-update time.
Establishes the consumer-facing contract for the daemon-side analytics
machinery. Future M2 sub-tickets (queue, resolvers, real client,
transport handler, IPC bridge) build against this interface; the no-op
serves as the daemon's default before M2.5 wires the real one and as a
stub for unit tests.

Adds the domain types in src/server/core/domain/analytics/:
  - AnalyticsEvent (internal camelCase: name, properties, timestamp)
  - Identity (wire snake_case: user_id?, device_id, email?, name?)
  - AnalyticsEventWithIdentity (Identity-stamped event)
  - AnalyticsBatchJson (wire shape: schema_version: 1, events)
  - AnalyticsBatch class with private constructor + static create +
    static fromJson (graceful failure, returns undefined on malformed
    input) + toJson, mirroring the GlobalConfig pattern

Adds IAnalyticsClient { track; flush } in core/interfaces/analytics/
and NoOpAnalyticsClient in infra/analytics/. The no-op's track() is a
true no-op (no allocations beyond the call frame, no buffering); its
flush() always resolves to an empty batch.

Tests: 24 cases. AnalyticsBatch round-trips empty + populated batches,
and fromJson rejects 13 distinct malformations (missing schema_version,
schema_version != 1, events not array, missing/non-string event name,
missing identity, missing/empty device_id, non-number timestamp,
non-object properties, etc). NoOpAnalyticsClient verifies track does
not throw under varied input and flush stays empty after many tracks.
Adds the in-memory queue that M2.5's AnalyticsClient will buffer events
into. Configurable cap with default 1000, drop-oldest on overflow, and
a cumulative droppedCount that survives drain calls for later
observability (surfaced in M4 via brv analytics status).

The interface IAnalyticsQueue lives next to its sibling IAnalyticsClient
under core/interfaces/analytics/. The BoundedQueue implementation in
infra/analytics/ uses a plain Array with push + shift; at maxSize=1000
the O(n) shift on overflow is negligible. drain transfers ownership of
the events array to the caller and re-initializes the internal queue,
avoiding a defensive clone. dropped is a private counter that no
method resets.

The constructor validates maxSize is a non-negative integer; negative
NaN, Infinity, and fractional values throw fast at construction time.
Without this check, a negative maxSize would cause an infinite loop in
push(): the `while events.length > maxSize` condition stays true at
length === 0 when maxSize < 0, with shift() as a no-op.

Tests cover the six ticket scenarios plus constructor validation:
FIFO drain, empty-queue defaults, drop-oldest with droppedCount
tracking, multi-drop FIFO order, cumulative droppedCount across
drains, size() invariant, default-cap behavior, drain ownership
transfer, and 5 constructor cases (negative, NaN, Infinity, fractional
all throw; maxSize === 0 is accepted as a degenerate but valid cap).
17 tests total.

Test fixtures use a fresh identity object per makeEvent call rather
than a shared module-scope reference, so future tests cannot
accidentally mutate the shared identity and bleed across cases.

Note: had to introduce a small pushAll(queue, events[]) helper in the
test file because the unicorn/no-array-push-push lint rule fires on
consecutive .push() calls regardless of receiver type. The helper
wraps a for-of loop, which the rule accepts.
Adds the resolver that stamps the five super properties onto every
analytics event: device_id, cli_version, os, node_version, environment.
M2.5's AnalyticsClient will inject this resolver and merge its output
into every track() call.

Wire-format snake_case throughout (matches the rest of the analytics
domain). cli_version, os, node_version, and environment are static
across the daemon's lifetime and lazy-cached on first resolve(). The
package.json reader is invoked exactly once per resolver instance,
verified by a sinon-stub assertion.

device_id is re-read from IGlobalConfigStore on every resolve() call
so a swapped GlobalConfig is observable. The tradeoff: resolve()
returns Promise<SuperProperties> instead of sync SuperProperties,
which cascades async to M2.5's AnalyticsClient.track() (deviation
from M2.5's spec to be addressed when M2.5 lands).

The cli-version reader was duplicated inline in brv-server.ts; this
commit extracts it to src/server/utils/read-cli-version.ts and both
sites now import the single source. The walk-up depth changes from 4
to 3 levels (the new utility lives one level closer to the project
root) but resolves to the same package.json.

environment defaults to 'production' when BRV_ENV is unset or set to
any value other than 'development'. Fail-safe in unfamiliar territory.

10 tests cover the seven ticket scenarios plus three additional cases:
device_id re-read on every call, environment default when BRV_ENV is
unset, and BRV_ENV=production explicitly. Tests save & restore
process.env.BRV_ENV per scenario to avoid cross-test pollution.
Adds IdentityResolver — produces the per-event Identity that M2.5 will
stamp onto every analytics event. Anonymous: {device_id} only.
Registered: {user_id, email?, name?, device_id} where empty user fields
are OMITTED (not present as undefined) so the wire envelope stays
clean for downstream serializers.

Per ticket DoD, defines a consumer-side IAuthStateReader interface
(1 method: getToken()) co-located with IIdentityResolver in
core/interfaces/analytics/. The full IAuthStateStore is broader; the
resolver only needs sync access to the cached token. This keeps the
auth module unaware of the analytics consumer, matching CLAUDE.md
"interfaces at the consumer".

resolve() is async because IGlobalConfigStore.read() is async (same
precedent as M2.3 super-properties resolver). Each call re-reads both
sources so auth-state transitions mid-batch are observable to M2.5
(M2.5 Test #3 requires this).

Empty-field omission uses conditional spread:
  ...(token.userEmail ? {email: token.userEmail} : {})
which OMITS the key entirely. Tests assert via chai's
.to.not.have.property('email') to distinguish absent keys from
explicit-undefined values.

9 tests cover all 6 ticket scenarios plus 2 bonus cases (empty
userName, missing GlobalConfig). Auth-transition tests use a mutable
authReader wrapper to flip identity between resolve() calls,
verifying per-call freshness with no caching.
Composes M2.2 (queue), M2.3 (super-props), M2.4 (identity) into the
daemon-scoped AnalyticsClient. GlobalConfigHandler now caches the
analytics flag synchronously so AnalyticsClient.isEnabled stays sync;
refreshCache() is awaited in setupFeatureHandlers before construction
so the very first track() (daemon_start) sees the correct flag.

Adds an ESLint no-restricted-imports rule preventing IAnalyticsClient
from being imported outside src/server/infra/; non-daemon consumers
should reach analytics through the analytics:track transport event
(M2.6).
- refreshCache(): catch block now explicitly sets cachedAnalytics=false
  on read failure. Under the prior fix the field defaulted to undefined,
  so a silent catch left getCachedAnalytics() throwing — which would
  crash the daemon on bootstrap if any IGlobalConfigStore implementation
  ever rejected from read(). Production FileGlobalConfigStore catches
  its own errors, but defense-in-depth restores the comment's promise.

- GlobalConfigHandler class JSDoc: corrected stale "populated at
  construction" wording. The eager constructor-refresh was removed
  during the prior fix-pass; consumers now MUST await refreshCache().

- AnalyticsClient class JSDoc: tightened the no-crash invariant. The
  guarantee covers async resolver/queue errors only — a sync isEnabled()
  throw (e.g. getCachedAnalytics before refreshCache) propagates by
  design to surface bootstrap-misconfiguration loudly.

- New integration test locks the refreshCache fail-safe: a stub
  IGlobalConfigStore whose read() rejects must leave the cache at false
  (NOT undefined), and getCachedAnalytics() must NOT throw afterwards.
Implements M2.6 transport mechanism for analytics:

- analytics:track transport event with wire-level Zod validation
  (src/shared/transport/events/analytics-events.ts,
   AnalyticsTrackPayloadSchema in core/domain/transport/schemas.ts)
- Daemon-side AnalyticsHandler routes valid payloads to
  AnalyticsClient.track (src/server/infra/transport/handlers/
   analytics-handler.ts, wired in feature-handlers.ts)
- Shared emitAnalytics helper at src/shared/analytics/emit.ts for
  in-process consumers (TUI, oclif, MCP, webui)
- Round-trip integration test exercises stub-emit -> handler -> queue
  with full identity + super-properties stamping

Deviation from ticket spec (see ENG-2628 Linear comment):
- Oclif sample consumer (cli_invocation from lifecycle hook) NOT
  shipped -- team decided against oclif lifecycle hooks.
  Follow-up ticket needed.

Tests added: 21 (schema 9, handler 5, helper 4, integration 3).
Schema-only milestone. No emitter wiring, no daemon-side validation.
Each shipped analytics event now has a TypeScript type plus a Zod
.strict() runtime schema, and typed enums eliminate magic strings.

Events covered (7):
- daemon_start (M2.5): empty schema; super-properties cover cold-start
- cli_invocation (M2.6): command_id, flag_names, is_tty, is_ci,
  terminal_program?, runtime, package_manager
- mcp_session_start (M2.6): client_name
- mcp_tool_called (M2.6): tool_name, client_name, success, duration_ms
- task_created: task_type, task_id, has_files, has_folder
- task_completed: task_type, task_id, duration_ms
- task_failed: task_type, task_id, duration_ms

Typed enums:
- AnalyticsEventNames (7 entries): wire-format event names
- TaskTypes (5 entries): mirrors daemon TaskInfo.type union
- TASK_TYPE_VALUES tuple: single source of truth used by every task_*
  schema's z.enum(...) call

Privacy fixture walks ALL_EVENT_SCHEMAS and rejects any field name on
the forbidden PII list (cwd, argv, path, email, result, ...). Adding
a banned field name later fails the build.

Wire-shape source of truth: AnalyticsTrackPayloadSchema lives in
shared/transport/events/analytics-events.ts (alongside the
AnalyticsEvents constants). Both the daemon handler and the shared
emitAnalytics helper consume the same Zod-derived
AnalyticsTrackPayload type, so the wire shape can no longer drift
between two declarations. The previous server-side duplicate has
been removed.

Drift detection: a new compile-time + runtime test asserts the
shared TaskTypes enum stays in lock-step with the server-side
TaskTypeSchema. If a daemon contributor adds a new TaskInfo.type
value and forgets to mirror it, the build fails loudly instead of
emitting events that silently fail wire-side validation.

Magic-string cleanup: the only existing analytics emit site at
brv-server.ts now passes AnalyticsEventNames.DAEMON_START instead
of the raw string literal.

Deliberate deviations from ticket text:
1. command_id is z.string() not a typed enum: oclif manifest is the
   source of truth (~80 commands), a hardcoded mirror would rot.
2. Emitter wiring (oclif init, MCP oninitialized, MCP wrapper) and
   TaskAnalyticsHook are deferred to a follow-up ticket; this
   milestone ships schema declarations only.
3. task_failed has no error_class/error_code: that would require a
   breaking ITaskLifecycleHook.onTaskError signature change which
   belongs in its own ticket.

29 files changed, 0 files modified outside this milestone's scope.
Tests: 7500 passing (+74 new under shared/analytics/ + drift + payload
schema). Lint, typecheck, build all green.
… depth)

Four review-driven hardening fixes on top of the M2.8 catalog. Zero
behavior changes for existing wire/runtime callers; all changes are
type-tightening or test-quality improvements.

NF2 — Typed `emitAnalytics`. The shared emit boundary now takes a
generic `<E extends AnalyticsEventName>` and derives the properties
type from the M2.8 discriminated union. Magic-string typos
(`'daemon_starts'`) and wrong-shape payloads
(`tool_name` on `cli_invocation`) become compile errors instead of
runtime drops. The properties argument is optional only for events
whose schema has no required keys (e.g. `daemon_start`); other events
require a fully-shaped payload.

NF5 — Replace batch.ts hand-rolled type guards with Zod. The three
`as Record<string, unknown>` casts (which violated CLAUDE.md
"avoid `as Type` assertions") are gone, replaced by
IdentityWireSchema / AnalyticsEventWithIdentityWireSchema /
AnalyticsBatchJsonSchema. All 19 existing fromJson rejection tests
still pass — Zod preserves the same edge cases (empty/whitespace
device_id, missing schema_version, non-object properties, etc.) at
~half the line count.

I5 — Privacy fixture walker recurses into nested ZodObject,
ZodArray elements, and ZodOptional / ZodNullable wrappers. Three
new regression tests verify the walker catches forbidden names
(`email`, `password`, `token`, `api_key`) embedded inside nested,
arrayed, and optional/nullable shapes. Today's schemas are flat, but
the fixture now stays correct as future schemas grow.

I6 — Extend FORBIDDEN_FIELD_NAMES from 20 to 41 entries: adds
secrets (`access_token`, `auth_token`, `api_key`, `cookie`,
`credential`, `password`, `secret`, `session_id`, `session_token`,
`token`, `auth_header`), additional PII (`address`, `display_name`,
`first_name`, `last_name`, `phone`, `phone_number`, `ssn`,
`username`). The list now matches a realistic threat model for the
analytics pipeline.

NF7 — Already covered by existing test #6 in
test/commands/analytics/enable.test.ts (retracted; my round-4 review
hallucinated the gap because I read only the first 80 lines of the
file).

Tests: 7503 passing (+3 walker tests). Lint, typecheck, build all
green. No production wiring changed; the only existing emit site
(brv-server.ts) still calls `analyticsClient.track(...)` directly
and is unaffected.
- New stored-record.ts: StoredAnalyticsRecord extends AnalyticsEventWithIdentity
  with {id, status, attempts}; Readonly via z.infer wrapper for consistency
  with rest of analytics domain (Identity, AnalyticsEvent, batch types)
- Zod schema for read-from-disk validation; default strip mode (matches
  batch.ts precedent, forward-compat with future field additions)
- Export MAX_ATTEMPTS=3 constant for M9.2's updateStatus retry-cap policy
- toWireEvent helper strips local-only fields (id, status, attempts) for
  M4 backend ship path
- batch.ts and existing M2 wire types unchanged (boundary preserved)
- 23 unit tests: 1 const + 16 schema + 6 helper; full suite 7526 passing
- New IJsonlAnalyticsStore interface: append/updateStatus/list/loadPending
  plus droppedSentCount/droppedFullCount counters for M4.6 observability
- New JsonlAnalyticsStore class: file-backed JSONL at
  {baseDir}/analytics-queue.jsonl
- Retry-cap policy lives inside updateStatus(_, 'failed'): increment attempts;
  stay 'pending' while attempts < MAX_ATTEMPTS; flip terminal 'failed' at cap;
  no overshoot on terminal rows
- Write serialization via private writeChain Promise mutex; eliminates
  appendFile vs readFile/rename interleave race for concurrent track + flush
- Atomic rewrite: tmp file + rename (mirrors FileQueryLogStore.writeAtomic)
- Append uses FileHandle.sync() for fsync durability so row survives daemon kill
- File-size cap (default 5000 rows / 10MB): drop oldest sent first; pending and
  failed never dropped; if cap full of pending+failed, append silent no-op
  plus droppedFullCount++
- list sort: (timestamp DESC, id DESC) for stable same-timestamp ordering
- Read methods (list, loadPending) skip the write chain; consistent snapshot
  via atomic rename
- Corruption tolerance: skip JSON.parse-failures and Zod-schema-failures
- 30 unit tests using tmpdir(); full suite 7554 passing
…sync (M9.3)

- Widen IAnalyticsQueue + BoundedQueue from AnalyticsEventWithIdentity to
  StoredAnalyticsRecord; id propagates through the queue
- AnalyticsClientDeps adds jsonlStore: IJsonlAnalyticsStore
- trackAsync: resolve identity + super-props, generate randomUUID id,
  compose StoredAnalyticsRecord{status:'pending', attempts:0},
  await jsonlStore.append() FIRST, then queue.push() on success
- JSONL append failure: queue NOT pushed, silent drop preserves
  "JSONL is source of truth" invariant; "MUST NOT crash consumer" honored
- feature-handlers.ts constructs ONE JsonlAnalyticsStore({baseDir:
  getGlobalDataDir()}) and injects into AnalyticsClient. Same instance
  will be shared with M11.2's analytics-list-handler
- 6 new unit tests covering dual-write happy path, JSONL-fail fallback,
  uuid distinctness, queue.size() = JSONL row count, disabled no-op
- All existing M2 AnalyticsClient + BoundedQueue tests pass with widened
  type
- Integration tests (daemon-tracking, transport) updated with real
  JsonlAnalyticsStore({baseDir: testDir}) per tmpdir convention
- Full suite 7562 passing
Comment thread src/server/infra/analytics/analytics-client.ts
Comment thread src/server/infra/process/synthetic-tool-result-emit.ts
Comment thread src/server/infra/transport/handlers/context-tree-handler.ts
Comment thread src/shared/utils/format-settings.ts
…nabled to analytics.share (#746)

The key name now reflects what it controls: local analytics tracking is always
on (regardless of auth or this setting); the flag only gates whether collected
events are shared with the remote telemetry backend.

- Rename the user-facing key value analytics.enabled -> analytics.share across
  settings set/get/list, the onboarding opt-in script, and the disclosure
  command reference. Hard rename (no alias) — the feature is unreleased.
- Behavior unchanged: tracking stays always-on locally; the flag still gates
  only remote sharing.
- Internals kept (value-only rename): GlobalConfig.analytics field, the
  globalConfig:setAnalytics transport event, in-process method names, and the
  key-constant identifier names.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 30, 2026

Claude finished @bao-byterover's task in 3m 29s —— View job


Claude PR Review — Analytics System (M1 → M17) — Sixteenth pass

Overall score: 3 / 5 — Same engineering substance carrying forward (durable JSONL queue, careful failure-mode taxonomy, exceptional test coverage). The new ENG-3035 commit (0f44a29) is a precise, value-only rename of the user-facing key (analytics.enabledanalytics.share) that materially improves the trust-separation framing — the verb "share" makes it linguistically obvious that the toggle gates remote sharing, not local capture. That partially closes the eighth-pass finding O on the onboarding doc's "two promises" framing, without changing internals. Score held at 3/5 because the single ship-blocker from the prior fifteen reviews — lorem-ipsum disclosure body — is still in the tree.

Todos

  • Identify new commits since the fifteenth review (0f44a29 — ENG-3035 value-only rename)
  • Re-check analytics-disclosure.md (15× prior ship-blocker — still lorem ipsum)
  • Spot-check 0f44a29 changes + verify rename consistency across surfaces
  • Re-verify other prior unresolved findings
  • Post inline issues + summary

What's new since the fifteenth review (1 commit)

  • 0f44a29 (ENG-3035) — Hard rename of the user-facing key value analytics.enabledanalytics.share across 27 files. The rename is value-only by design (per the commit message): GlobalConfig.analytics field, the globalConfig:setAnalytics transport event, in-process method names (setAnalytics, analyticsClient), and the key-constant identifier names (ANALYTICS_ENABLED_KEY, SETTINGS_KEYS.ANALYTICS_ENABLED) all keep their pre-rename identifiers. The rename touches the wire key value, every place the user-facing command form appears in docs/comments/error messages, the e2e harness, the onboarding script, the disclosure markdown's "How to disable" command form (line 31), and the test cases asserting the wire key. No alias path — the feature is unreleased, so the rename is clean-break.

New findings on this pass

  1. analytics-disclosure.md — ship-blocking, sixteenth flag. Same body, key name on line 31 now reads brv settings set analytics.share false. Lorem-ipsum bodies otherwise unchanged. The rename means PM/legal can now lean on the verb in the key name itself to carry the trust-separation framing in the eventual real copy. Inline →

  2. settings-keys.ts:13 — code-quality drift from value-only rename, non-blocking. ANALYTICS_ENABLED_KEY = 'analytics.share' — identifier and value now disagree. Same on SETTINGS_KEYS.ANALYTICS_ENABLED, the setAnalytics method, globalConfig:setAnalytics transport event, and test/commands/settings/analytics-enabled.test.ts filename. Defensible scope (no internal churn), but future maintainers grep'ing in either direction take an extra hop. Two cheap follow-ups: identifier-rename pass OR a one-line redirect comment. Inline →

  3. settings.ts:200 — praise. The descriptor sentence ("Send anonymous telemetry to ByteRover. Local tracking is always on.") + the renamed key (analytics.share) + the (eventual) real disclosure body form a three-way agreement on the trust-separation framing. Recommend reusing this descriptor sentence verbatim as the lead paragraph of the disclosure-md "What is collected" section so the three surfaces stay locked in step. Inline →

  4. onboarding.md:290 — verbal-script gaps re-evaluated post-rename, non-blocking. The eighth-pass finding O ("two promises framing") is now further mitigated by the rename: a user typing brv settings set analytics.share false sees "share" not "enabled," reaching the right mental model linguistically. Still unresolved: super-properties undersold (no device_id / os / space_id / team_id), cross-device alias omitted (permanence + ENG-3019 rotation softening neither mentioned), no persisted declined state vs. "Do not re-ask in future sessions." Bundle into the PM/legal copy round. Inline →

Prior findings — status

# File Finding Status
A analytics-disclosure.md + webui/features/analytics/constants.ts:13-39 Lorem-ipsum body ship-blocker, 16th flag
B shared/constants/privacy.ts:6 PRIVACY_POLICY_URL (placeholder until M1.5) user-facing copy resolved; URL inconsistency vs. WebUI remains
C http-analytics-sender.ts:62-69 No-deviceId returns no reason resolved by ENG-2658
D analytics-hook.ts:88 <outside-project>/${basename} leaks filename leaf resolved by ENG-2658
E analytics-handler.ts dispatch switch 48 cases scaling poorly unresolved (non-blocking)
F event-names.ts:32 MCP_SESSION_START tense asymmetry unresolved (non-blocking)
G auth-state-store.ts:175 Sequential pre-change listener wait — undocumented unresolved (non-blocking)
H analytics-client.ts:248 Microscopic pendingTracks.add race unresolved (non-blocking)
I migrate-handler.ts:132-140 Brittle string-prefix match resolved by ENG-2658
J synthetic-tool-result-emit.ts:96-109 TOOL_CALL → TOOL_RESULT ordering unresolved (non-blocking)
K format-analytics-status.ts:114 Import-time side-effect registration unresolved (non-blocking)
L webui/pages/configuration/general.tsx:3 Style nit resolved earlier
M noop-analytics-sender.ts vs. no-op-analytics-sender.ts Twin classes one hyphen apart resolved by ENG-2658
N onboarding.md:290-292 Verbal-script scope gap partially mitigated by ENG-3035 rename; "two promises" framing component closed
O onboarding.md:299-303 "Two promises" framing vs. code reality further mitigated by ENG-3035 — the key value itself now reads share
P onboarding.md:305-308 No persisted declined state unresolved (non-blocking)
Q batch.ts:24 schema_version: 1 despite breaking wire change resolved earlier
R stored-record.ts:99-109 toWireEvent derivation caveats resolved earlier
S auth-handler.ts:453 Rotate-after-emit TOCTOU unresolved (non-blocking)
T i-global-config-rotator.ts:19 Non-idempotent contract undocumented unresolved (non-blocking)
14-2 analytics-client.ts:388-390 Rate-limited records bump retry-cap unresolved (non-blocking)
U webui/features/analytics/constants.ts:11 docs.byterover.dev URL drift unresolved (non-blocking)
V webui/features/analytics/constants.ts:13-39 Disclosure body duplicated across markdown + WebUI folded into Finding A
16-NEW settings-keys.ts:13 Value-only rename leaves identifier/value name drift unresolved (non-blocking)

What's good in this push (ENG-3035)

  • Linguistic improvement. analytics.share carries the trust-separation framing in the key name itself. A user reading brv settings list sees a verb that means what it does. That's a real product-language win for an opt-in surface.
  • Hard rename discipline. No alias path, no backwards-compat shim, no deprecation warning. Because the feature is unreleased, the rename is clean-break — exactly the right call for pre-release maintenance.
  • Internal stability preserved. The deliberate value-only scope keeps the transport-event names, the in-process method names, and the constant identifiers fixed — no churn in GlobalConfigHandler, the wireAnalyticsHttpSender shape, or any test that doesn't directly assert the wire key. Minimizes regression risk on a long-running branch.
  • Test coverage stays pinned. 17 tests updated to assert 'analytics.share' rather than 'analytics.enabled'; the disclosure-content test at analytics-disclosure-content.test.ts:32 now pins the post-M16.4 disable command form so a future regression that re-introduces brv analytics disable fails loudly.
  • Comment + docblock sweep is thorough. grep confirms zero stale analytics.enabled user-facing references in source; the only analytics_enabled mentions are intentional ones in global-config-handler.ts:212 (referring to the event analytics_enabled which the code intentionally does NOT emit) and the test at global-config-handler.test.ts:532 (asserting that non-emission).

Items outside the inline scope (carried forward)

  • WebUI typecheck still broken per PR description — confirm @campfirein/byterover-packages bump before merge or husky pre-push blocks.
  • build:ui not re-verified — gated on the same package bump.
  • x-byterover-session-id header in axios-analytics-http-client.ts:81 — backend contract note still pending.

Verdict

One ship-blocker, unchanged for the sixteenth review in a row. Replace lorem-ipsum disclosure copy in both src/shared/assets/analytics-disclosure.md and src/webui/features/analytics/constants.ts:13-39 with PM/legal-approved text. The ENG-3035 rename makes the PM/legal round materially easier — the key name now does linguistic work the disclosure body used to have to do — so the rewrite can be tighter than it would have been before. Resolve the byterover.dev/privacy vs. docs.byterover.dev/privacy URL drift (Finding U) and the verbal-script gaps (Finding N) in the same round. Every other finding is recommended-but-not-blocking.
· branch: proj/analytics-system-tool-mode

Comment thread src/shared/assets/analytics-disclosure.md Outdated
Comment thread src/shared/constants/settings-keys.ts
Comment thread src/server/core/domain/entities/settings.ts
Comment thread src/server/templates/skill/onboarding.md
device_id is now seeded at daemon bootstrap and before each remote flush, independent of the analytics flag. Previously it was created only when analytics was enabled, so fresh or never-authed users recorded events with an empty device_id that the wire schema and sender dropped, and telemetry never shipped anonymously.

analytics:track dispatch now derives from a single source of truth (ALL_EVENT_SCHEMAS): AnyAnalyticsEvent is derived from it, the handler validates against it and forwards through a segregated IWireEventTracker, replacing the per-event switch. Also fixes silently dropped swarm_* events. No as-casts.
The status text read 'Analytics: enabled/disabled', but local analytics tracking is unconditional (AnalyticsClient.track always records to JSONL); only remote sharing (analytics.share) toggles. Relabelled to 'Analytics sharing: enabled/disabled' so it no longer implies tracking stopped.

Read-only settings rows (e.g. analytics.status) are now expandable in the TUI: Enter opens a detail panel showing the full multi-line value (list rows only render the headline), Esc returns to the list. Adds displayDetail to the SettingsRow view-model, carrying the full block.
isAnalyticsEventName used `value in ALL_EVENT_SCHEMAS`, which matches inherited Object.prototype keys ('toString', 'constructor', '__proto__', ...). Such an event passed the guard, then ALL_EVENT_SCHEMAS[event].safeParse resolved to an inherited member (not a Zod schema) and threw at the wire-side dispatch, escaping the handler. Switch to Object.hasOwn (own-property only) so prototype-key events drop like any other unknown event. Add a regression test that asserts they drop without throwing.

Also refresh the AnalyticsClient class docblock to match the unconditional track() contract: local recording is unconditional, the isEnabled gate lives in flush() (remote send), not track().

Add test/e2e/analytics/mcp-tool-db.e2e.ts (npm run test:e2e:mcp): drives the real 'brv mcp' over JSON-RPC and verifies mcp_session_start / mcp_tool_called / mcp_session_ended land in postgres raw_events across brv-query and brv-curate roundtrips (anonymous), plus device_id rotation on auth:logout.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 30, 2026

Claude finished @bao-byterover's task in 5m 43s —— View job


Claude PR Review — Analytics System (M1 → M17) — Seventeenth pass

Overall score: 3 / 5 — Same engineering substance carrying forward (durable JSONL queue, careful failure-mode taxonomy, exceptional test coverage). The three new commits in this push are the most productive bundle since ENG-2658: one fixes a real anonymous-shipping bug (fresh users silently shipped nothing), one closes the long-standing 48-case dispatch-switch finding from the very first review (E), one closes a security-relevant prototype-pollution guard bypass, and a UX-quality push furthers the trust-separation framing via the "Analytics sharing" label change. Score held at 3/5 because the single ship-blocker from the prior sixteen reviews — lorem-ipsum disclosure body — is still in the tree.

Todos

  • Identify new commits since the sixteenth review (219da05, 1014a9f, b2274ea)
  • Re-check analytics-disclosure.md (16× prior ship-blocker — still lorem ipsum)
  • Spot-check three substantive new commits
  • Re-verify prior unresolved findings (Finding E closed)
  • Post inline issues + summary

What's new since the sixteenth review (3 substantive commits)

  • 219da05 — always-present device_id + single-source analytics:track dispatch. Three layered fixes:

    1. ensureDeviceId() on GlobalConfigHandler seeds the install-level device_id at daemon bootstrap (feature-handlers.ts:204-210) and again before each remote flush (wired through analytics-client.ts:404's new ensureDeviceId?: () => Promise<void> dep), independent of the analytics.share flag. Canonical seeder (ensureDeviceIdOn) is the single place a UUID is generated; doSetAnalytics now reuses it. Closes a real anonymous-shipping bug: previously device_id was only created when analytics.share flipped true, so a fresh / never-authed user recorded events with an empty device_id that the wire schema + sender dropped → silent zero-telemetry for new users.
    2. AnalyticsHandler dispatch consolidation — the 330-line, 48-case switch collapses to a 6-line table-driven dispatch via ALL_EVENT_SCHEMAS. New segregated IWireEventTracker interface (ISP-correct: handler depends on the one method it uses; track<E> test doubles unaffected). Closes Finding E (standing since pass 1) — also incidentally fixes silently dropped swarm_* events (pinned by a new test).
    3. CliMetadata type cleanup — removes the Record<string, unknown> & intersection that was a cast workaround the prior dispatch shape required.
  • 1014a9f — "Analytics: enabled/disabled" → "Analytics sharing: enabled/disabled" + TUI read-only detail panel.

    1. The status snapshot label correctly surfaces the trust-separation semantics (local tracking is unconditional; only sharing toggles). Third compounding piece alongside the analytics.share rename (ENG-3035) and the descriptor honesty at settings.ts:200. Further closes Finding O.
    2. New displayDetail field on SettingsRow carries the full multi-line value (analytics.status is multi-line by nature); list rows still render the headline-only displayCurrent. Enter on a read-only row opens an overlay; Esc returns.
  • b2274ea — prototype-pollution guard + MCP-tool DB e2e + docblock refresh.

    1. Security-relevant fix. isAnalyticsEventName switched from value in ALL_EVENT_SCHEMAS to Object.hasOwn(...). A client sending {event: 'toString'} previously passed the guard (in walks prototype chain) and crashed the handler at safeParse (Function.prototype.toString has no .safeParse). Excellent regression test walks five prototype keys and asserts both no-throw AND no-track-reached.
    2. AnalyticsClient class docblock refresh — explicitly documents the unconditional track() contract and includes "Do NOT reintroduce an isEnabled() gate here: that was the bug where fresh/never-authed users recorded nothing" — the kind of historical-failure-mode comment that forward-defends against drift.
    3. MCP-tool DB e2e — 690 lines driving real brv mcp over JSON-RPC, verifying the mcp_* trio + device_id rotation on logout. npm run test:e2e:mcp.

New findings on this pass

  1. analytics-disclosure.md — ship-blocking, seventeenth flag. No change. Bundle the lorem-ipsum body fix in both src/shared/assets/analytics-disclosure.md and src/webui/features/analytics/constants.ts:13-39 in the same PM/legal copy round.
  2. analytics-handler.ts:62 — praise. Finding E closed (standing since 1st review). 330 lines → 6 lines.
  3. events/index.ts:174 — praise (security). Prototype-pollution guard bypass closed.
  4. global-config-handler.ts:100 — praise + question. ensureDeviceId bootstrap+per-flush+canonical-seeder is the right shape; open question on whether a bootstrap+first-flush race is worth pinning with a test.
  5. format-analytics-status.ts:63 — praise + observation. "Analytics sharing" label closes Finding O further; JSON envelope's enabled key is unchanged — defensible (script callers) but worth a one-line decision record on text-vs-JSON asymmetry.
  6. settings-page.tsx:476 — praise + small observation. Detail overlay is the right UX shape; the displayCurrent fallback in renderDetailOverlay is structurally unreachable from the current Enter binding (Enter only routes readonly-info rows there).
  7. analytics-client.ts:94 — praise. Class-docblock refresh documents the unconditional track() contract and names the historical bug ("Do NOT reintroduce…") that the prior isEnabled() gate caused.

Prior findings — status

# File Finding Status
A analytics-disclosure.md + webui/features/analytics/constants.ts:13-39 Lorem-ipsum body ship-blocker, 17th flag
B shared/constants/privacy.ts:6 PRIVACY_POLICY_URL annotated (placeholder until M1.5) user-facing copy resolved; URL inconsistency vs. WebUI remains
C http-analytics-sender.ts:62-69 No-deviceId returns no reason resolved by ENG-2658
D analytics-hook.ts:88 <outside-project>/${basename} leaks filename leaf resolved by ENG-2658
E analytics-handler.ts dispatch switch 48 cases scaling poorly vs. table-driven dispatch RESOLVED by 219da05 — table-driven via ALL_EVENT_SCHEMAS
F event-names.ts:32 MCP_SESSION_START tense asymmetry unresolved (non-blocking)
G auth-state-store.ts:175 Sequential pre-change listener wait — undocumented unresolved (non-blocking)
H analytics-client.ts:248 Microscopic pendingTracks.add race unresolved (non-blocking)
I migrate-handler.ts:132-140 Brittle string-prefix match resolved by ENG-2658
J synthetic-tool-result-emit.ts:96-109 TOOL_CALL → TOOL_RESULT ordering unresolved (non-blocking)
K format-analytics-status.ts:114 Import-time side-effect registration unresolved (non-blocking)
L webui/pages/configuration/general.tsx:3 Style nit resolved earlier
M noop-analytics-sender.ts vs. no-op-analytics-sender.ts Twin classes one hyphen apart resolved by ENG-2658
N onboarding.md:290-292 Verbal-script scope gap unresolved (bundled into disclosure-body fix round)
O onboarding.md:299-303 "Two promises" framing vs. code reality further mitigated by 1014a9f "Analytics sharing" label
P onboarding.md:305-308 No persisted declined state unresolved (non-blocking)
Q batch.ts:24 schema_version: 1 despite breaking wire change resolved earlier
R stored-record.ts:99-109 toWireEvent derivation caveats resolved earlier
S auth-handler.ts:453 Rotate-after-emit TOCTOU unresolved (non-blocking)
T i-global-config-rotator.ts:19 Non-idempotent contract undocumented unresolved (non-blocking)
14-2 analytics-client.ts:388-390 Rate-limited records bump retry-cap unresolved (non-blocking)
U webui/features/analytics/constants.ts:11 docs.byterover.dev URL drift unresolved (non-blocking)
V webui/features/analytics/constants.ts:13-39 Disclosure body duplicated folded into Finding A
16-NEW settings-keys.ts:13 Value-only rename leaves identifier/value drift unresolved (non-blocking)

What's good in this push

  • Finding E is closed. The 48-case switch was first flagged on the 1st review pass; it has now collapsed into a 6-line table-driven dispatch backed by ALL_EVENT_SCHEMAS as the single source of truth. AnyAnalyticsEvent, PropsForEvent, PropsArg, the runtime guard, and the handler's per-event validation all derive from the same map. No drift surface.
  • Anonymous-shipping fixed. ensureDeviceId at bootstrap means a fresh / never-authed user's events now carry a real device_id from the very first track, and the per-flush belt-and-suspenders guards against runtime config wipes — both via the same canonical seeder, idempotent and serialized through writeChain.
  • Prototype-pollution guard. Object.hasOwn instead of in is a 7-character fix that closes a class of TypeError-throwing crashes. The regression test discipline (walks five prototype keys, asserts no-throw + no-track-reached) is exactly the shape that prevents the same bug from creeping back in.
  • Trust-separation framing now triple-anchored. Key name (analytics.share) + descriptor description (settings.ts:200 "Local tracking is always on") + status snapshot label ("Analytics sharing: enabled/disabled") all linguistically consistent. The eighth-review finding O is meaningfully closer to closed.
  • AnalyticsClient docblock refresh. "Do NOT reintroduce an isEnabled() gate here: that was the bug where fresh/never-authed users recorded nothing and shipped no telemetry" — names the historical failure mode in the place where a future maintainer would reach for the optimization. Forward-defended.
  • MCP-tool DB e2e rounds out the lifecycle-db.e2e coverage that previously couldn't exercise clientType === 'mcp' paths. The brv-query / brv-curate roundtrips run LLM-free (deterministic) so the test is reproducible without provider credentials.

Items outside the inline scope (carried forward)

  • WebUI typecheck still broken per PR description — confirm @campfirein/byterover-packages bump before merge or husky pre-push blocks.
  • build:ui not re-verified — gated on the same package bump.
  • x-byterover-session-id header in axios-analytics-http-client.ts:81 — backend contract note still pending.

Verdict

One ship-blocker, unchanged for the seventeenth review in a row. Replace lorem-ipsum disclosure copy in both src/shared/assets/analytics-disclosure.md and src/webui/features/analytics/constants.ts:13-39 with PM/legal-approved text. Resolve the byterover.dev/privacy vs. docs.byterover.dev/privacy URL drift (Finding U) in the same round. Every other finding is recommended-but-not-blocking.

The three new commits represent the most substantive review-response bundle since ENG-2658: Finding E (standing since the 1st review) is fully closed, the anonymous-shipping bug for fresh users is fixed at root, and a security-relevant prototype-pollution guard bypass is closed with a regression test. The disclosure-body lorem ipsum is now the only substantive item between this branch and merge.
· branch: proj/analytics-system-tool-mode

Comment thread src/shared/assets/analytics-disclosure.md Outdated
Comment thread src/server/infra/transport/handlers/analytics-handler.ts
Comment thread src/shared/analytics/events/index.ts
Comment thread src/server/infra/transport/handlers/global-config-handler.ts
Comment thread src/shared/utils/format-analytics-status.ts
Comment thread src/tui/features/settings/components/settings-page.tsx
Comment thread src/server/infra/analytics/analytics-client.ts
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 1, 2026

Claude finished @bao-byterover's task in 1m 37s —— View job


Claude PR Review — Analytics System (M1 → M17) — Eighteenth pass

Overall score: 3 / 5 — Same engineering substance carrying forward (durable JSONL queue, careful failure-mode taxonomy, exceptional test coverage). No analytics-substantive changes since the seventeenth review — the only new commit on this branch (5dc2457) is a merge from main that brought in unrelated curate-protocol / html-renderer work. Score held at 3/5 because the single ship-blocker from the prior seventeen reviews — lorem-ipsum disclosure body — is still in the tree.

Todos

  • Identify new commits since the seventeenth review (5dc2457 — merge from main, no analytics surface delta)
  • Re-check analytics-disclosure.md (17× prior ship-blocker — still lorem ipsum across all five sections)
  • Spot-check changes in this push (verified merge content is curate / html-reader / html-renderer, no analytics files)
  • Re-verify prior unresolved findings (no movement on any open finding this pass)
  • Post inline issue + summary

What's new since the seventeenth review

Only 5dc2457 — a merge from main pulling in unrelated work: curate/index.ts (367 lines), curate-session.ts (171 lines), html-reader.ts (40 lines), html-renderer.ts (80 lines), system-prompt.yml, plus the matching test files. Zero touch on any src/server/infra/analytics/, src/shared/analytics/, or src/webui/features/analytics/ file. The analytics surface is exactly where it was at the end of pass 17.

Standing ship-blocker — unresolved, eighteenth flag

src/shared/assets/analytics-disclosure.md (lines 3-34) + src/webui/features/analytics/constants.ts:13-39. Both surfaces still ship literal Lorem ipsum bodies. Verified by reading both files at the head of this PR. The fix is two files, no external dependency, no submodule bump, no agent-team handoff. With the analytics.share rename in place, the descriptor honesty at settings.ts:200, the "Analytics sharing" status label, and the device_id rotation/seeding fixes from 219da05 + b2274ea, the PM/legal round has unusually clean foundations to lean on.

Inline →

Prior findings — status

# File Finding Status
A analytics-disclosure.md + webui/features/analytics/constants.ts:13-39 Lorem-ipsum body ship-blocker, 18th flag
B shared/constants/privacy.ts:6 PRIVACY_POLICY_URL annotated (placeholder until M1.5) user-facing copy resolved; URL inconsistency vs. WebUI remains
C http-analytics-sender.ts:62-69 No-deviceId returns no reason resolved by ENG-2658
D analytics-hook.ts:88 <outside-project>/${basename} leaks filename leaf resolved by ENG-2658
E analytics-handler.ts dispatch switch 48 cases scaling poorly resolved by 219da05
F event-names.ts:32 MCP_SESSION_START tense asymmetry unresolved (non-blocking)
G auth-state-store.ts:175 Sequential pre-change listener wait — undocumented unresolved (non-blocking)
H analytics-client.ts:248 Microscopic pendingTracks.add race unresolved (non-blocking)
I migrate-handler.ts:132-140 Brittle string-prefix match resolved by ENG-2658
J synthetic-tool-result-emit.ts:96-109 TOOL_CALL → TOOL_RESULT ordering unresolved (non-blocking)
K format-analytics-status.ts:114 Import-time side-effect registration unresolved (non-blocking)
L webui/pages/configuration/general.tsx:3 Style nit resolved earlier
M noop-analytics-sender.ts vs. no-op-analytics-sender.ts Twin classes one hyphen apart resolved by ENG-2658
N onboarding.md:290-292 Verbal-script scope gap unresolved (bundled into disclosure-body fix round)
O onboarding.md:299-303 "Two promises" framing vs. code reality further mitigated by 1014a9f "Analytics sharing" label
P onboarding.md:305-308 No persisted declined state unresolved (non-blocking)
Q batch.ts:24 schema_version: 1 despite breaking wire change resolved earlier
R stored-record.ts:99-109 toWireEvent derivation caveats resolved earlier
S auth-handler.ts:453 Rotate-after-emit TOCTOU unresolved (non-blocking)
T i-global-config-rotator.ts:19 Non-idempotent contract undocumented unresolved (non-blocking)
14-2 analytics-client.ts:388-390 Rate-limited records bump retry-cap unresolved (non-blocking)
U webui/features/analytics/constants.ts:11 docs.byterover.dev URL drift unresolved (non-blocking)
V webui/features/analytics/constants.ts:13-39 Disclosure body duplicated folded into Finding A
16-NEW settings-keys.ts:13 Value-only rename leaves identifier/value drift unresolved (non-blocking)

Items outside the inline scope (carried forward)

  • WebUI typecheck still broken per PR description — confirm @campfirein/byterover-packages bump before merge or husky pre-push blocks.
  • build:ui not re-verified — gated on the same package bump.
  • x-byterover-session-id header in axios-analytics-http-client.ts:81 — backend contract note still pending.

Verdict

One ship-blocker, eighteenth review. Replace lorem-ipsum disclosure copy in both src/shared/assets/analytics-disclosure.md and src/webui/features/analytics/constants.ts:13-39 with PM/legal-approved text. Resolve the byterover.dev/privacy vs. docs.byterover.dev/privacy URL drift (Finding U) in the same round. Every other open finding is recommended-but-not-blocking — Findings C, D, E, I, M, Q, R have all been closed across prior passes, and Finding O is meaningfully mitigated by the ENG-3035 rename + "Analytics sharing" label.

The engineering substance of this branch is excellent. The merge readiness is one PM/legal copy round away.
· branch: proj/analytics-system-tool-mode

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Event names and
super properties (`device_id`, `cli_version`, `os`, `node_version`,
`environment`) are recorded. No content of your queries, files, or
memory is collected.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (ship-blocking — eighteenth flag): Unchanged for the eighteenth review in a row. Every section body still opens with literal Lorem ipsum. The only commit since pass 17 (5dc2457) is a merge from main that brought in unrelated curate-protocol / html-renderer work — no new analytics surface changes, no movement on the disclosure body.

Four formal consent surfaces (oclif text-mode brv settings set analytics.share true, oclif text+--yes, TUI confirm-disclosure overlay, agent-driven --yes from the onboarding skill at onboarding.md:296) plus the WebUI Privacy panel default-render this lorem ipsum text (src/webui/features/analytics/constants.ts:13-39).

This remains the only substantive ship-blocker on the PR. The fix is two files — no external dependency, no submodule bump, no agent-team handoff. With the analytics.share rename now in place, the descriptor honesty at settings.ts:200 ("Local tracking is always on"), the "Analytics sharing" status label, and the device_id rotation/seeding fixes from 219da05 + b2274ea, the PM/legal round has unusually clean foundations to lean on.

Fix this →

…eal text; drop brittle content-assertion test.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 1, 2026

Claude finished @leehpham's task in 3m 41s —— View job


Claude PR Review — Analytics System (M1 → M17) — Nineteenth pass

Overall score: 4 / 5 — The headline ship-blocker from the prior eighteen reviews is finally closed in the canonical disclosure markdown (125b5c4). PM/legal-approved copy replaces every lorem-ipsum body, the trust-separation framing carries through cleanly ("the CLI keeps a local record of activity regardless of this setting; enabling analytics only controls whether anonymous events are shared"), and the disable command form points at the post-rename analytics.share key. Score moves 3 → 4 because:

  • Two of three consumer paths (oclif collectConsent, TUI confirm-disclosure) now render real disclosure copy via loadAnalyticsDisclosureText().
  • The third path — WebUI Privacy panel — still ships lorem ipsum (src/webui/features/analytics/constants.ts:13-39) and default-renders it (detailsOpen = true from 5d96619) on every Privacy tab visit. This is a real user-visible bug today; the canonical markdown round was a half-fix.
  • Privacy URL drift (Finding U from pass 13) unresolved: canonical disclosure now points at byterover.dev/privacy; WebUI still points at docs.byterover.dev/privacy.
  • Content-assertion test deleted with the rationale "drop brittle content-assertion test" — the 36-line contract test verified five section headers + privacy URL + disable command form. Was minimal, not brittle; removing it leaves both disclosure surfaces unguarded against future copy drift.

Todos

  • Identify new commits since the eighteenth review (125b5c4 — disclosure copy + test deletion)
  • Verify the ship-blocker: canonical markdown is real copy (✅); WebUI constants are still lorem ipsum (❌)
  • Spot-check 125b5c4 — markdown copy is strong; deleted test is a meaningful guard
  • Audit consumer paths: oclif + TUI load canonical (✅); WebUI loads its own lorem-ipsum constants (❌)
  • Cross-check verbal script — still has the gaps from pass 8/16
  • Re-verify prior unresolved findings (Finding A half-closed; Finding U still open)
  • Post inline issues + summary

What's new since the eighteenth review (1 substantive commit)

  • 125b5c4 (ENG-2618) — Replace placeholder analytics disclosure copy with real text; drop content-assertion test.
    • src/shared/assets/analytics-disclosure.md: every section body replaced with real PM/legal-approved copy. Preserves all five required headers (What is collected, Which surfaces are tracked, Where it goes, Cross-device alias, How to disable) plus the Privacy policy link. Lead paragraph carries the trust-separation framing the descriptor + key-name renames have been building toward.
    • test/unit/shared/assets/analytics-disclosure-content.test.ts: deleted (36 lines, 6 regex assertions + 1 URL include + 1 negation).
    • src/webui/features/analytics/constants.ts:13-39: not touched — still lorem ipsum.

New findings on this pass

  1. src/webui/features/analytics/constants.ts:13-39 — Finding A WebUI half still open, ship-pending. The canonical markdown shipped real copy in 125b5c4, but this parallel source of truth — rendered by DisclosureDetails inside the default-open AnalyticsPanel Privacy tab — still ships Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore. for every one of its five section bodies. A user landing on the WebUI Privacy tab today sees lorem-ipsum boilerplate by default; the toggle ships, the copy doesn't.

  2. Canonical markdown enumeration gaps vs. ALL_EVENT_SCHEMAS (non-blocking, additive copy round):

    • space_id / team_id (ENG-3020, pass 10) — backend-resolvable identifiers stamped on curate_run_completed and query_completed. Not mentioned in "What is collected".
    • device_id rotation on logout / account-switch / refresh-failure sign-out (ENG-3019, pass 12) — a privacy-positive feature that softens the "permanently linked" claim. Not mentioned in "Cross-device alias".
  3. Privacy URL drift (Finding U pass 13, still open). Three URLs in the codebase that should agree on one canonical target:

    • shared/constants/privacy.ts:6byterover.dev/privacy
    • analytics-disclosure.md:56 (new copy) → byterover.dev/privacy
    • webui/features/analytics/constants.ts:11 + analytics-panel.tsx:111docs.byterover.dev/privacy

    Plus: shared/constants/privacy.ts:1-5 docblock still annotates the URL as Placeholder until M1.5 — contradictory with the now-shipped canonical disclosure that treats it as real.

  4. Deleted contract test — minimal, not brittle. The 36-line test pinned five section headers + privacy URL + disable command form. That's the contract PM/legal sign off on at landing; removing it leaves future copy edits unguarded. Recommend restoring (cheap) or upgrading to a cross-source consistency test (would have caught the WebUI lorem-ipsum gap).

  5. Verbal-script gaps (pass 8/16 findings N) — now more visible. With the canonical disclosure shipping real copy, the verbal script in onboarding.md:290-296 is now materially narrower than the markdown it precedes:

    • "Things like which commands ran and how long they took" still undersells super-properties (no device_id, os, space_id, team_id).
    • "Cross-device alias" still unmentioned (the new markdown's "permanently linked to your ByteRover account" is a real one-way commitment).
    • "You can change your mind anytime" / "reverses it anytime" still overpromises against the markdown's permanence claim.

Prior findings — status

# Finding Status
A Lorem-ipsum disclosure body PARTIALLY RESOLVED — canonical markdown done (125b5c4), WebUI half still open
B PRIVACY_POLICY_URL placeholder annotation docblock at privacy.ts:1-5 still says "Placeholder until M1.5" — now contradictory with shipped disclosure
C-D, I, M, Q, R, E Various correctness / quality fixes resolved in prior passes
F MCP_SESSION_START tense asymmetry unresolved (non-blocking)
G Sequential pre-change listener — undocumented unresolved (non-blocking)
H pendingTracks.add micro-race unresolved (non-blocking)
J Synthetic TOOL_CALL → TOOL_RESULT FIFO assumption unresolved (non-blocking)
K Import-time side-effect registration unresolved (non-blocking)
N Verbal-script scope gap unresolved — gaps more visible now that canonical copy is real
O "Two promises" framing resolved — new disclosure lines 19-22 make trust separation explicit
P No persisted declined state in onboarding ask unresolved (non-blocking)
S Rotate-after-emit TOCTOU on device_id unresolved (non-blocking)
T Non-idempotent rotateDeviceId undocumented unresolved (non-blocking)
U docs.byterover.dev/privacy URL drift unresolved (carried 7 passes now)
V Disclosure body duplicated across markdown + WebUI folded into A — duplication still exists, half is real, half is lorem ipsum
14-2 Rate-limited records bump retry-cap unresolved (non-blocking)
16-NEW analytics.share value/identifier drift unresolved (non-blocking)

What's good in this push

  • Disclosure copy quality. The new markdown is tight, scoped, and honest. The lead paragraph names what is and isn't shared in one sentence; the trust-separation framing carries through every section; the enumeration of super-properties is bulleted rather than buried; the disable instruction uses the post-rename key name.
  • Three-surface agreement on the toggle's semantics. The new disclosure body + the descriptor description at settings.ts:200 ("Local tracking is always on") + the analytics.share key name + the "Analytics sharing" status label all now linguistically consistent. A user reading any one surface first encounters the same mental model.
  • Section headers stable across the rewrite. Although the test was deleted, the headers in the new copy match the deleted test's expectations — which means a restoration of the contract test (recommended in inline) would pass today.

Verdict

Half-closed ship-blocker. The canonical disclosure markdown round is the work this branch has been waiting nineteen reviews for, and it landed well. To close the remaining half before merge: (1) replace the lorem-ipsum bodies in src/webui/features/analytics/constants.ts:13-39 with copy that mirrors the canonical markdown (or single-source via a vite-side markdown loader), (2) align webui/features/analytics/constants.ts:11 + analytics-panel.tsx:111 with the canonical PRIVACY_POLICY_URL constant, (3) drop the (Placeholder until M1.5) annotation in shared/constants/privacy.ts:1-5 since the disclosure now ships against it as canonical, and (4) consider restoring the deleted contract test or upgrading to a cross-source consistency assertion.

The enumeration gaps (space_id/team_id, device_id rotation) and the verbal-script alignment are additive copy rounds — recommended but not blocking. Items C, D, E, I, M, Q, R have all been closed across prior passes; the remaining open findings are all non-blocking.
· branch: proj/analytics-system-tool-mode

Comment on lines +13 to +39
export const ANALYTICS_DISCLOSURE_SECTIONS: readonly AnalyticsDisclosureSection[] = [
{
body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.',
icon: Database,
label: 'WHAT IS COLLECTED',
},
{
body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.',
icon: Eye,
label: 'WHICH SURFACES ARE TRACKED',
},
{
body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.',
icon: Server,
label: 'WHERE IT GOES',
},
{
body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.',
icon: Link2,
label: 'CROSS-DEVICE ALIAS',
},
{
body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.',
icon: PowerOff,
label: 'HOW TO DISABLE',
},
] as const
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (ship-blocking — Finding A, WebUI half still open): 125b5c4 replaced the canonical disclosure markdown (src/shared/assets/analytics-disclosure.md) with real PM/legal-approved copy — good. But this file, which is the other source of disclosure body content, still ships literal Lorem ipsum dolor sit amet... for every one of its five section bodies.

The AnalyticsPanel (analytics-panel.tsx:21) defaults detailsOpen to true (from 5d96619, flagged in the 13th review). So every user landing on the WebUI Privacy tab sees these lorem-ipsum bodies by default — even though oclif + TUI now render the real text via loadAnalyticsDisclosureText(). Two of three consumer paths (oclif collectConsent, TUI confirm-disclosure) get real copy; the third (WebUI default-open Privacy panel) still gets lorem ipsum.

The fix should mirror the canonical markdown's five sections (What is collected / Which surfaces are tracked / Where it goes / Cross-device alias / How to disable) with the same content. Better still: a vite-side markdown loader that single-sources src/shared/assets/analytics-disclosure.md and renders it into the AnalyticsDisclosureSection[] shape — closes the drift hazard architecturally so the next PM/legal copy edit (and there will be one — see the enumeration gap below) touches one file, not two.

When updating, also cover the gaps the canonical markdown still has vs. ALL_EVENT_SCHEMAS:

  • space_id / team_id are now stamped on curate_run_completed and query_completed (ENG-3020, pass 10) but neither the WebUI bodies nor the canonical markdown's "What is collected" section enumerates them.
  • device_id rotation behavior (ENG-3019, pass 12) — rotates on logout, account-switch login, and refresh-failure sign-out — is a privacy-positive feature that softens the "permanently linked" claim in "Cross-device alias" but is currently missing from both surfaces.

Fix this →

- `cli_version` — the installed CLI version
- `os` — your operating system platform
- `node_version` — the Node.js runtime version
- `environment` — whether the build is development or production
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise + content gap (non-blocking but bundle-friendly): Excellent close on the longest-standing finding in this PR's review history. The new copy carries the trust-separation framing all the way through ("the CLI keeps a local record of activity regardless of this setting; enabling analytics only controls whether anonymous events are shared with ByteRover" at lines 20-22), which precisely matches the descriptor honesty at settings.ts:200 and the analytics.share rename — three surfaces now agree linguistically about what the toggle does.

Two specific enumeration gaps worth folding into a follow-up copy round (non-blocking; they're additive):

  1. space_id and team_id. These were added by ENG-3020 (pass 10 review) and now stamp on curate_run_completed and query_completed payloads. Backend-resolvable identifiers materially expand what an authenticated user is sharing vs. the anonymous identifier list above; they deserve enumeration alongside device_id / cli_version / os / node_version / environment.

  2. device_id rotation behavior under "Cross-device alias". ENG-3019 (pass 12) rotates the device_id on three identity transitions: explicit brv logout, account-switch login, and refresh-failure sign-out. That's a privacy-positive feature that softens the "permanently linked" claim at line 39-40 — a future anonymous session after logout does NOT inherit the prior device's history. Naming it both honors the engineering work and gives the user a clearer mental model of the alias semantics.

A one-paragraph addition to "Cross-device alias" along the lines of "When you log out, the device's anonymous identifier is retired and a fresh one is generated for the next session — so prior anonymous activity stays linked to the account you used, and the next anonymous run starts fresh" would close the gap cleanly.

label: string
}

export const ANALYTICS_PRIVACY_URL = 'https://docs.byterover.dev/privacy'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (Finding U from pass 13, still unresolved): Two different canonical privacy URLs now ship:

Source URL
src/shared/constants/privacy.ts:6 https://byterover.dev/privacy
src/shared/assets/analytics-disclosure.md:56 (new copy as of 125b5c4) https://byterover.dev/privacy
This file https://docs.byterover.dev/privacy
analytics-panel.tsx:111 (display text) docs.byterover.dev/privacy

The disclosure-markdown round (great work landing the real copy) explicitly committed to byterover.dev/privacy as the canonical destination. The WebUI Privacy panel still points users to a different host — if docs.byterover.dev/privacy resolves to documentation rather than the canonical legal privacy policy, the consent surfaces are pointing at different pages. Even if both resolve identically today, the divergence is invisible to tests and silently grows the next time either page is renamed.

Cleanest fix: import the canonical constant rather than re-declaring it locally:

Suggested change
export const ANALYTICS_PRIVACY_URL = 'https://docs.byterover.dev/privacy'
import {PRIVACY_POLICY_URL as ANALYTICS_PRIVACY_URL} from '../../../shared/constants/privacy'

(Display string at analytics-panel.tsx:111 needs the same alignment — byterover.dev/privacy, not docs.byterover.dev/privacy.)

Also worth catching while the disclosure file is open: shared/constants/privacy.ts:1-5's docblock still annotates the URL as Placeholder until M1.5. Since the M1 disclosure copy has now shipped and points at this URL, the comment is stale — either the URL is canonical (drop the placeholder note) or it's still pending (block on the doc-page landing). Today's text is contradictory between the new disclosure markdown (treats it as canonical) and the constants file (treats it as placeholder).

@@ -0,0 +1,56 @@
# ByteRover CLI Analytics Disclosure
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (test coverage, non-blocking): 125b5c4 deleted test/unit/shared/assets/analytics-disclosure-content.test.ts with the commit-message rationale "drop brittle content-assertion test." Reading the deleted test (only 36 lines), it was actually quite minimal — six regex matches against section headers, one include check for PRIVACY_POLICY_URL, and one negation that the deleted brv analytics disable command form never reappears.

I don't think it was brittle in the high-maintenance sense — it's a contract test that says "the disclosure has these five sections plus the privacy link." That contract is what PM/legal sign off on at landing time; if a future copy edit accidentally drops "Cross-device alias" or rewords the disable command to a deprecated form, this test would have caught it loudly.

With both this test removed AND the WebUI lorem-ipsum still present (separate finding), the disclosure copy now lives in two unguarded sources of truth. The next copy edit could silently drift them apart without any test failure. Two cheap paths back:

  1. Restore the section-header contract — keep just the regex assertions for section names + PRIVACY_POLICY_URL + the disable command form. The test is 30 lines; the maintenance burden is "update if PM intentionally renames a section."

  2. Cross-source consistency test — assert that every section label in ANALYTICS_DISCLOSURE_SECTIONS (WebUI) matches a section header in analytics-disclosure.md. Would have prevented the lorem-ipsum-in-WebUI gap from shipping unnoticed.

Either gives back the audit guarantee the deleted test provided. Non-blocking but worth weighing — content-assertion tests on legal-premise copy are usually exactly the kind of low-churn / high-value test that pays compound interest.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants