Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .grok/rules/zavet.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ agent context at session start. Keep it short and non-negotiable.
- Nothing enters `repo_dirs` unless the directory demonstrably belongs to the
repo it is filed under, and `register_repo_dir` stays I/O-free.
See DIRASH-0027.
- Telemetry ships only the closed `TelemetryEvent` enum — never argv, paths,
repo names, git identity, or error text. Plaintext repo refs are hashed
daemon-side with the per-install salt; the analytics id is never derived
from the device key; the flush gate is consent, never `cloud_link`.
Changing what ships changes `TELEMETRY_DISCLOSURE` and `docs/TELEMETRY.md`
in the same commit. See DIRASH-0033.

### Recorded decisions (read the file before changing guarded code; ask /zavet:why)

Expand Down Expand Up @@ -84,6 +90,8 @@ agent context at session start. Keep it short and non-negotiable.
- DIRASH-0030 — Full-content knowledge sync is opted into by its own prompt, never implied by linking (active)
- DIRASH-0031 — One backoff ladder lives in dira_core; callers own their attempt budget (active)
- DIRASH-0032 — A record's first-sight triple is repaired as a unit, from recorded facts (active)
- DIRASH-0033 — Telemetry is opt-out, anonymous by construction, and rides its own unsigned channel (active)
- DIRASH-0034 — Repo-visibility probing sends the plaintext ref only to the forge's own public API, anonymously (active)

### Living specs (.zavet/specs/ — keep current while you work)

Expand All @@ -95,6 +103,7 @@ agent context at session start. Keep it short and non-negotiable.
- harness-sources — Harness sources and hook ingestion (session, high)
- knowledge-sync — Knowledge sync — the consent-gated second channel (session, medium)
- onboarding — Onboarding — dira onboard and the installer handoff (session, high)
- telemetry — Telemetry — anonymous product analytics (session, high)

Capture bar: record non-obvious choices a future reader could not reconstruct — micro-decisions as commit trailers (Why:/Rejected:/Constraint:/Refs:), structural ones via /zavet:decide.
Spec maintenance (do this as part of normal work, no command needed): when implementing or changing a feature, update its covering spec in .zavet/specs/ — or create one from .zavet/.spec-template.md (origin: session) for substantial new features — reference the decisions involved, and add a `Spec: <slug>` trailer to the commit.
Expand Down
3 changes: 3 additions & 0 deletions .zavet/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ a handful of documents. The decisions block below is regenerated by
- **DIRASH-0030** — Full-content knowledge sync is opted into by its own prompt, never implied by linking (active)
- **DIRASH-0031** — One backoff ladder lives in dira_core; callers own their attempt budget (active)
- **DIRASH-0032** — A record's first-sight triple is repaired as a unit, from recorded facts (active)
- **DIRASH-0033** — Telemetry is opt-out, anonymous by construction, and rides its own unsigned channel (active)
- **DIRASH-0034** — Repo-visibility probing sends the plaintext ref only to the forge's own public API, anonymously (active)
<!-- zavet:decisions:end -->

## Specs
Expand All @@ -55,6 +57,7 @@ the block by hand.
- **harness-sources** — Harness sources and hook ingestion (session, high, 2026-08-09)
- **knowledge-sync** — Knowledge sync — the consent-gated second channel (session, medium, 2026-08-11)
- **onboarding** — Onboarding — dira onboard and the installer handoff (session, high, 2026-08-13)
- **telemetry** — Telemetry — anonymous product analytics (session, high, 2026-08-25)
<!-- zavet:specs:end -->

## See also
Expand Down
6 changes: 6 additions & 0 deletions .zavet/RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,9 @@ agent context at session start. Keep it short and non-negotiable.
- Nothing enters `repo_dirs` unless the directory demonstrably belongs to the
repo it is filed under, and `register_repo_dir` stays I/O-free.
See DIRASH-0027.
- Telemetry ships only the closed `TelemetryEvent` enum — never argv, paths,
repo names, git identity, or error text. Plaintext repo refs are hashed
daemon-side with the per-install salt; the analytics id is never derived
from the device key; the flush gate is consent, never `cloud_link`.
Changing what ships changes `TELEMETRY_DISCLOSURE` and `docs/TELEMETRY.md`
in the same commit. See DIRASH-0033.
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
id: DIRASH-0033
title: Telemetry is opt-out, anonymous by construction, and rides its own unsigned channel
status: active
guards:
- cli/core/src/telemetry/
- cli/dira/src/telemetry.rs
- cli/dirad/src/telemetry_sync.rs
checks:
- every wire variant carries exactly its declared keys :: cargo test -p dira-core --lib telemetry::event
- the disclosure names what ships :: cargo test -p dira --bin dira the_telemetry
origin: session
verified: false
---

## Decision

