Telemetry Tiers — Green / Yellow / Red
This note formalizes the consent model the cockpit relies on. It builds
on the tier names already in the code (ANALYTICS-EVENT-CATALOG.md)
but extends them from "build-flag bundle gate" to "per-workspace owner
consent".
The model must satisfy two constraints simultaneously:
- CareerVector has no user accounts (CLAUDE.md §1). There is no admin role to grant operator visibility. The workspace owner — defined as anyone with the link, per CLAUDE.md §5 — is the only entity that can extend visibility.
- The operator (Julian today, a small team eventually) must be able to operate the system. Pure end-to-end opacity is incompatible with running it.
The reconciliation: aggregate, anonymous, low-cardinality signal is always available to the operator. Per-workspace richness is gated by explicit consent from the workspace owner, in tiers, with the highest tier time-limited.
Two color systems, do not confuse
Before defining tiers, lock the vocabulary. The cockpit uses green/yellow/red for two distinct concepts that happen to share color language:
| Term | Decided by | Changes when | Surfaces in |
|---|---|---|---|
| Telemetry tier | Workspace owner | Owner toggles in workspace settings | Determines what cockpit can SHOW about this workspace |
| Operational state | The system (observed) | Health probes / queue state / error rate change | The traffic light next to a workspace in the cockpit list |
A workspace can be tier:green and state:red — system is failing,
operator sees only that it is failing (no payload, no error message).
A workspace can be tier:red and state:green — owner has opted in
to debug telemetry, but nothing is wrong. Useful for proactive support
sessions while the user is on a call.
The rest of this document refers to tier when "consent level" is meant, state when "operational health" is meant.
Tier definitions
Green — silent (default)
- Workspace emits no event-level telemetry.
- The browser bundle does not load the telemetry module
(
greenbranch inui/src/lib/telemetry/). - The operator can still see, from D1 alone:
- The workspace exists.
updated_attimestamp (when its sub-docs last changed).- Aggregate counts (
jobs,cv-profile, etc., row counts). - Process-request rows the workspace owns — but only at the schema level
(request_id, status, claim_class, timestamps). NOT the
resultJSON payload, NOT theerror_message, NOT any workspace-specific identifiers beyondworkspace_id.
- The cockpit shows green-tier workspaces as opaque rows: color (operational state), name (slug truncated), last-touched, queue count. Clicking does not deepen the view beyond what was visible in the row.
Green is the default and the right default. Most users never need to know the cockpit exists.
Yellow — support
- Owner has explicitly opted in via workspace settings: "Help us help you when you ask for support."
- Browser bundle loads
yellow.tsand emits the documented yellow event types to Axiom. - D1 begins persisting
result.summaryand sanitizederror_messageon workspace_process_requests (today these may be persisted; the yellow tier formalizes the consent, not the storage). - The cockpit unlocks for this workspace:
- Per-request state, stage, attempt count, providers tried.
- Error categories (
provider_429,provider_5xx,extract_invalid_json) but NOT raw error strings. - Recent activity timeline.
- Process-request
resultJSON with PII fields masked (URL kept, descriptions trimmed to first 64 chars, salary kept, free-text fields hashed).
Yellow does not raise CF quota — events go to Axiom, not back through Workers.
Red — debug
- Owner has explicitly enabled red tier for a bounded time window (default proposal: 1 hour; max 24 hours; banner in the workspace UI visible while active so the owner cannot forget).
- Browser bundle loads
red.tsand emits the full event catalog, including p2p probe payloads and op apply traces. - D1 persists everything yellow does, plus operator-readable raw error strings and full provider request/response excerpts for the duration.
- The cockpit unlocks the full drill-down for this workspace:
- Live op tape (each op as it commits, who emitted it, what changed).
- Provider chain step-by-step: which provider was called, what it returned, why fallback fired.
- PII-bearing fields visible to the operator, because the owner has explicitly consented.
- On expiry, the workspace automatically returns to its previous tier. Persisted red-tier data is retained according to the Axiom retention policy (already configured) but the cockpit re-projects through the yellow/green filter again.
Red is for incident response. It is not a default and it is not "recommended" anywhere in the workspace UI.
Where the tier lives
Recommendation (not locked). Store tier on the workspace settings sub-doc:
// lib/schemas/src/settings.schema.ts (proposed)
export const TelemetryTierSchema = z.enum(['green', 'yellow', 'red']);
export const WorkspaceSettingsSchema = z.object({
// ... existing fields ...
telemetryTier: TelemetryTierSchema.default('green'),
telemetryTierExpiresAt: z.string().datetime({ offset: true }).optional()
});
Storing on settings rather than a separate table:
- Keeps the tier as part of workspace state, replicated through the same Yjs/D1 spine that already enforces "link = identity" — anyone with the link sees and can change the tier.
- Means the SSE fanout the cockpit reads already broadcasts tier
changes (every settings update lands in
workspace_sub_doc_ops). - Avoids a parallel ledger that could drift out of sync with workspace ownership — there is no workspace ownership table to keep aligned.
A separate read model can project the current tier per workspace for
the cockpit's left-rail filter, but the source of truth is on
settings.
How tier changes propagate
- Owner toggles tier in workspace settings UI.
settings.updateop commits as today.- Worker reading
workspace_sub_docon next request picks up the change. For browser sessions, the SSE stream emits thesub-doc:settingsevent sub-second. - The Axiom telemetry bundle re-evaluates which module to load on next workspace init (it is module-loaded lazily, not at app boot).
- Server-side persistence of richer fields keys off the tier read
from
settingson each write.
There is no "tier propagation lag" worth designing for — settings updates already have to land in D1 before they take effect anywhere. The cockpit reads the same value, so what it sees matches what the workspace is actually emitting.
What the operator gets without any opt-in
For green-tier workspaces — i.e. by default, for most workspaces — the operator always sees:
| Signal | Source | Why it is OK to surface |
|---|---|---|
| Workspace exists | workspaces table |
Existence is not private; the link is private. |
| Operational color (green/yellow/red) | Derived from process-request status, lease age, recent error count | All inputs are schema-level fields, not content. |
| Queue depth | workspace_process_requests count by status |
Schema-level. |
| Last-touched timestamp | workspaces.updated_at |
Aggregate of any write activity. |
| Aggregate counts | Row counts on workspace_sub_doc |
Aggregate. |
| Quota-cost contribution | Request count attributable to this workspace, rolled up | Operator must see this; it is the operator's bill. |
What the operator does NOT see for green-tier workspaces:
resultpayloads from process requestserror_messagetext- Free-text inside cells (job descriptions, notes, CVL prose)
- Provider request/response bodies
- Cell origins beyond the existing public projection
(
cell-originsis yellow-tier territory for drill-down)
This is enough to operate the system. A red workspace with no detail visible is enough signal to reach out to the owner ("we see your workspace is having trouble — would you like to enable Yellow tier so we can debug?"). Operator never sees the user's content without consent.
How status projects tiers (it does not)
Status is fully anonymous. No tier information surfaces on
status.careervector.corbet.ch, not even "N workspaces are in red
tier" — that count itself would tell an attacker that some workspace
has opted in to richer logging.
The status traffic light is the public projection of the infra rollup, not workspace rollups:
infra_state = rollupHealthState({
checks: [d1, r2, relay-cf, relay-deno, mcp, typst_service, …]
})
// `realtime` is the derived rollup over per-relay probes (relay-cf, relay-deno, …),
// not a separately-probed binding.
Workspace rollups exist in the cockpit but never leak to status.
Today vs end-state
| Aspect | Today | End-state |
|---|---|---|
| Tier defined per | Build flag (VITE_AXIOM_INGEST_TOKEN present or not) |
Workspace setting |
| Tier default | Green (silent) | Green (silent) |
| Tier opt-in surface | None | Workspace settings UI panel |
| Cockpit drill-down depth | None (no per-workspace cockpit yet) | Tier-gated as defined above |
| Server-side richer persistence | Already persists schema-level fields | Adds yellow/red persistence keyed off tier |
| Status leakage of tier | None (no surface) | Still none |
| Red tier auto-expiry | N/A | Implemented, with workspace banner |
The end-state is reachable in stages without breaking the current build-flag behavior. The build flag remains a development-only override for staging/dev environments where every workspace should behave as red tier — orthogonal to per-workspace consent in production.
Open questions
These should be resolved before this tier model is locked. None are blockers for the cockpit work; all are decisions the operator can make.
- Red-tier max duration. Is 24 hours the right ceiling? Longer helps incident response sessions that span timezones; shorter reduces the window where persisted PII lives in Axiom.
- Does an agent's "request explanation" gesture count as the owner
consenting? When ChatGPT/Claude asks "why is this stuck?" via
MCP, the request comes from an
agentactor on behalf of the workspace. Does that imply temporary yellow tier for that request, or must the owner toggle first? Recommendation: agent-initiated requests get single-request yellow tier (just enough to return the answer) but do not change the workspace tier setting. - Cross-workspace operator queries. Can the operator run "show me all workspaces where commute calculation has failed in the last hour" without consenting workspaces' content surfacing? Yes if the query only returns workspace_id + count; no if it joins to anything content-bearing. Define a small set of approved operator queries in the cockpit.
- What happens to data emitted in red tier after the workspace reverts? Axiom retention applies. Cockpit projection re-applies the tier filter on read, so older red-tier data is not shown for a currently-yellow workspace. This needs a clear test fixture.