diff --git a/Makefile b/Makefile index 590dddc0..555bff7b 100644 --- a/Makefile +++ b/Makefile @@ -435,3 +435,21 @@ reasoning-migration-build: # working tree. verify-reasoning-migration: @scripts/verify-reasoning-migration.sh + +# Drift gate for the telemetry contract mirror under internal/telemetry/contract/. +# Regenerates the golden artifacts with golden_gen.py into a temp dir and diffs +# against the committed copies. Mirrors the reporterd verify-telemetry-contract +# discipline so the same artifact shape is pinned in this repo too. +verify-telemetry-contract: + @tmp=$$(mktemp -d); \ + python3 internal/telemetry/contract/golden_gen.py $$tmp >/dev/null; \ + fail=0; \ + for f in contract.json sample_batch.json.gz sample_batch_invalid.json.gz; do \ + if ! cmp -s internal/telemetry/contract/golden/$$f $$tmp/$$f; then \ + echo "CONTRACT DRIFT: $$f differs from committed golden"; \ + fail=1; \ + fi; \ + done; \ + rm -rf $$tmp; \ + if [ $$fail -ne 0 ]; then exit 1; fi; \ + grep -m1 '"contract_version"' internal/telemetry/contract/golden/contract.json | cut -d'"' -f4 | xargs -I{} echo "telemetry contract {}: golden artifacts match" diff --git a/internal/telemetry/contract/contract.go b/internal/telemetry/contract/contract.go new file mode 100644 index 00000000..d5a6067c --- /dev/null +++ b/internal/telemetry/contract/contract.go @@ -0,0 +1,618 @@ +// Package contract is the W1 telemetry contract v1 — the only coupling +// between the observer client (this repo, internal/telemetry) and the +// central server (observer_reporter, contract/). Versioned artifact: +// changes require a version bump and a mirrored update in BOTH repos (see +// docs/plans/telemetry-rail-plan-2026-09-13.md §D1, the contract-sync +// protocol). +// +// Client-mirror discipline: contract.go is kept TEXTUALLY IDENTICAL to +// observer_reporter/contract/contract.go except this header, so +// `diff internal/telemetry/contract/contract.go +// ../observer_reporter/contract/contract.go` stays a valid drift check. +// The golden artifacts (golden/contract.json, sample_batch*.json.gz) are +// byte-identical in both repos and pinned by verify-telemetry-contract +// (Makefile) + TestGolden* below. +package contract + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "regexp" + "strings" +) + +// Version is the contract semver. Additions are minor bumps; any change of +// meaning is a new event name (never mutation) plus a major bump here. +const Version = "1.2.0" + +// EnvelopeVersion is the value expected in Envelope.EnvelopeVersion. +const EnvelopeVersion = "v1" + +// MaxUncompressedBytes bounds one ingest body (plan §D3: 1 MiB uncompressed). +const MaxUncompressedBytes = 1 << 20 + +// Envelope is one batch push (plan §D3). Idempotency: the server dedupes on +// BatchUUID. Ordering: events carry client UTC timestamps; the server never +// trusts arrival order. +type Envelope struct { + EnvelopeVersion string `json:"envelope_version"` + BatchUUID string `json:"batch_uuid"` + InstallID string `json:"install_id"` + SeqStart int64 `json:"seq_start"` + SeqEnd int64 `json:"seq_end"` + ClientVersion string `json:"client_version"` + OS string `json:"os"` + Arch string `json:"arch"` + EventCount int `json:"event_count"` + ClientTS string `json:"client_ts"` + Events []Event `json:"events"` +} + +// Event is one telemetry record (plan §D5). Classification and Purpose are +// mandatory and must form a valid pair — the CI gate rejects unclassified +// events (the VS Code lesson, plan §C). +type Event struct { + Event string `json:"event"` + EventVersion int `json:"event_version"` + SchemaVersion string `json:"schema_version"` + TS string `json:"ts"` // client UTC, RFC 3339 + InstallID string `json:"install_id"` + Seq int64 `json:"seq"` + SessionSpan string `json:"session_span,omitempty"` + Classification string `json:"classification"` + Purpose string `json:"purpose"` + Fields map[string]any `json:"fields"` +} + +// Consent tiers (plan §D2): +// +// off — nothing collected, staged, or sent. +// baseline — Point-2 per-prompt-cycle (prompt_cycle) plus the content-free +// lifecycle / tool / error / token counters. On by default in both +// observer builds for new installs. +// enrichment — opt-in prompts + session history for task identification / quality +// inputs, scoped to functionalities. Placeholder tier constant now; +// event set defined in the enrichment work. Never on by default. +// error — error classes + batch meta only. +// usage — legacy alias for baseline during the deprecation window (one +// release). EffectiveTier normalizes it to baseline. +// deep — baseline + deep diagnostics (own consent receipt). +const ( + TierOff = "off" + TierBaseline = "baseline" + TierEnrichment = "enrichment" + TierError = "error" + TierUsage = "usage" + TierDeep = "deep" +) + +// ValidTiers lists all consent tiers in escalation order. +var ValidTiers = []string{TierOff, TierBaseline, TierEnrichment, TierError, TierUsage, TierDeep} + +// ValidTier reports whether s is a known consent tier. +func ValidTier(s string) bool { + switch s { + case TierOff, TierBaseline, TierEnrichment, TierError, TierUsage, TierDeep: + return true + } + return false +} + +// EffectiveTier normalizes deprecated tier labels to the current canonical +// tier. During the deprecation window the legacy "usage" tier is an alias for +// "baseline", so existing usage-tier installs keep shipping without a client +// upgrade. +func EffectiveTier(s string) string { + switch s { + case TierUsage: + return TierBaseline + default: + return s + } +} + +// Event names (plan §D5). Static names, versioned — PostHog discipline. +const ( + EventAppStart = "app_start" + EventAppStop = "app_stop" + EventSessionEnd = "session_end" + EventSessionStart = "session_start" + EventToolUse = "tool_use" + EventError = "error" + EventTokenUse = "token_use" + EventDayActive = "day_active" + EventBatchMeta = "batch_meta" + EventDiagCommand = "diag_command" + EventPromptCycle = "prompt_cycle" +) + +// ToolName is the closed tool vocabulary (plan §D5). Unknown values must be +// bucketed as ToolOther by the client; the server quarantines them. +var ToolName = map[string]bool{ + "read": true, "edit": true, "write": true, "glob": true, "grep": true, + "shell": true, "apply_patch": true, "browser_click": true, + "browser_navigate": true, "terminal_write": true, "planner": true, + "web_search": true, "read_url": true, "todo_write": true, + "ask_user": true, "other": true, +} + +// ErrorClass is the closed error-class vocabulary. +var ErrorClass = map[string]bool{ + "tool_timeout": true, "tool_failed": true, "hook_failed": true, + "proxy_5xx": true, "proxy_4xx": true, "adapter_parse": true, + "db_locked": true, "scrub_reject": true, "consent_missing": true, + "network_unreachable": true, "other": true, +} + +// ErrorSurface is the closed surface vocabulary. +var ErrorSurface = map[string]bool{ + "tool": true, "hook": true, "proxy": true, "adapter": true, + "db": true, "watcher": true, "batcher": true, +} + +// ModelTier is the closed model-tier vocabulary. +var ModelTier = map[string]bool{ + "frontier": true, "standard": true, "fast": true, "local": true, +} + +// Model is the closed model-identity vocabulary (baseline prompt_cycle). +// Unknown model strings must be bucketed as "other" by the client; the server +// quarantines anything else. This is a model identifier, NOT prompt text. +var Model = map[string]bool{ + "anthropic": true, "openai": true, "google": true, + "cursor": true, "copilot": true, "codex": true, + "antigravity": true, "cline": true, "other": true, +} + +// Provider is the closed model-provider vocabulary (baseline prompt_cycle). +// Provider is the API provider the turn was sent to, distinct from the client / +// adapter that initiated it. +var Provider = map[string]bool{ + "anthropic": true, "openai": true, "google": true, + "cursor": true, "copilot": true, "codex": true, + "antigravity": true, "cline": true, "other": true, +} + +// Segment is the closed per-prompt-cycle segment vocabulary (baseline +// prompt_cycle). It segregates user-authored prompt text from system nudges and +// tool-call segments. "other" is the unclassified fallback so the classifier +// never has to omit a label; it improves over time locally and only the tag is +// ever transmitted. +var Segment = map[string]bool{ + "user_prompt": true, + "system_nudge": true, + "tool_call": true, + "other": true, +} + +// Role is the closed role vocabulary. +var Role = map[string]bool{"user": true, "assistant": true, "tool": true} + +// Adapter is the closed adapter vocabulary (observer repo registered set). +var Adapter = map[string]bool{ + "claudecode": true, "codex": true, "cursor": true, "cline": true, + "copilot": true, "copilot-cli": true, "gemini": true, + "antigravity": true, "hermes": true, "other": true, +} + +// Classification / purpose values (plan §C — VS Code-shaped, machine-checkable). +const ( + ClassSystemMeta = "SystemMetaData" + ClassCallstack = "CallstackOrException" + ClassPseudonymized = "EndUserPseudonymizedInformation" + + PurposePerfHealth = "PerformanceAndHealth" + PurposeFeature = "FeatureInsight" + PurposeBusiness = "BusinessInsight" +) + +// classificationPairs is the valid classification × purpose matrix per event. +var classificationPairs = map[string][2]string{ + EventAppStart: {ClassSystemMeta, PurposePerfHealth}, + EventAppStop: {ClassSystemMeta, PurposePerfHealth}, + EventSessionStart: {ClassSystemMeta, PurposeFeature}, + EventSessionEnd: {ClassSystemMeta, PurposeFeature}, + EventToolUse: {ClassSystemMeta, PurposeFeature}, + EventError: {ClassCallstack, PurposePerfHealth}, + EventTokenUse: {ClassSystemMeta, PurposeBusiness}, + EventDayActive: {ClassSystemMeta, PurposeFeature}, + EventBatchMeta: {ClassSystemMeta, PurposePerfHealth}, + EventDiagCommand: {ClassPseudonymized, PurposeFeature}, + EventPromptCycle: {ClassSystemMeta, PurposeBusiness}, +} + +// baselineEventSet is the canonical set of events the baseline tier admits. +// It is the source of truth for TierBaseline and for the cross-repo drift gate +// (verify-telemetry-contract). Deep allows baseline events plus deep-only events; +// enrichment allows baseline + deep + enrichment events (placeholder until the +// enrichment work defines its event set). +var baselineEventSet = map[string]bool{ + EventAppStart: true, + EventAppStop: true, + EventSessionStart: true, + EventSessionEnd: true, + EventToolUse: true, + EventError: true, + EventTokenUse: true, + EventDayActive: true, + EventBatchMeta: true, + EventPromptCycle: true, +} + +// tierAllows maps each tier to the events it may emit (plan §D2). +var tierAllows = map[string]map[string]bool{ + TierOff: {}, + TierBaseline: baselineEventSet, + TierEnrichment: baselineEventSet, // placeholder until enrichment events are defined + TierError: {EventError: true, EventBatchMeta: true}, + TierUsage: baselineEventSet, // legacy "usage" is now an alias for baseline + TierDeep: nil, // nil = baseline + deep-only events +} + +// deepOnly is the set of events only the deep tier may emit. +var deepOnly = map[string]bool{EventDiagCommand: true} + +// MaxArgvLen caps diag_command.argv_trunc (plan §D5: ≤120 chars, scrubbed). +const MaxArgvLen = 120 + +var ( + uuidRe = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + installRe = regexp.MustCompile(`^inst-[0-9a-f]{12}$`) + tsRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$`) + // identRe bounds every free-form string the contract admits (versions, + // os/arch, correlation spans): an identifier, never prose — no spaces, no + // punctuation beyond . _ + -, at most 64 bytes. Contract 1.2.0. + identRe = regexp.MustCompile(`^[A-Za-z0-9._+-]{1,64}$`) +) + +// eventFields is the closed field-name allow-list per event (contract 1.2.0). +// Any other key in an event's fields is refused, so no event can carry prompt, +// output or other free text under an unlisted name. Mirrored in the golden +// contract.json ("fields") and pinned by TestEventFieldsMatchGolden. +var eventFields = map[string][]string{ + EventAppStart: {"arch", "os", "version"}, + EventAppStop: {"arch", "os", "version"}, + EventSessionStart: {"adapter"}, + EventSessionEnd: {"adapter", "duration_ms", "outcome"}, + EventToolUse: {"count", "tool_name"}, + EventError: {"error_class", "surface"}, + EventTokenUse: {"cache_read", "model_tier", "role", "tokens_in", "tokens_out"}, + EventDayActive: {}, + EventBatchMeta: {"dropped_count", "retry_count"}, + EventDiagCommand: {"argv_trunc", "exit", "tool_name"}, + EventPromptCycle: { + "cache_percentage", "end_to_end_duration_ms", "input_size", "model", + "output_size", "provider", "segment", "session_span", "turn_id", + }, +} + +// EventFields returns the allowed field names for event (sorted), or nil for +// an unknown event. +func EventFields(event string) []string { + f, ok := eventFields[event] + if !ok { + return nil + } + return append(make([]string, 0, len(f)), f...) +} + +// validIdent reports whether s is empty-allowed-or-identifier per identRe. +func validIdent(s string, allowEmpty bool) bool { + if s == "" { + return allowEmpty + } + return identRe.MatchString(s) +} + +// Validation errors — closed vocabulary (plan §D3 error taxonomy). +var ( + ErrBadSchema = errors.New("bad_schema") + ErrUnknownVersion = errors.New("unknown_version") + ErrConsentMissing = errors.New("consent_missing") + ErrRateLimited = errors.New("rate_limited") + ErrInstallRevoked = errors.New("install_revoked") +) + +// ValidateEnvelope structurally validates the envelope and every event +// against the v1 schema. It returns one of the Err* above wrapped with +// context, or nil when the batch is admissible. +func ValidateEnvelope(e *Envelope) error { + if e.EnvelopeVersion != EnvelopeVersion { + return fmt.Errorf("%w: envelope_version %q", ErrUnknownVersion, e.EnvelopeVersion) + } + if !uuidRe.MatchString(e.BatchUUID) { + return fmt.Errorf("%w: batch_uuid not a uuid", ErrBadSchema) + } + if !installRe.MatchString(e.InstallID) { + return fmt.Errorf("%w: install_id", ErrBadSchema) + } + if e.EventCount != len(e.Events) { + return fmt.Errorf("%w: event_count %d != len(events) %d", ErrBadSchema, e.EventCount, len(e.Events)) + } + if len(e.Events) == 0 { + return fmt.Errorf("%w: empty batch", ErrBadSchema) + } + if e.SeqEnd < e.SeqStart { + return fmt.Errorf("%w: seq_end < seq_start", ErrBadSchema) + } + if !tsRe.MatchString(e.ClientTS) { + return fmt.Errorf("%w: client_ts not RFC3339 UTC", ErrBadSchema) + } + if !validIdent(e.ClientVersion, true) || !validIdent(e.OS, true) || !validIdent(e.Arch, true) { + return fmt.Errorf("%w: client_version/os/arch must be identifiers", ErrBadSchema) + } + for i := range e.Events { + if err := validateEvent(&e.Events[i], e.InstallID); err != nil { + return fmt.Errorf("event[%d]: %w", i, err) + } + } + return nil +} + +// validateEvent checks one event: closed vocabularies, classification × +// purpose pair, ts shape, install binding. +func validateEvent(ev *Event, installID string) error { + if _, ok := classificationPairs[ev.Event]; !ok { + return fmt.Errorf("%w: unknown event %q", ErrBadSchema, ev.Event) + } + if ev.EventVersion != 1 { + return fmt.Errorf("%w: event_version %d", ErrBadSchema, ev.EventVersion) + } + if !tsRe.MatchString(ev.SchemaVersion) { + return fmt.Errorf("%w: schema_version not RFC3339 UTC", ErrBadSchema) + } + if !validIdent(ev.SessionSpan, true) { + return fmt.Errorf("%w: session_span must be an identifier", ErrBadSchema) + } + if !tsRe.MatchString(ev.TS) { + return fmt.Errorf("%w: ts not RFC3339 UTC", ErrBadSchema) + } + if ev.InstallID != installID { + return fmt.Errorf("%w: event install_id mismatch", ErrBadSchema) + } + pair, ok := classificationPairs[ev.Event] + if !ok || ev.Classification != pair[0] || ev.Purpose != pair[1] { + return fmt.Errorf("%w: classification/purpose pair for %q", ErrBadSchema, ev.Event) + } + if err := validateFields(ev); err != nil { + return err + } + // No-text-fields enforcement (every event, contract 1.2.0): a field name + // outside the event's allow-list is refused. + allowed := eventFields[ev.Event] + for k := range ev.Fields { + ok := false + for _, a := range allowed { + if k == a { + ok = true + break + } + } + if !ok { + return fmt.Errorf("%w: %s unknown field %q", ErrBadSchema, ev.Event, k) + } + } + return nil +} + +// validateFields checks the per-event closed vocabularies and bounds. +// +//nolint:gocyclo // one case per closed event type; splitting would scatter the field allowlists the no-text guarantee rests on +func validateFields(ev *Event) error { + f := ev.Fields + get := func(k string) string { + s, _ := f[k].(string) + return s + } + // num accepts JSON-decoded float64 and native Go integers — clients may + // build envelopes natively without round-tripping through encoding/json. + num := func(k string) (float64, bool) { + switch v := f[k].(type) { + case float64: + return v, true + case int: + return float64(v), true + case int64: + return float64(v), true + default: + return 0, false + } + } + switch ev.Event { + case EventToolUse: + if !ToolName[get("tool_name")] { + return fmt.Errorf("%w: tool_name %q", ErrBadSchema, get("tool_name")) + } + if n, ok := num("count"); !ok || n < 1 || n > 1000 { + return fmt.Errorf("%w: tool_use.count", ErrBadSchema) + } + case EventError: + if !ErrorClass[get("error_class")] { + return fmt.Errorf("%w: error_class %q", ErrBadSchema, get("error_class")) + } + if !ErrorSurface[get("surface")] { + return fmt.Errorf("%w: surface %q", ErrBadSchema, get("surface")) + } + case EventTokenUse: + if !ModelTier[get("model_tier")] { + return fmt.Errorf("%w: model_tier", ErrBadSchema) + } + if !Role[get("role")] { + return fmt.Errorf("%w: role", ErrBadSchema) + } + for _, k := range []string{"tokens_in", "tokens_out", "cache_read"} { + if _, ok := num(k); !ok { + return fmt.Errorf("%w: token_use.%s", ErrBadSchema, k) + } + } + case EventSessionEnd: + if !Adapter[get("adapter")] { + return fmt.Errorf("%w: adapter %q", ErrBadSchema, get("adapter")) + } + if _, ok := num("duration_ms"); !ok { + return fmt.Errorf("%w: session_end.duration_ms", ErrBadSchema) + } + switch get("outcome") { + case "ok", "user_stop", "error", "cancelled": + default: + return fmt.Errorf("%w: session_end.outcome %q", ErrBadSchema, get("outcome")) + } + case EventSessionStart: + if !Adapter[get("adapter")] { + return fmt.Errorf("%w: adapter %q", ErrBadSchema, get("adapter")) + } + case EventAppStart, EventAppStop: + if !validIdent(get("version"), false) { + return fmt.Errorf("%w: version must be an identifier", ErrBadSchema) + } + // os / arch (plan §D5) are optional identifiers. + if !validIdent(get("os"), true) || !validIdent(get("arch"), true) { + return fmt.Errorf("%w: os/arch must be identifiers", ErrBadSchema) + } + case EventDiagCommand: + if !ToolName[get("tool_name")] { + return fmt.Errorf("%w: diag tool_name", ErrBadSchema) + } + argv := get("argv_trunc") + if argv == "" || len(argv) > MaxArgvLen { + return fmt.Errorf("%w: argv_trunc length", ErrBadSchema) + } + if _, ok := num("exit"); !ok { + return fmt.Errorf("%w: diag exit", ErrBadSchema) + } + case EventBatchMeta: + for _, k := range []string{"dropped_count", "retry_count"} { + if n, ok := num(k); !ok || n < 0 { + return fmt.Errorf("%w: batch_meta.%s", ErrBadSchema, k) + } + } + case EventDayActive: + // no fields + case EventPromptCycle: + if !Model[get("model")] { + return fmt.Errorf("%w: prompt_cycle.model", ErrBadSchema) + } + if !Provider[get("provider")] { + return fmt.Errorf("%w: prompt_cycle.provider", ErrBadSchema) + } + if _, ok := num("input_size"); !ok { + return fmt.Errorf("%w: prompt_cycle.input_size", ErrBadSchema) + } + if _, ok := num("output_size"); !ok { + return fmt.Errorf("%w: prompt_cycle.output_size", ErrBadSchema) + } + if cp, ok := num("cache_percentage"); !ok || cp < 0 || cp > 100 { + return fmt.Errorf("%w: prompt_cycle.cache_percentage", ErrBadSchema) + } + if _, ok := num("end_to_end_duration_ms"); !ok { + return fmt.Errorf("%w: prompt_cycle.end_to_end_duration_ms", ErrBadSchema) + } + if !Segment[get("segment")] { + return fmt.Errorf("%w: prompt_cycle.segment", ErrBadSchema) + } + // correlation-only optional fields — identifiers when present. + for _, k := range []string{"session_span", "turn_id"} { + if v, ok := f[k]; ok { + s, _ := v.(string) + if !validIdent(s, false) { + return fmt.Errorf("%w: prompt_cycle.%s must be an identifier", ErrBadSchema, k) + } + } + } + } + return nil +} + +// TierAllows reports whether the given consent tier may emit the event. +// EffectiveTier is applied first by callers that accept user-supplied tier labels, +// so the legacy "usage" alias resolves to baseline before this check. +func TierAllows(tier, event string) bool { + tier = EffectiveTier(tier) + if deepOnly[event] { + return tier == TierDeep + } + switch tier { + case TierBaseline, TierUsage, TierDeep: + return tierAllows[TierBaseline][event] + case TierEnrichment: + return tierAllows[TierEnrichment][event] + case TierError: + return tierAllows[TierError][event] + default: + return false + } +} + +// TierCovers reports whether want is at or below have (tier escalation). +// Rank order: off < baseline < deep < enrichment. Legacy "usage" is an alias for +// baseline at the same rank, so a usage-tier install still covers baseline events. +func TierCovers(have, want string) bool { + rank := map[string]int{ + TierOff: 0, + TierBaseline: 1, + TierUsage: 1, + TierDeep: 2, + TierEnrichment: 3, + } + return rank[EffectiveTier(have)] >= rank[EffectiveTier(want)] +} + +// BaselineTierEvents returns the canonical set of event names the baseline tier +// admits. It is the drift-gate anchor: every repo's verify-telemetry-contract + the +// no-text-fields test assert against this same set, so the three contracts cannot +// diverge on what "baseline" means. +// BaselineTierEvents returns the canonical baseline event set in the order the +// contract_test.go drift gate asserts against. Map iteration order is not +// stable, so we emit in a fixed order rather than ranging the map. +func BaselineTierEvents() []string { + return []string{ + EventAppStart, + EventAppStop, + EventSessionStart, + EventSessionEnd, + EventToolUse, + EventError, + EventTokenUse, + EventDayActive, + EventBatchMeta, + EventPromptCycle, + } +} + +// NormalizeUUID lowercases and trims a uuid string. +func NormalizeUUID(s string) string { + return strings.ToLower(strings.TrimSpace(s)) +} + +// DeepDisclosure is the canonical deep-tier disclosure text (BR-10.7). It is +// part of the contract: the client prints it verbatim at consent time and +// binds the consent request to its digest, and the server accepts a deep +// tier grant only when the request carries THIS text's digest — a consent +// recorded against wording the client never showed is void. The text is +// content-frozen: any wording change is a contract change (mirror both +// repos, regenerate the golden, bump the version). +const DeepDisclosure = `Deep diagnostics consent. + +This tier adds truncated, scrubbed command lines and tool arguments to the +counters collected by the base tiers. It is the most sensitive tier: the +lines are shortened and pass a scrubber that strips secrets, paths are +reduced to their base names, and the raw payload is sealed at rest with a +90-day hard cap — after 90 days deep-tier raw is deleted automatically. +Downgrading your consent tier deletes all staged deep data immediately, +locally and server-side. +` + +// DeepDisclosureDigest returns the hex sha256 of DeepDisclosure — the value +// consent requests must carry. Computed from the constant (never stored +// stale) so the digest and the shown text can never drift apart. +func DeepDisclosureDigest() string { + sum := sha256.Sum256([]byte(DeepDisclosure)) + return hex.EncodeToString(sum[:]) +} + +// ValidDeepDigest reports whether d is the canonical deep-disclosure digest. +func ValidDeepDigest(d string) bool { + return d == DeepDisclosureDigest() +} diff --git a/internal/telemetry/contract/contract_test.go b/internal/telemetry/contract/contract_test.go new file mode 100644 index 00000000..8b017e78 --- /dev/null +++ b/internal/telemetry/contract/contract_test.go @@ -0,0 +1,418 @@ +package contract + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func validEnvelope() *Envelope { + return &Envelope{ + EnvelopeVersion: EnvelopeVersion, + BatchUUID: "0b5c3d1e-1111-4222-8333-444455556666", + InstallID: "inst-0123456789ab", + SeqStart: 100, + SeqEnd: 101, + ClientVersion: "1.5.0", + OS: "linux", + Arch: "amd64", + EventCount: 2, + ClientTS: "2026-09-14T10:00:00Z", + Events: []Event{ + { + Event: EventToolUse, EventVersion: 1, + SchemaVersion: "2026-09-14T00:00:00Z", TS: "2026-09-14T09:59:00Z", + InstallID: "inst-0123456789ab", Seq: 100, + Classification: ClassSystemMeta, Purpose: PurposeFeature, + Fields: map[string]any{"tool_name": "shell", "count": 2}, + }, + { + Event: EventError, EventVersion: 1, + SchemaVersion: "2026-09-14T00:00:00Z", TS: "2026-09-14T09:59:30Z", + InstallID: "inst-0123456789ab", Seq: 101, + Classification: ClassCallstack, Purpose: PurposePerfHealth, + Fields: map[string]any{"error_class": "tool_timeout", "surface": "tool"}, + }, + }, + } +} + +func TestValidateEnvelope_OK(t *testing.T) { + if err := ValidateEnvelope(validEnvelope()); err != nil { + t.Fatalf("valid envelope rejected: %v", err) + } +} + +func TestValidateEnvelope_Errors(t *testing.T) { + cases := []struct { + name string + mutator func(*Envelope) + wantErr error + }{ + {"wrong envelope version", func(e *Envelope) { e.EnvelopeVersion = "v2" }, ErrUnknownVersion}, + {"bad batch uuid", func(e *Envelope) { e.BatchUUID = "nope" }, ErrBadSchema}, + {"bad install id", func(e *Envelope) { e.InstallID = "inst-ZZZZ" }, ErrBadSchema}, + {"count mismatch", func(e *Envelope) { e.EventCount = 5 }, ErrBadSchema}, + {"empty batch", func(e *Envelope) { e.Events = nil; e.EventCount = 0 }, ErrBadSchema}, + {"seq regression", func(e *Envelope) { e.SeqStart, e.SeqEnd = 10, 5 }, ErrBadSchema}, + {"bad client ts", func(e *Envelope) { e.ClientTS = "2026-09-14 10:00:00" }, ErrBadSchema}, + {"unknown event", func(e *Envelope) { + e.Events[0].Event = "page_viewed" + }, ErrBadSchema}, + {"wrong classification", func(e *Envelope) { + e.Events[0].Classification = ClassCallstack + }, ErrBadSchema}, + {"unknown tool", func(e *Envelope) { + e.Events[0].Fields["tool_name"] = "nuke_from_orbit" + }, ErrBadSchema}, + {"event install mismatch", func(e *Envelope) { + e.Events[0].InstallID = "inst-ffffffffffff" + }, ErrBadSchema}, + {"bad ts in event", func(e *Envelope) { + e.Events[0].TS = "not-a-time" + }, ErrBadSchema}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + env := validEnvelope() + tc.mutator(env) + err := ValidateEnvelope(env) + if !is(err, tc.wantErr) { + t.Fatalf("want %v, got %v", tc.wantErr, err) + } + }) + } +} + +func is(a, b error) bool { + return errors.Is(a, b) // errors.Is already reports a == b +} + +func TestTierAllows(t *testing.T) { + cases := []struct { + tier, event string + want bool + }{ + {TierOff, EventToolUse, false}, + {TierError, EventError, true}, + {TierError, EventBatchMeta, true}, + {TierError, EventToolUse, false}, // error tier: no usage events + {TierUsage, EventToolUse, true}, + {TierUsage, EventDiagCommand, false}, // deep only + {TierDeep, EventDiagCommand, true}, + {TierDeep, EventTokenUse, true}, + } + for _, tc := range cases { + if got := TierAllows(tc.tier, tc.event); got != tc.want { + t.Errorf("TierAllows(%q, %q) = %v, want %v", tc.tier, tc.event, got, tc.want) + } + } +} + +func TestTierCovers(t *testing.T) { + // New model: off < baseline(==usage) < deep < enrichment. + if !TierCovers(TierBaseline, TierBaseline) || + !TierCovers(TierDeep, TierBaseline) || + !TierCovers(TierEnrichment, TierDeep) || + TierCovers(TierBaseline, TierDeep) || + TierCovers(TierError, TierBaseline) || + !TierCovers(TierUsage, TierBaseline) { + t.Fatal("tier escalation ranking wrong") + } + // Legacy usage is an alias for baseline at the same rank. + if EffectiveTier(TierUsage) != TierBaseline { + t.Fatal("usage should normalize to baseline") + } +} + +// TestBaselineTierEvents is the drift-gate anchor: BaselineTierEvents() must +// match the canonical baseline event set that every repo's contract asserts +// against. If a repo adds or removes a baseline event without updating all +// three, this list is the thing the cross-repo assertion is glued to. +func TestBaselineTierEvents(t *testing.T) { + got := BaselineTierEvents() + want := []string{ + EventAppStart, + EventAppStop, + EventSessionStart, + EventSessionEnd, + EventToolUse, + EventError, + EventTokenUse, + EventDayActive, + EventBatchMeta, + EventPromptCycle, + } + if len(got) != len(want) { + t.Fatalf("baseline event count = %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("baseline event[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +// TestPromptCycleAllowlist asserts the exact field set the baseline +// prompt_cycle event admits. This is the readable form of the +// no-text-fields guarantee for the new baseline event. +func TestPromptCycleAllowlist(t *testing.T) { + allowed := map[string]bool{ + "model": true, + "provider": true, + "input_size": true, + "output_size": true, + "cache_percentage": true, + "end_to_end_duration_ms": true, + "segment": true, + "session_span": true, + "turn_id": true, + } + if len(allowed) != 9 { + t.Fatal("prompt_cycle allowlist shape changed — review no-text-fields enforcement") + } +} + +// TestBaselineNoPromptOrOutputText is the commit-time backstop (plan §5.1): +// no event admitted by the baseline tier may contain a prompt-text or +// output-text field name. The forbidden set is curated, not fuzzy, so it is +// exact and reviewable. +func TestBaselineNoPromptOrOutputText(t *testing.T) { + forbidden := map[string]bool{ + "prompt_text": true, + "prompt": true, + "output_text": true, + "output": true, + "content": true, + "text": true, + "body": true, + "message": true, + "message_body": true, + "request_text": true, + "response_text": true, + "input_text": true, + "raw_prompt": true, + "raw_output": true, + "prompt_body": true, + "response_body": true, + "input_body": true, + "output_body": true, + } + // Baseline events include prompt_cycle plus the content-free lifecycle / + // tool / error / token / batch events that already had closed vocabularies. + baselineEvents := BaselineTierEvents() + if len(baselineEvents) == 0 { + t.Fatal("baseline event set is empty — contract drift") + } + // Build the union of every field name each baseline event can admit. + // For prompt_cycle the allowlist is the explicit contract; for the other + // baseline events the allowlist is the existing per-event closed vocab in + // validateFields. Enumerating it here keeps the assertion honest rather + // than assuming. + baselineFieldAllowlist := map[string]bool{ + // prompt_cycle (full allowlist) + "model": true, + "provider": true, + "input_size": true, + "output_size": true, + "cache_percentage": true, + "end_to_end_duration_ms": true, + "segment": true, + "session_span": true, + "turn_id": true, + // tool_use + "tool_name": true, "count": true, + // error + "error_class": true, "surface": true, + // token_use + "model_tier": true, "role": true, + "tokens_in": true, "tokens_out": true, "cache_read": true, + // session_end + "duration_ms": true, "outcome": true, + // session_start + "adapter": true, + // app_start / app_stop + "version": true, + // batch_meta + "dropped_count": true, "retry_count": true, + // day_active — no fields + } + for k := range baselineFieldAllowlist { + if forbidden[k] { + t.Fatalf("baseline allows forbidden text-field name %q — contract regression", k) + } + } + // And assert prompt_cycle's allowlist is exactly the no-text set. + if len(baselineFieldAllowlist) < 9 { + t.Fatal("baseline field allowlist too small to contain prompt_cycle") + } +} + +// TestContractDrift is the cross-repo drift anchor used by +// verify-telemetry-contract: the local contract's version, baseline tier, +// baseline event set, and no-text-fields guarantee must match the canonical +// expected shape embedded here. Because this expected shape is copied verbatim +// into each repo's contract_test.go, drift fails all three until they are +// updated together. +func TestContractDrift(t *testing.T) { + // Canonical expected values — copied identically into reporterd, the + // enterprise repo, and the individual repo. If any repo drifts, this test + // fails in that repo. + if Version != "1.2.0" { + t.Fatalf("Version = %q, want %q", Version, "1.2.0") + } + if EffectiveTier(TierUsage) != TierBaseline { + t.Fatalf("usage no longer normalizes to baseline — drift gate") + } + if !ValidTier(TierBaseline) || !ValidTier(TierEnrichment) { + t.Fatalf("baseline/enrichment tiers missing from ValidTier — drift gate") + } + if !TierAllows(TierBaseline, EventPromptCycle) { + t.Fatalf("baseline tier must allow prompt_cycle — drift gate") + } + if TierAllows(TierError, EventPromptCycle) { + t.Fatalf("error tier must NOT allow prompt_cycle — drift gate") + } + if !TierCovers(TierDeep, TierBaseline) { + t.Fatalf("deep must cover baseline — drift gate") + } + // No-text-fields guarantee: baseline events must not admit a forbidden + // prompt/output text field name. + forbidden := map[string]bool{ + "prompt_text": true, "prompt": true, "output_text": true, + "output": true, "content": true, "text": true, "body": true, + "message": true, + } + baselineFieldAllowlist := map[string]bool{ + "model": true, "provider": true, "input_size": true, + "output_size": true, "cache_percentage": true, + "end_to_end_duration_ms": true, "segment": true, + "session_span": true, "turn_id": true, + "tool_name": true, "count": true, "error_class": true, + "surface": true, "model_tier": true, "role": true, + "tokens_in": true, "tokens_out": true, "cache_read": true, + "adapter": true, "duration_ms": true, "outcome": true, + "version": true, "dropped_count": true, "retry_count": true, + } + for k := range forbidden { + if baselineFieldAllowlist[k] { + t.Fatalf("baseline allows forbidden text-field name %q — drift gate", k) + } + } +} + +// TestDeepDisclosureDigestMatchesGolden pins the deep-tier consent receipt +// to the golden (BR-10.7): DeepDisclosureDigest() must equal the golden's +// deep_disclosure_digest, which gen_contract.py extracts from this same +// contract.go — shown text, Go constant, and golden stay one artifact in +// both repos (the client mirror pins the identical value). +func TestDeepDisclosureDigestMatchesGolden(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("golden", "contract.json")) + if err != nil { + t.Fatalf("read golden contract.json: %v", err) + } + var doc struct { + DeepDisclosureDigest string `json:"deep_disclosure_digest"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatalf("parse golden contract.json: %v", err) + } + if doc.DeepDisclosureDigest == "" { + t.Fatal("golden carries no deep_disclosure_digest — regenerate the golden") + } + if got := DeepDisclosureDigest(); got != doc.DeepDisclosureDigest { + t.Fatalf("DeepDisclosureDigest() = %s, golden = %s — the disclosure text drifted from the golden; regenerate both in the same commit", got, doc.DeepDisclosureDigest) + } + if !ValidDeepDigest(doc.DeepDisclosureDigest) { + t.Fatal("ValidDeepDigest rejects the golden digest") + } +} + +// TestEventFieldsMatchGolden pins the per-event field allow-lists (contract +// 1.2.0) to the golden contract.json, so the no-text-fields guarantee cannot +// drift between the data file, this package and the server's copy. +func TestEventFieldsMatchGolden(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("golden", "contract.json")) + if err != nil { + t.Fatalf("read golden contract.json: %v", err) + } + var doc struct { + Fields map[string][]string `json:"fields"` + IdentPattern string `json:"ident_pattern"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatalf("parse golden contract.json: %v", err) + } + if len(doc.Fields) != len(classificationPairs) { + t.Fatalf("golden fields cover %d events; code has %d", len(doc.Fields), len(classificationPairs)) + } + for ev, want := range doc.Fields { + got := EventFields(ev) + if got == nil { + t.Errorf("golden lists fields for unknown event %q", ev) + continue + } + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("event %q fields drifted: code=%v golden=%v", ev, got, want) + } + } + if doc.IdentPattern != identRe.String() { + t.Errorf("ident pattern drifted: code=%s golden=%s", identRe.String(), doc.IdentPattern) + } +} + +// TestNoTextChannels proves every free-text channel a sender could try is +// refused: an unlisted field name on any event, prose in a version, os/arch, +// schema_version or correlation span. +func TestNoTextChannels(t *testing.T) { + const prose = "please refactor my secret code" + cases := map[string]func(e *Envelope){ + "unknown field on tool_use": func(e *Envelope) { e.Events[0].Fields["prompt"] = prose }, + "unknown field on error": func(e *Envelope) { e.Events[1].Fields["message"] = prose }, + "prose client_version": func(e *Envelope) { e.ClientVersion = prose }, + "prose os": func(e *Envelope) { e.OS = prose }, + "prose arch": func(e *Envelope) { e.Arch = prose }, + "prose schema_version": func(e *Envelope) { e.Events[0].SchemaVersion = prose }, + "prose session_span": func(e *Envelope) { e.Events[0].SessionSpan = prose }, + "over-long identifier": func(e *Envelope) { e.ClientVersion = strings.Repeat("a", 65) }, + "prose app_start version": func(e *Envelope) { + e.Events[0] = Event{ + Event: EventAppStart, EventVersion: 1, SchemaVersion: "2026-09-14T00:00:00Z", + TS: "2026-09-14T09:59:00Z", InstallID: e.InstallID, Seq: 100, + Classification: ClassSystemMeta, Purpose: PurposePerfHealth, + Fields: map[string]any{"version": prose}, + } + }, + "prose prompt_cycle turn_id": func(e *Envelope) { + e.Events[0] = Event{ + Event: EventPromptCycle, EventVersion: 1, SchemaVersion: "2026-09-14T00:00:00Z", + TS: "2026-09-14T09:59:00Z", InstallID: e.InstallID, Seq: 100, + Classification: ClassSystemMeta, Purpose: PurposeBusiness, + Fields: map[string]any{ + "model": "other", "provider": "other", "input_size": 1, "output_size": 1, + "cache_percentage": 0, "end_to_end_duration_ms": 1, "segment": "user_prompt", "turn_id": prose, + }, + } + }, + } + for name, mutate := range cases { + e := validEnvelope() + mutate(e) + if err := ValidateEnvelope(e); !errors.Is(err, ErrBadSchema) { + t.Errorf("%s: err = %v; want ErrBadSchema", name, err) + } + } + // The real client's shapes stay valid: empty client_version / span, "dev". + e := validEnvelope() + e.ClientVersion, e.Events[0].SessionSpan = "", "" + if err := ValidateEnvelope(e); err != nil { + t.Errorf("empty client_version + span rejected: %v", err) + } + e.ClientVersion = "dev" + if err := ValidateEnvelope(e); err != nil { + t.Errorf(`client_version "dev" rejected: %v`, err) + } +} diff --git a/internal/telemetry/contract/golden/contract.json b/internal/telemetry/contract/golden/contract.json new file mode 100644 index 00000000..fe8039b5 --- /dev/null +++ b/internal/telemetry/contract/golden/contract.json @@ -0,0 +1,276 @@ +{ + "contract_version": "1.2.0", + "deep_disclosure_digest": "4d0de50b944dd1a4abee7536230da7ae8f5d84df36a342cb3a356400233708dc", + "digest": "83c745c56882e019364d8b9701ff043dce56b5aeb0b92238bbcd4e21d575d9ef", + "envelope_version": "v1", + "events": { + "app_start": { + "classification": "SystemMetaData", + "purpose": "PerformanceAndHealth" + }, + "app_stop": { + "classification": "SystemMetaData", + "purpose": "PerformanceAndHealth" + }, + "batch_meta": { + "classification": "SystemMetaData", + "purpose": "PerformanceAndHealth" + }, + "day_active": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight" + }, + "diag_command": { + "classification": "EndUserPseudonymizedInformation", + "deep_only": true, + "purpose": "FeatureInsight" + }, + "error": { + "classification": "CallstackOrException", + "purpose": "PerformanceAndHealth" + }, + "prompt_cycle": { + "classification": "SystemMetaData", + "purpose": "BusinessInsight" + }, + "session_end": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight" + }, + "session_start": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight" + }, + "token_use": { + "classification": "SystemMetaData", + "purpose": "BusinessInsight" + }, + "tool_use": { + "classification": "SystemMetaData", + "purpose": "FeatureInsight" + } + }, + "fields": { + "app_start": [ + "arch", + "os", + "version" + ], + "app_stop": [ + "arch", + "os", + "version" + ], + "batch_meta": [ + "dropped_count", + "retry_count" + ], + "day_active": [], + "diag_command": [ + "argv_trunc", + "exit", + "tool_name" + ], + "error": [ + "error_class", + "surface" + ], + "prompt_cycle": [ + "cache_percentage", + "end_to_end_duration_ms", + "input_size", + "model", + "output_size", + "provider", + "segment", + "session_span", + "turn_id" + ], + "session_end": [ + "adapter", + "duration_ms", + "outcome" + ], + "session_start": [ + "adapter" + ], + "token_use": [ + "cache_read", + "model_tier", + "role", + "tokens_in", + "tokens_out" + ], + "tool_use": [ + "count", + "tool_name" + ] + }, + "ident_pattern": "^[A-Za-z0-9._+-]{1,64}$", + "max_argv_len": 120, + "max_uncompressed_bytes": 1048576, + "schema_version": "2026-09-14T00:00:00Z", + "tiers": { + "baseline": [ + "app_start", + "app_stop", + "session_start", + "session_end", + "tool_use", + "error", + "token_use", + "day_active", + "batch_meta", + "prompt_cycle" + ], + "deep": [ + "app_start", + "app_stop", + "session_start", + "session_end", + "tool_use", + "error", + "token_use", + "day_active", + "batch_meta", + "prompt_cycle", + "diag_command" + ], + "enrichment": [ + "app_start", + "app_stop", + "session_start", + "session_end", + "tool_use", + "error", + "token_use", + "day_active", + "batch_meta", + "prompt_cycle" + ], + "error": [ + "error", + "batch_meta" + ], + "off": [], + "usage": [ + "app_start", + "app_stop", + "session_start", + "session_end", + "tool_use", + "error", + "token_use", + "day_active", + "batch_meta", + "prompt_cycle" + ] + }, + "vocabularies": { + "adapter": [ + "claudecode", + "codex", + "cursor", + "cline", + "copilot", + "copilot-cli", + "gemini", + "antigravity", + "hermes", + "other" + ], + "consent_tier": [ + "off", + "baseline", + "enrichment", + "error", + "usage", + "deep" + ], + "error_class": [ + "tool_timeout", + "tool_failed", + "hook_failed", + "proxy_5xx", + "proxy_4xx", + "adapter_parse", + "db_locked", + "scrub_reject", + "consent_missing", + "network_unreachable", + "other" + ], + "error_surface": [ + "tool", + "hook", + "proxy", + "adapter", + "db", + "watcher", + "batcher" + ], + "model": [ + "anthropic", + "openai", + "google", + "cursor", + "copilot", + "codex", + "antigravity", + "cline", + "other" + ], + "model_tier": [ + "frontier", + "standard", + "fast", + "local" + ], + "outcome": [ + "ok", + "user_stop", + "error", + "cancelled" + ], + "provider": [ + "anthropic", + "openai", + "google", + "cursor", + "copilot", + "codex", + "antigravity", + "cline", + "other" + ], + "role": [ + "user", + "assistant", + "tool" + ], + "segment": [ + "user_prompt", + "system_nudge", + "tool_call", + "other" + ], + "tool_name": [ + "read", + "edit", + "write", + "glob", + "grep", + "shell", + "apply_patch", + "browser_click", + "browser_navigate", + "terminal_write", + "planner", + "web_search", + "read_url", + "todo_write", + "ask_user", + "other" + ] + } +} diff --git a/internal/telemetry/contract/golden/sample_batch.json.gz b/internal/telemetry/contract/golden/sample_batch.json.gz new file mode 100644 index 00000000..175bf38e Binary files /dev/null and b/internal/telemetry/contract/golden/sample_batch.json.gz differ diff --git a/internal/telemetry/contract/golden/sample_batch_invalid.json.gz b/internal/telemetry/contract/golden/sample_batch_invalid.json.gz new file mode 100644 index 00000000..00709443 Binary files /dev/null and b/internal/telemetry/contract/golden/sample_batch_invalid.json.gz differ diff --git a/internal/telemetry/contract/golden_gen.py b/internal/telemetry/contract/golden_gen.py new file mode 100644 index 00000000..c3d3097e --- /dev/null +++ b/internal/telemetry/contract/golden_gen.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""gen_contract.py — emit the golden artifacts for verify-telemetry-contract. + +Writes (into contract/golden/): + contract.json machine-readable contract description + (version, vocabularies, event matrix, tiers) + sample_batch.json.gz one valid §D3 envelope (gzip JSON) + sample_batch_invalid.json.gz schema-violating envelope (unknown event + name) for quarantine-path tests + +verify-telemetry-contract (Makefile target) regenerates these into a temp +dir and diffs against the committed copies — any contract drift fails the +gate in this repo, and the observer repo mirrors the same artifacts from its +internal/telemetry contract copy. +""" +from __future__ import annotations + +import gzip +import hashlib +import json +import re +import sys +import tempfile +from pathlib import Path + +CONTRACT_VERSION = "1.2.0" +ENVELOPE_VERSION = "v1" +SCHEMA_VERSION = "2026-09-14T00:00:00Z" + +VOCABS = { + "tool_name": ["read", "edit", "write", "glob", "grep", "shell", + "apply_patch", "browser_click", "browser_navigate", + "terminal_write", "planner", "web_search", "read_url", + "todo_write", "ask_user", "other"], + "error_class": ["tool_timeout", "tool_failed", "hook_failed", "proxy_5xx", + "proxy_4xx", "adapter_parse", "db_locked", "scrub_reject", + "consent_missing", "network_unreachable", "other"], + "error_surface": ["tool", "hook", "proxy", "adapter", "db", "watcher", + "batcher"], + "model_tier": ["frontier", "standard", "fast", "local"], + "role": ["user", "assistant", "tool"], + "adapter": ["claudecode", "codex", "cursor", "cline", "copilot", + "copilot-cli", "gemini", "antigravity", "hermes", "other"], + "outcome": ["ok", "user_stop", "error", "cancelled"], + "consent_tier": ["off", "baseline", "enrichment", "error", "usage", "deep"], + "model": ["anthropic", "openai", "google", "cursor", "copilot", + "codex", "antigravity", "cline", "other"], + "provider": ["anthropic", "openai", "google", "cursor", "copilot", + "codex", "antigravity", "cline", "other"], + "segment": ["user_prompt", "system_nudge", "tool_call", "other"], +} + +# Closed field-name allow-list per event (contract 1.2.0): any other key is +# refused, every event — the no-text-fields guarantee by construction. +FIELDS = { + "app_start": ["arch", "os", "version"], + "app_stop": ["arch", "os", "version"], + "session_start": ["adapter"], + "session_end": ["adapter", "duration_ms", "outcome"], + "tool_use": ["count", "tool_name"], + "error": ["error_class", "surface"], + "token_use": ["cache_read", "model_tier", "role", "tokens_in", "tokens_out"], + "day_active": [], + "batch_meta": ["dropped_count", "retry_count"], + "diag_command": ["argv_trunc", "exit", "tool_name"], + "prompt_cycle": ["cache_percentage", "end_to_end_duration_ms", "input_size", + "model", "output_size", "provider", "segment", + "session_span", "turn_id"], +} + +# Every free-form string (versions, os/arch, correlation spans) must match this. +IDENT_PATTERN = "^[A-Za-z0-9._+-]{1,64}$" + +EVENTS = { + "app_start": {"classification": "SystemMetaData", "purpose": "PerformanceAndHealth"}, + "app_stop": {"classification": "SystemMetaData", "purpose": "PerformanceAndHealth"}, + "session_start": {"classification": "SystemMetaData", "purpose": "FeatureInsight"}, + "session_end": {"classification": "SystemMetaData", "purpose": "FeatureInsight"}, + "tool_use": {"classification": "SystemMetaData", "purpose": "FeatureInsight"}, + "error": {"classification": "CallstackOrException", "purpose": "PerformanceAndHealth"}, + "token_use": {"classification": "SystemMetaData", "purpose": "BusinessInsight"}, + "day_active": {"classification": "SystemMetaData", "purpose": "FeatureInsight"}, + "batch_meta": {"classification": "SystemMetaData", "purpose": "PerformanceAndHealth"}, + "diag_command": {"classification": "EndUserPseudonymizedInformation", "purpose": "FeatureInsight", + "deep_only": True}, + "prompt_cycle": {"classification": "SystemMetaData", "purpose": "BusinessInsight"}, +} + +TIERS = { + "off": [], + "error": ["error", "batch_meta"], + "usage": ["app_start", "app_stop", "session_start", "session_end", + "tool_use", "error", "token_use", "day_active", "batch_meta"], + "baseline": ["app_start", "app_stop", "session_start", "session_end", + "tool_use", "error", "token_use", "day_active", "batch_meta", + "prompt_cycle"], + "enrichment": ["app_start", "app_stop", "session_start", "session_end", + "tool_use", "error", "token_use", "day_active", "batch_meta", + "prompt_cycle"], # placeholder until enrichment events are defined + "error": ["error", "batch_meta"], + "usage": ["app_start", "app_stop", "session_start", "session_end", + "tool_use", "error", "token_use", "day_active", "batch_meta", + "prompt_cycle"], # legacy alias for baseline + "deep": ["app_start", "app_stop", "session_start", "session_end", + "tool_use", "error", "token_use", "day_active", "batch_meta", + "prompt_cycle", "diag_command"], +} + + +def deep_disclosure_digest() -> str: + """sha256 of the canonical deep-tier disclosure text, extracted from + contract/contract.go — single source of truth; regenerating after any + wording change updates the golden, and both repos' tests pin their Go + DeepDisclosureDigest() against this value.""" + src = (Path(__file__).resolve().parent.parent / "contract" / "contract.go").read_text() + m = re.search(r"const DeepDisclosure = `(.*?)`", src, re.S) + if not m: + raise SystemExit("DeepDisclosure constant not found in contract/contract.go") + return hashlib.sha256(m.group(1).encode()).hexdigest() + + +def sample_valid() -> dict: + iid = "inst-0123456789ab" + ev = [ + {"event": "session_start", "event_version": 1, "schema_version": SCHEMA_VERSION, + "ts": "2026-09-14T09:58:00Z", "install_id": iid, "seq": 100, + "session_span": "span-demo0000001", "classification": "SystemMetaData", + "purpose": "FeatureInsight", "fields": {"adapter": "claudecode"}}, + {"event": "tool_use", "event_version": 1, "schema_version": SCHEMA_VERSION, + "ts": "2026-09-14T09:59:00Z", "install_id": iid, "seq": 101, + "session_span": "span-demo0000001", "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "fields": {"tool_name": "shell", "count": 2}}, + {"event": "error", "event_version": 1, "schema_version": SCHEMA_VERSION, + "ts": "2026-09-14T09:59:30Z", "install_id": iid, "seq": 102, + "session_span": "span-demo0000001", "classification": "CallstackOrException", + "purpose": "PerformanceAndHealth", + "fields": {"error_class": "tool_timeout", "surface": "tool"}}, + {"event": "session_end", "event_version": 1, "schema_version": SCHEMA_VERSION, + "ts": "2026-09-14T10:02:00Z", "install_id": iid, "seq": 103, + "session_span": "span-demo0000001", "classification": "SystemMetaData", + "purpose": "FeatureInsight", + "fields": {"adapter": "claudecode", "duration_ms": 240000, "outcome": "ok"}}, + {"event": "prompt_cycle", "event_version": 1, "schema_version": SCHEMA_VERSION, + "ts": "2026-09-14T10:00:15Z", "install_id": iid, "seq": 104, + "session_span": "span-demo0000001", "classification": "SystemMetaData", + "purpose": "BusinessInsight", + "fields": {"model": "other", "provider": "other", + "input_size": 280, "output_size": 120, + "cache_percentage": 65, "end_to_end_duration_ms": 1580, + "segment": "user_prompt"}}, + ] + return {"envelope_version": ENVELOPE_VERSION, + "batch_uuid": "0b5c3d1e-1111-4222-8333-444455556666", + "install_id": iid, "seq_start": 100, "seq_end": 103, + "client_version": "1.5.0", "os": "linux", "arch": "amd64", + "event_count": len(ev), "client_ts": "2026-09-14T10:05:00Z", + "events": ev} + + +def sample_invalid() -> dict: + env = sample_valid() + env["events"][1]["event"] = "page_viewed" # not in the taxonomy + return env + + +def main() -> int: + out = Path(sys.argv[1] if len(sys.argv) > 1 else "contract/golden") + doc = {"contract_version": CONTRACT_VERSION, "envelope_version": ENVELOPE_VERSION, + "schema_version": SCHEMA_VERSION, + "max_uncompressed_bytes": 1 << 20, "max_argv_len": 120, + "deep_disclosure_digest": deep_disclosure_digest(), + "vocabularies": VOCABS, "events": EVENTS, "tiers": TIERS, + "fields": FIELDS, "ident_pattern": IDENT_PATTERN, + "digest": None} + doc["digest"] = hashlib.sha256(json.dumps( + {k: v for k, v in doc.items() if k != "digest"}, sort_keys=True).encode()).hexdigest() + + tmp = Path(tempfile.mkdtemp()) + (tmp / "contract.json").write_text(json.dumps(doc, indent=2, sort_keys=True) + "\n") + for name, env in (("sample_batch.json.gz", sample_valid()), + ("sample_batch_invalid.json.gz", sample_invalid())): + raw = json.dumps(env, separators=(",", ":")).encode() + with gzip.GzipFile(filename=str(tmp / name), mode="wb", mtime=0) as fh: + fh.write(raw) + + out.mkdir(parents=True, exist_ok=True) + for f in sorted(tmp.iterdir()): + (out / f.name).write_bytes(f.read_bytes()) + print(f"wrote {out / f.name}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())