Product analytics is **on by default** (opt-out), disclosed by its own prompt
in onboarding and by a one-time first-run notice, and disabled by any of:
`telemetry.enabled = false`, `DIRA_TELEMETRY_ENABLED=0`, `DO_NOT_TRACK=1`,
`CI`, or a dev build. What ships is the closed `TelemetryEvent` enum and
nothing else: command name (top-level only), duration, success plus a closed
error-kind taxonomy, host class, visibility, and a salted repo hash. Never
argv, paths, repo names, git identity, or error text.

Identity is a **random install ULID plus a random 32-byte salt**, minted
lazily in `meta`, never derived from the Ed25519 device key. The repo hash is
HMAC-SHA256 keyed by that per-install salt, computed **daemon-side**: the
canonical `host/owner/repo` ref crosses only the local control socket, and
plaintext repo identity is never persisted in the queue nor sent to the
network. Batches are **unsigned** and flush to the cloud's `/api/v1/pulse`
gated on `cloud_url` + consent — explicitly never on device linkage.

## Why

**Opt-out with honest disclosure** is the only consent model that yields data
representative enough to steer a pricing strategy, and it stays honest the
same way DIRASH-0030 does: a named disclosure constant shown on every path,
pinned by a wording test to the fields that actually ship, so the promise and
the payload cannot drift apart silently.

**The identity split is the load-bearing part.** Reusing the device key (or
anything derived from it) as an analytics id would let the analytics store
correlate back to the signing identity, and would break the moment a key
rotates. A per-install salt keyed into the repo hash means the same repo
hashes differently on every install: we can count distinct repos per install
and split public from private, but no cross-install correlation of repos is
possible even with our own database in hand — which is what lets the word
"anonymous" in the disclosure be true rather than aspirational.

**Unsigned, and consent-gated rather than link-gated**, because the entire
point is hearing from installs that never linked. Requiring the envelope
would silence exactly the population whose conversion we want to understand,
and telemetry is not trust-critical: the cloud treats it as untrusted input
behind a server-side allowlist regardless of what we sign.

**One final `consent_recorded(enabled=false)`** is allowed through when the
knob is turned off — the opt-out rate is itself the signal that keeps this
feature honest — but every other kill switch (env, DO_NOT_TRACK, CI, dev
build) suppresses even that.

## Rejected

- Authoring the wire types in `/contract` — telemetry is best-effort and
versioned independently (`v: 1`); riding the drift-gated contract would
couple every taxonomy tweak to a contract release and the cloud vendoring
dance.
- A global (unsalted) repo hash — would let public repos be dictionary-
reversed and private repos be correlated across installs; "pseudonymous"
is not what the disclosure says.
- Emitting from the CLI process directly — D-0006's rule generalizes: no
network on the foreground path. The CLI's only telemetry I/O is a
150ms-budgeted local-socket fire-and-forget.
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
id: DIRASH-0034
title: Repo-visibility probing sends the plaintext ref only to the forge's own public API, anonymously
status: active
guards:
- cli/dirad/src/repo_visibility.rs
checks:
- visibility mapping, caching, and probe bounding hold :: cargo test -p dirad --lib repo_visibility
origin: recorded
verified: true
---

## Decision

WP3 resolves a GitHub/GitLab remote's Public/Private visibility with a cache-first,
anonymous `GET` to that forge's own public API (`api.github.com` /
`gitlab.com/api/v4`), carrying the plaintext `owner/repo` path. Bitbucket and
self-hosted remotes get `Unknown` with no request. This is the one place beyond
the local control socket (DIRASH-0033) the plaintext canonical ref travels to.

## Why

DIRASH-0033 guards "plaintext repo identity ... never sent to the network" against
Dira's own cloud ingest — that boundary is unchanged: `/api/v1/pulse` still only
ever receives `repo_hash`, `host_class`, and the resolved visibility string, never
the ref. The forge probe is a different network and a different question: the
forge already hosts the repo and already knows it exists, so asking it "is this
public?" discloses nothing to it that it doesn't already have. The request carries
no auth header, no cookie, and no install/device identifier — only a generic
`dirad/<version>` UA required by GitHub's API — so the forge cannot correlate the
request back to this install even if it wanted to. Visibility is a materially
useful segmentation signal for the pricing-strategy goal DIRASH-0033 already cites.

## Rejected

- Always reporting `Unknown` (never probing) — keeps the DIRASH-0033 boundary
literally untouched, but throws away a real segmentation signal for an exposure
that is, at most, "the forge learns someone anonymous asked about a repo it
already hosts" — not a meaningful privacy cost.
- Routing the probe through Dira's cloud (cloud resolves visibility server-side) —
rejected as WP3 scope creep; would need the cloud to hold forge credentials and
widen the trusted-cloud surface for a lookup the daemon can do statelessly.

## Agent directives

- Never add an `Authorization`/cookie header, or any install/device identifier, to
a request built in `repo_visibility.rs`.
- Never persist the plaintext canonical ref from this module — only cache by the
salted `repo_hash` (`VisibilityCache`), matching DIRASH-0033's key discipline.
- Bitbucket/self-hosted must stay probe-free (`Unknown`, no request) unless a
later decision adds a probe for them explicitly.

## Verification

`cargo test -p dirad --lib repo_visibility` covers the status→visibility mapping,
TTL choice (short for rate-limit/error, long for a confident or never-probed
answer), cache eviction/expiry, in-flight probe bounding, and the
`ingest`-integration "unknown first, real answer once warm" behavior. Whether the
request itself carries no auth/cookie headers is not separately asserted by a
test — a human reviewing `repo_visibility.rs`'s `request_visibility` is the check.
80 changes: 80 additions & 0 deletions .zavet/specs/telemetry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
---
title: Telemetry — anonymous product analytics
version: 1
origin: session
verified: false
confidence: high
date: 2026-08-25
paths:
- cli/core/src/telemetry/
- cli/dira/src/telemetry.rs
- cli/dirad/src/telemetry_sync.rs
- cli/dirad/src/repo_visibility.rs
decisions: [DIRASH-0033, DIRASH-0034, DIRASH-0030, DIRASH-0031, D-0006, D-0011, D-0020]
---

## Overview

Anonymous, opt-out product analytics: which commands run, how long they take,
and coarse facts about the repos they run in, flushed through the daemon to
the Dira cloud's `/api/v1/pulse` proxy and forwarded server-side to PostHog
Cloud EU. The user-facing disclosure is `docs/TELEMETRY.md`; the structural
rules are DIRASH-0033.

## Pipeline

1. **Emit (CLI, `cli/dira/src/telemetry.rs`).** The thin `main()` times the
dispatched command and calls `record_command`, which passes
`TelemetryGate` (knob, `DIRA_TELEMETRY_ENABLED`, `DO_NOT_TRACK`, `CI`,
dev build — any one suppresses), classifies failure into the closed
`ErrorKind`, resolves the cwd's canonical repo ref via `explain_project`,
and fire-and-forgets `Request::IngestTelemetry` over the control socket
under a 150ms total budget. The CLI process never does network I/O for
telemetry (D-0006 generalized). Some paths exit the process directly and
record nothing — listed on `run()`'s doc comment.
2. **Ingest (daemon, `telemetry_sync::ingest`).** Re-checks consent, mints or
loads the install id + salt (`meta`), hashes the canonical ref
(HMAC-SHA256, per-install salt) with visibility from the probe cache
(`Unknown` on a cold cache; the probe fills it for later events), and
appends the finished wire JSON to the `telemetry_events` queue. Plaintext
repo identity is never stored.
3. **Flush (daemon, `telemetry_sync::run`).** knowledge_sync-shaped loop:
5s debounce, jittered 300s backstop, chunks of 200 over `(cursor, until]`,
POST `{cloud_url}/api/v1/pulse` on the shared TLS-pinned client (D-0011),
cursor advances per accepted chunk on its own 2xx (D-0020), backoff via
the shared ladder (DIRASH-0031). Gate is `cloud_url` + consent — never
device linkage. 400 advances past the poison chunk loudly (rows kept);
404 is a quiet endpoint-missing skip; 413/429/5xx/network are transient.
Health lands in `META_TELEMETRY_HEALTH`.
4. **Visibility probe (`repo_visibility.rs`).** Unauthenticated GET to the
provider that already hosts the repo (github.com / gitlab.com only),
200→public, 404→private, else unknown; cached 24h keyed by the salted
hash, short-TTL on rate-limit/error; never blocks ingestion; no tokens.

## Consent surfaces

- Onboarding step (DIRASH-0030 shape): `TELEMETRY_DISCLOSURE` shown on every
path, confirm defaults to on, decline writes `telemetry.enabled = false`,
`--telemetry <on|off>` skips the prompt. A wording test pins the
disclosure to the shipped fields.
- First-run notice: once, stderr, tty-only, marker file in the config dir.
- `dira config set telemetry.enabled on|off`, `DIRA_TELEMETRY_ENABLED`,
`DO_NOT_TRACK`. Consent transitions emit `cli_consent_recorded`; turning
the knob off is the one event allowed through on the disable transition.

## Identity

`telemetry_install_id` (ULID) + `telemetry_salt` (32 random bytes) in `meta`,
independent of the device key. `dira device link` sends the install id in the
claim body (fetched from the daemon, gate-checked, never blocking the link);
the cloud performs the PostHog alias at claim time. `Store::nuke` clears the
queue, cursor, and health keys.

## Invariants worth re-checking after changes

- Changing what ships requires updating `TELEMETRY_DISCLOSURE`,
`docs/TELEMETRY.md`, and the cloud allowlist in the same change set.
- The wire enum's per-variant no-stray-field tests are the drift guard; a new
wire field without a taxonomy decision should fail review.
- The batch is versioned `v: 1`; the cloud 400s unknown majors and the daemon
skips past such batches — bump deliberately.
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ agent context at session start. Keep it short and non-negotiable.
- Nothing enters `repo_dirs` unless the directory demonstrably belongs to the
repo it is filed under, and `register_repo_dir` stays I/O-free.
See DIRASH-0027.
- Telemetry ships only the closed `TelemetryEvent` enum — never argv, paths,
repo names, git identity, or error text. Plaintext repo refs are hashed
daemon-side with the per-install salt; the analytics id is never derived
from the device key; the flush gate is consent, never `cloud_link`.
Changing what ships changes `TELEMETRY_DISCLOSURE` and `docs/TELEMETRY.md`
in the same commit. See DIRASH-0033.

### Recorded decisions (read the file before changing guarded code; ask /zavet:why)

Expand Down Expand Up @@ -91,6 +97,8 @@ agent context at session start. Keep it short and non-negotiable.
- DIRASH-0030 — Full-content knowledge sync is opted into by its own prompt, never implied by linking (active)
- DIRASH-0031 — One backoff ladder lives in dira_core; callers own their attempt budget (active)
- DIRASH-0032 — A record's first-sight triple is repaired as a unit, from recorded facts (active)
- DIRASH-0033 — Telemetry is opt-out, anonymous by construction, and rides its own unsigned channel (active)
- DIRASH-0034 — Repo-visibility probing sends the plaintext ref only to the forge's own public API, anonymously (active)

### Living specs (.zavet/specs/ — keep current while you work)

Expand All @@ -102,6 +110,7 @@ agent context at session start. Keep it short and non-negotiable.
- harness-sources — Harness sources and hook ingestion (session, high)
- knowledge-sync — Knowledge sync — the consent-gated second channel (session, medium)
- onboarding — Onboarding — dira onboard and the installer handoff (session, high)
- telemetry — Telemetry — anonymous product analytics (session, high)

Capture bar: record non-obvious choices a future reader could not reconstruct — micro-decisions as commit trailers (Why:/Rejected:/Constraint:/Refs:), structural ones via /zavet:decide.
Spec maintenance (do this as part of normal work, no command needed): when implementing or changing a feature, update its covering spec in .zavet/specs/ — or create one from .zavet/.spec-template.md (origin: session) for substantial new features — reference the decisions involved, and add a `Spec: <slug>` trailer to the commit.
Expand Down
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ ed25519-dalek = { version = "3", features = ["rand_core"] }
# Note: sha2 0.11 (digest 0.11) dropped the hasher's `io::Write` impl, so
# `verify_sha256` streams the file through `update()` instead of `io::copy`.
sha2 = "0.11"
# Telemetry's repo-hash HMAC-SHA256 (salted, so a canonical remote never round-trips
# without the per-install salt). 0.13 is the line that resolves against `digest 0.11`
# — the same line `sha2 0.11` and `ed25519-dalek 3` already pull in — so this adds no
# second `digest` major to the tree.
hmac = "0.13"
# Hex-encode/decode the sha256 digest `dira update` compares against the
# published `.sha256` asset. Already resolved transitively (sqlx-macros-core);
# promoted to a direct dep rather than added fresh.
Expand Down
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ effective-dated policy.
/dirad resident daemon (tokio): ingress (loopback HTTP + UDS), accounting, store
/dira thin CLI client over the daemon's Unix domain socket
/sources per-harness hook normalization (claude_code, …)
/docs docs/install.md (installer reference), docs/zavet.md (knowledge module)
/docs docs/install.md (installer reference), docs/zavet.md (knowledge module),
docs/TELEMETRY.md (what's collected and how to turn it off)
install.sh curl | sh installer for dira + dirad (see docs/install.md)
mise.toml toolchain pins (rust, just)
justfile task runner
Expand Down Expand Up @@ -141,6 +142,19 @@ DIRA_CLOUD_URL=http://localhost:3000 dira device link --code LOCALDEV1 # per-i
dira config set cloud_url http://localhost:3000 # persistent
```

## Telemetry

Dira collects anonymous product-usage analytics, on by default — never repo names, git
identity, file paths, command arguments, or error text. `dira onboard` asks about it
explicitly, on its own terms. Turn it off anytime:

```sh
dira config set telemetry.enabled false
```

`DIRA_TELEMETRY_ENABLED=0` and `DO_NOT_TRACK=1` also work; dev builds and CI never send.
See [docs/TELEMETRY.md](docs/TELEMETRY.md) for exactly what is (and is never) collected.

## Contract

The wire schema is authored once in Rust (`/contract`) because the daemon is the producer.
Expand Down
Loading