#Usage, analytics, accounts, and discovery
On this page
Where it livesHow it worksUsage: two inputs, one archiveProduct analyticsAccounts and sign-inDiscoveryState and ownershipContractsControl methodsPorts and traitsExternal protocolsInvariantsFailure and recoveryExtension pointsA new harness that reports usageA new harness in analyticsA new analytics eventA new discovery providerA new advertised fieldA new credential kindRefactor notesFour daemon-side services sit beside the room and runtime pipelines rather than inside them. Usage and cost turns provider transcripts and live provider events into a grow-only local ledger of tokens and estimated spend, attributed to Jam agents and rooms. Product analytics emits a closed vocabulary of product-outcome events to Amplitude and PostHog, to an encrypted local store, or nowhere, depending on the build and deployment. Accounts owns sign-in, the Human-API credential for each Band profile, token refresh, and the account-scoped room directory. Discovery advertises content-free presence for this machine's agents and merges what other machines advertise, over the LAN and through Band.
None of the four owns runtime processes, room routing, or message delivery. Usage never uploads to Band and never manufactures a zero. Analytics never changes the result of the product operation that produced it. Accounts never exposes a secret to the WebView. Discovery never advertises prompts, task text, paths, process IDs, or credentials, and fails toward "off".
#Where it lives
| Area | Crate and module | Key types and functions |
|---|---|---|
| ccusage facade | crates/jam-usage/src/lib.rs (about 1,300 production lines, tests after) |
UsageSource, UsageSourceConfig::from_env, UsageSource::capture_with_managed_codex_dirs, report, sessions, sessions_for_providers, cached_sessions, active_block, merge_canonical_archive, report_day_label, quote_canonical_usage, CANONICAL_SNAPSHOT_PROVIDERS, MAX_CAPTURE_SESSION_SNAPSHOTS |
| Grow-only merge policy | crates/jam-usage/src/archive.rs |
UsageArchive (storage port), merge_sessions, merge_days, reconcile_session_metadata, preserve_authoritative_billing |
| Canonical usage domain | crates/jam-domain/src/usage.rs (3,804 lines, tests from line 2,888) |
UsageObservation, UsageObservation::live_report_id, cumulative_snapshot, UsageReconciliationRecord, UsageCounterRecord::apply, UsageCounterDisposition, UsageCostProjection, UsageAuthoritativeCost, UsageCaptureFailureRecord, SessionLedgerEntry, UsageArchiveSession, UsageArchiveDay |
| Live ingestion pipeline | crates/jam-manager/src/worker.rs |
project_runtime_usage, runtime_usage_observation, ingest_runtime_usage_observation, reconcile_pending_runtime_usage, reconcile_ccusage_usage_snapshots, ccusage_snapshot_owners, managed_codex_usage_homes |
| Capture loop and Control methods | crates/jam-manager/src/manager.rs |
Manager::usage_capture, capture_usage_once, capture_usage_from_managed_homes, synchronize_canonical_usage_archive, record_usage_capture_failure, StoreUsageArchive, impl Control usage_* methods |
| Canonical-to-archive bridge | crates/jam-manager/src/canonical_usage.rs |
load_archive_projection, synchronize_archive, reprice_accepted_usage |
| Attribution join | crates/jam-manager/src/usage_attr.rs |
attribute, build_index, trailing_uuid, resolve_agent_filter, UNATTRIBUTED |
| Usage persistence | crates/jam-store/src/lib.rs traits UsageObservationRepo (22 methods), UsageArchiveRepo (4), SessionLedgerRepo; sqlite.rs and file.rs backends |
Tables in Store schema: Usage and cost |
| Analytics contract | crates/jam-analytics/src/contract.rs, envelope.rs, error_report.rs |
AnalyticsEvent (16 variants), FrontendAnalyticsEvent, EVENT_NAMES, map_event, event_allowed, CanonicalEvent |
| Analytics transports | crates/jam-analytics/src/transport.rs, amplitude.rs, band_user_identity.rs, policy.rs, identity.rs |
AnalyticsClient, AnalyticsSink, FanoutSink, AmplitudeSink, PostHogSink, NoopSink, BandUserIdentity, AnalyticsPolicyGate, AnalyticsControlFile, InstallationIdentity, POSTHOG_DUAL_DELIVERY_DISABLE_AT_UNIX_SECONDS |
| On-prem analytics | crates/jam-analytics-local/src/lib.rs |
QueuedLocalAnalyticsSink, LocalAnalyticsStore, database_key_name |
| Analytics composition | bins/jam/src/jamd.rs analytics_mode, product_analytics, hosted_sink; apps/desktop/src-tauri/src/analytics.rs AnalyticsState, deployment_analytics_mode, build_client; crates/jam-manager/src/analytics.rs ProductAnalytics, track, agent_runtime_configuration_for |
|
| Hosted sign-in | crates/jam-auth/src/lib.rs (about 2,000 production lines) |
oauth_signin_tokens*, keyless_signin* (mint), run_loopback, run_device, exchange_code, refresh, RefreshError, TokenSet, mint, linear_oauth_tokens, refresh_linear_oauth, revoke_linear_oauth |
| Refresh seam | crates/jam-manager/src/oauth.rs |
TokenRefresher, RefreshedTokens, TokenRefreshError, wait_until_refresh, refresh_retry_window, next_backoff |
| Refresh loop and account lifecycle | crates/jam-manager/src/manager.rs |
start_oauth_session, run_account_refresh, spawn_oauth_refreshers, mark_account_auth_required, mark_account_superseded, cancel_oauth_session, user_transport, ready_user_transport, add_oauth_account, sign_out_account, delete_local_account_data, account_generation_guard, rooms_at_with_guard |
| Concrete refresher | bins/jam/src/jamd.rs |
AuthTokenRefresher (per-issuer token-endpoint cache) |
| Secrets at rest | crates/jam-store/src/secret_store.rs (SecretStore, open_secret_store); crates/jam-secure-store/src/lib.rs (SQLCipher bootstrap, analytics key custody) |
Keys user:<profile>, user_refresh:<profile>, integration:<profile>:<provider>, agent:<profile>/<scope>, db:master |
| Account records | crates/jam-domain/src/account.rs, credential.rs, roommsg.rs |
Account, AccountIdentity, AuthKind, AccountAuthState, Credential, AccountDirectory, AccountRoomSnapshot |
| Linear integration | crates/jam-manager/src/linear.rs |
connect_oauth, usable_credential, credential_lock, disconnect, StoredLinearCredential |
| mDNS provider | crates/jam-discovery/src/lib.rs, tier2.rs |
MdnsDiscovery, SERVICE_TYPE, tier2::serve, PRESENCE_PATH |
| Band provider and aggregate | crates/jam-manager/src/discovery_band.rs (about 2,500 production lines), discovery_agg.rs |
BandDiscovery, published_agents, consume_presence, AggregateDiscovery |
| Discovery state and loop | crates/jam-manager/src/discovery.rs; manager.rs |
DiscoveryState, merged_at, tick, project_activity, team_of, deployment_scope; Manager::spawn_discovery, assemble_presence, set_discoverable_impl, discovery_fail_closed, presence_cost_maps |
| Discovery seam | crates/jam-contract/src/lib.rs |
DiscoveryProvider, DiscoveryEvent, DiscoveryError, NullDiscovery |
| Discovery DTOs | crates/jam-domain/src/discovery.rs |
AccountPresence, DiscoveredAgent, DiscoveredTeam, AgentActivity, TaskCounts, SourcePresence, DISCOVERABLE_SETTING, SHARE_ROOM_TITLES_SETTING, SHARE_COST_SETTING |
| Desktop consumers | apps/desktop/src/lib/usageQueries.ts (USAGE_POLL_MS), components/DashboardPanel.tsx, components/settings/AccountSettings.tsx, src-tauri/src/commands.rs (sign_in, sign_out_account, linear_connect) |
The crate graph matches the intended direction with one exception. jam-usage depends only on jam-domain and the ccusage fork; jam-discovery on jam-contract and jam-domain; jam-analytics on jam-domain and optionally jam-tls. jam-manager depends on jam-usage, jam-analytics, and also jam-auth (see Refactor notes). The desktop depends on jam-auth, jam-analytics, and jam-analytics-local but never on jam-usage, ccusage, jam-discovery, or mdns-sd; just no-daemon fails if it does. The full graph is in crates.md.
#How it works
#Usage: two inputs, one archive
Usage reaches the local archive from two independent inputs. The first is the provider file scan: the rev-pinned thenvoi/ccusage fork reads Claude Code, Codex, Copilot, and other supported transcripts on disk. The second is the live raw-ingestion path: a Jam-owned runtime inside Docker Sandbox reports per-turn token usage as AgentEventKind::Usage, and the manager records it as an immutable, owner-attributed observation. Both inputs end in the same two archive tables, usage_session_archive and usage_daily_archive, which every report reads.
Where usage data comes from, and which durable tables it passes through before a report can show it:
#The provider file scan
jam-usage is an anti-corruption layer over the fork's ccusage::api module. No ccusage type leaves the crate. UsageSource owns an optional UsageArchive port, which the manager implements over jam-store as StoreUsageArchive, plus a set of in-memory response caches with a TTL (DEFAULT_TTL, 120 seconds, overridable with JAM_USAGE_TTL_SECS).
Only one method reads provider files in a manager-built source: capture_with_managed_codex_dirs. It takes refresh_gate so a scheduled capture and a manual jam usage refresh never scan the same logs concurrently, invalidates every cache, and then:
- Runs
cc::all_dailywithoffline: falseunlessJAM_USAGE_OFFLINE=1. This is the only place pricing is fetched from the network (LiteLLM, with the fork's fallback). The daily cells are merged intousage_daily_archiveunderdays_gate. - Runs
cc::all_sessionsonce. If the result exceedsMAX_CAPTURE_SESSION_SNAPSHOTS(10,000 rows) the capture fails before anything is persisted. - Splits the session rows two ways. Rows whose provider is in
CANONICAL_SNAPSHOT_PROVIDERS(currently onlycodex) becomeUsageSessionSnapshotvalues for canonical reconciliation. Rows that match a Jam-managed provider session (the(provider, provider_session_id)pairs frommanaged_ccusage_provider_sessions) are withheld from the legacy session merge, so one managed turn is not archived both under ccusage's rollout-path identity and under its canonical provider-thread identity.managed_provider_session_matchesaccepts either the exact ID or a path whose suffix after-or/equals the ID. - Runs
cc::claude_blockswith the same fetched pricing and stores the result as the latest billing-block capture in thesettingsrowusage.captured_billing_blocks.v1.
The extra Codex homes come from managed_codex_usage_homes, which lists every runtime host whose provider is codex, resolves its UUID-owned provider-state root through workspace::managed_runtime_paths, and adds <provider_state_root>/codex-home. No environment variable is changed for this; the list travels in UsageOptions.codex_dirs.
The grow-only rule lives in archive.rs. A session is keyed by (provider, session_id) and a day cell by (provider, date, model). A fresh scan row wins only when its token total is at least the archived one; an equal total takes the fresh cost, so a repricing pass still applies. A smaller fresh reading means Claude pruned the transcript, so the archived row is kept and flagged from_archive. A row that vanished from the scan survives as archive-only. Nothing in the merge deletes rows. When token totals are equal, reconcile_*_metadata keeps canonical catalog provenance and any AuthoritativeBilled provenance that the fresh, metadata-free scan lacks, and keeps the higher reasoning and extension category counts (tests archive::tests::shrunken_scan_keeps_the_archive_and_writes_nothing, vanished_rows_survive_as_archive_only, equal_token_import_cannot_erase_exact_canonical_day_pricing, equal_token_scan_cannot_erase_canonical_provider_categories).
#The live raw-ingestion path
Three adapters emit AgentEventKind::Usage: Codex app-server (codex/app_server.rs usage_event), the Copilot SDK (copilot/sdk.rs map_assistant_usage_event), and owned Claude Code (claudecode/owned/runtime.rs). ACP maps its SessionUpdate::UsageUpdate to a context-window RuntimeStatus instead, so ACP and OpenCode produce no canonical usage. None of the three adapters sets authoritative_cost; the Copilot test asserts that nano-AIU is not treated as USD.
The worker's runtime-event projection calls project_runtime_usage for every event. runtime_usage_observation returns None for anything other than Usage. For a Usage event it re-reads the peer from the store (a live-bind may have added the session after the worker started), then requires, in order: the peer's immutable agent UUID is unchanged, and the event's host session exists and is bound to the event's room. Only then does it return None when the host session is not sandboxed (session.runtime.spawn.sandbox.enabled is false), so a host-native Usage event with an unknown session or mismatched room is an error rather than a silent skip. A sandboxed session further requires that the session has a runtime-session UUID, a runtime host owns that session and belongs to the same agent, and the agent's active room binding points at that runtime session. Any mismatch is an error, not a guess. The resulting UsageObservation carries agent, runtime host, runtime session, room, provider thread, provider turn, Jam turn, and source message IDs. Its report_id comes from UsageObservation::live_report_id, a SHA-256 over provider, provider session, provider turn, and provider event, so a replay after restart produces the same ID.
ingest_runtime_usage_observation then runs a fixed sequence of store writes. Each write is individually idempotent, so a crash between any two of them converges on the next attempt. The diagram marks where a crash changes the outcome, and which step makes the observation visible.
The outbox row is the recovery handle. UsageReconciliationRecord has three states, and only an Accepted row ever reaches a report.
Conflict is reached through conflict_staged_usage_observation with one of four fixed classes: immutable_raw_conflict (same report ID, different facts), usage_counter_conflict, usage_cost_projection_conflict, or authoritative_billing_projection_conflict. Staging changed facts under an existing report ID, whether the row is Pending or Accepted, yields Conflict with a fifth class, immutable_facts_conflict, without replacing the first row (UsageReconciliationRecord::stage).
UsageCounterRecord is the per-provider-session state machine that prevents double counting when the same usage is seen by both inputs. A delta observation either adds a new exact turn (DeltaAdded), links to a turn already counted from another source (OverlapLinked), or fills unknown categories on it (OverlapEnriched). Source priority is LiveProvider > AuthoritativeBilling > FileReconciliation > CcusageImport (usage_source_priority). A cumulative snapshot needs a snapshot_generation; it advances (SnapshotAdvanced), is unchanged, or resets its generation (SnapshotReset) without lowering the durable effective total. Exact live deltas observed after a snapshot add above it (post_snapshot_live_delta). The counter returns effective_delta, the tokens this observation newly contributes, and only that delta is priced. In SQLite the counter row, the exact-overlap row, and a per-report receipt row commit in one transaction; FileStore writes an immutable mutation journal first and converges from it (FileStore::apply_usage_counter).
Pricing is a separate immutable record. quote_canonical_usage prices through the fork's embedded catalog (cc::quote_embedded_tokens), which does no I/O. It returns missing_pricing: true with no amount unless the provider is codex or claudecode, the model is known, input, cached input, and output are all present, and every non-zero extension category is the Claude cache-write category. The projection ID is deterministic over report, catalog, catalog content version, and formula version, so re-ingestion is a Duplicate and a changed row under the same ID is a Conflict.
#From canonical ledger to archive
canonical_usage::load_archive_projection pages usage_cost_projections 500 at a time in (report_id, projection_id) order, skips any report whose reconciliation is not Accepted, validates each projection against its raw observation, and keeps one estimate per report (latest priced_at, then projection ID) plus at most one authoritative-billed projection. It aggregates estimates into session rows. Only Delta observations produce day rows, dated with UsageSource::report_day_label so a turn near midnight lands in the same day the file scanner would use; a cumulative snapshot stays session-only because assigning a multi-day total to its last day would inflate that day and double count the ccusage daily archive (test canonical_usage::tests::cumulative_session_snapshot_is_not_misassigned_to_its_last_activity_day). Billed provenance is attached but never added to cost totals (test authoritative_billing_is_visible_but_never_adds_to_estimate_totals). synchronize_archive hands the rows to UsageSource::merge_canonical_archive, which runs them through the same grow-only merge as a scan.
Repricing is explicit. Control::usage_reprice calls reprice_accepted_usage, which appends one projection per Accepted report using the currently embedded catalog, refuses when projection versions of one report disagree on the effective delta, and then resynchronizes the archive. Raw observations and counters are never modified.
#Capture scheduling
jamd spawns a tokio::time::interval task that calls Manager::usage_capture at startup (the interval's first tick is immediate) and then every JAM_USAGE_ARCHIVE_INTERVAL_SECS (default 21,600 seconds, six hours; 0 disables it). usage_capture runs capture_usage_once on the blocking pool, logs the counts, and then recomputes the discovery cost cache with presence_cost_maps. Errors are logged and never fatal.
capture_usage_from_managed_homes wraps the whole capture in one ManagedOperationLog lifecycle named managed_usage_capture and the archive step in a second named usage_archive_reconciliation. Each stage records or resolves a row in the capture-failure ledger (usage_capture_failures): scan failures and inventory failures under stage scan, ownership inventory and ambiguous ownership under attribution, invalid snapshots, ingestion failures, and archive projection under normalization. A healthy later pass resolves the row idempotently. Snapshots with no Jam owner count as unattributed and are not failures (test manager::managed_usage_capture_tests::ambient_unattributed_capture_is_not_misclassified_as_a_failure).
Daemon startup also replays the outbox. Manager::rebuild calls reconcile_pending_runtime_usage, which re-ingests at most 10,000 Pending rows, oldest first, through the same ingestion function (test lifecycle.rs::daemon_rebuild_repairs_usage_insert_to_outbox_crash_window).
#Display reads and attribution
What a report read touches, and where attribution happens:
Control::usage_report and Control::usage_sessions run on the blocking pool without the manager Inner lock. Each first calls synchronize_canonical_usage_archive; a failure there is logged and the last durable archive is served. UsageSource::report and sessions then read the archive only. With an archive configured they never call the scanner (test jam-usage archive_backed_display_reads_do_not_scan_provider_history), and active_block returns the captured billing blocks or None before the first capture. Weekly and monthly rows are re-bucketed from the merged daily cells. usage_sessions narrows the provider set when the request names an exact session or agent (usage_session_providers, usage_agent_providers) and calls sessions_for_providers, which also reads the archive only.
usage_attr::attribute joins session costs to Jam identities. It builds an index from the session ledger first (historical rows, live: false), then overwrites with live host sessions on current peers, indexing both agent_session_id and provider_session_id because a lease-backed pull session holds its receive lease in the first field. Lookup tries the exact (provider, session_id) key, then trailing_uuid, because Codex reports a rollout-path session ID that ends in the thread UUID. An --agent selector resolves to exactly one identity or fails as AmbiguousAgentSelector, which the manager maps to ControlError::Conflict. Unmatched sessions stay in the (unattributed) bucket so the agent totals, the session totals, and the report totals agree (test usage_attr::tests::rollup_reconciles_with_sessions_and_buckets_unattributed_last).
The ledger is written at the Manager::upsert_host_session chokepoint by record_session_ledger. It is best effort: a failed write is logged and never blocks a bind. In SQLite the write upserts usage_session_identity_bindings and usage_session_room_bindings and keeps the legacy session_ledger table current "for older diagnostics"; reads come from the normalized tables.
The desktop has no push event for usage. usageQueries.ts keeps one poll timer per query key while any mounted surface subscribes, at USAGE_POLL_MS (60 seconds), pauses while the document is hidden or offline, and shares in-flight pulls. The capture-failure and reconciliation-diagnostics queries run only while the usage-diagnostics experiment preference is on (DashboardPanel.tsx); the daemon routes themselves are not gated.
Platform delivery of usage is disabled. The store still has usage_platform_deliveries and the stage_usage_platform_delivery family of methods, but no production code stages a row, Control::usage_delivery_failures returns an empty list, and Manager::retry_pending_usage_delivery_for_agent is a no-op (tests lifecycle.rs::historical_platform_usage_rows_are_never_sent_or_retried, local_usage_accounting_never_wakes_the_disabled_platform_delivery_path). This is a legacy path kept for database compatibility.
#Product analytics
Analytics has three sink families selected once per process: hosted (Amplitude plus a temporary PostHog copy), local (an encrypted on-prem store), and off. Which component decides whether an event is delivered, and where it goes:
Vocabulary. AnalyticsEvent has 16 variants and EVENT_NAMES lists the same 16 wire names. Every property is a closed enum, bool, or bucket, with one documented open-set exception, agent_provider_tag. map_event first checks event_allowed, which pins each event to the (surface, emitter) pairs that may produce it, and returns MappingError::InvalidEventOwner otherwise. CanonicalEvent is minted once with a UUID and an occurrence time before fan-out, so hosted deduplication and local export see the same identity.
Producers. The daemon's Manager holds an Arc<dyn ProductAnalytics>. Production code calls crate::analytics::track 14 times: 12 in manager.rs (AgentIdentityCreated, AgentRuntimeStarted, WorkItemCreated ×4, WorkKickedOff, ParticipantAddedToRoom ×3, ParticipantAddRefused, SharedWorkCompleted) and 2 in roomboard.rs (SharedWorkCompleted, reached from workitems.rs and task_lane.rs through roomboard::correlate). track logs a delivery error at debug level and returns nothing, so analytics cannot fail a product path. The desktop Tauri process has its own AnalyticsClient for launch, setup, product-area, and participant events, and the WebView calls the track_analytics_event command with a FrontendAnalyticsEvent.
Mode selection. jamd's analytics_mode and the desktop's deployment_analytics_mode apply the same rules: a managed-settings disable wins (off; the desktop checks it separately as AnalyticsState::managed_disabled and builds no client, rather than inside deployment_analytics_mode); a customer-hosted server (is_customer_hosted(BAND_CLOUD_URL)) selects local; a build compiled with cfg(jam_release_analytics) selects hosted; everything else is off. That cfg is set by crates/jam-analytics/build_support.rs only when JAM_RELEASE_ANALYTICS=1, the profile is release or dist, the tracked Amplitude key and PostHog token validate, and both transport Cargo features are enabled. The keys are compiled in as JAM_COMPILED_AMPLITUDE_API_KEY and JAM_COMPILED_POSTHOG_PROJECT_TOKEN.
Hosted delivery. FanoutSink resolves the Band user once under BandUserIdentity's read lock and offers the same event to both children; the event counts as accepted when either child accepts (tests transport::tests::fanout_attempts_both_sinks_and_accepts_either_delivery, fanout_holds_one_user_identity_across_both_admissions). BandUserIdentity keeps one user ID per source (per profile in the daemon, a single source in the desktop) and resolves only when every source agrees; any disagreement or unresolved source suppresses hosted delivery (test band_user_identity::tests::multiple_sources_require_one_unambiguous_user). Both hosted sinks drop $exception error reports and suppress events with no resolved user (tests posthog.rs::hosted_transport_suppresses_events_without_a_band_user_id, posthog_transport_suppresses_remote_error_reports). PostHogSink additionally closes admission at POSTHOG_DUAL_DELIVERY_DISABLE_AT_UNIX_SECONDS (1,790,294,400, which is 2026-09-25 00:00:00 UTC) without a restart (test posthog_admission_closes_at_september_25_utc). Transport properties pass allowed_transport_property, which rejects every $-prefixed key except three transport controls, and uses a closed allowlist for $exception.
In the daemon, ProductAnalytics identity is fed by account lifecycle: set_analytics_profile_user_id and clear_analytics_profile_identity are called 20 times in manager.rs, including on OAuth authentication, auth_required, supersede, sign-out, and deletion. A daemon in hosted mode built without a BandUserIdentity gets the no-op client.
Local delivery. QueuedLocalAnalyticsSink enqueues into a bounded channel (RUNTIME_QUEUE_CAPACITY 256) drained by a writer thread into product-analytics.db, an SQLCipher database keyed from jam-secure-store. The key name is analytics:database-key:<sha256 of the canonical config dir>, so two config directories never share a key (test database_key_accounts_are_scoped_to_the_canonical_config_directory). Retention is 365 days and a 100 MiB logical cap. A full queue returns an error and counts a drop. Desktop commands analytics_status, set_analytics_enabled, repair_local_analytics, and export_local_analytics operate on it.
Policy. AnalyticsControlFile persists analytics-control.json in the config directory under a file lock. The desktop and the daemon each load it, and AnalyticsPolicyGate::capture_if_enabled re-reads the file on every capture, so a toggle in one process takes effect in the other without IPC. The installation UUID lives in analytics-installation-id; delete_local_account_data rotates it through a journaled setting analytics.local_data_rotation.<profile> so a crash between deletion and rotation finishes exactly once on retry.
#Accounts and sign-in
A profile names one Band account inside one configuration directory. A configuration directory (JAM_CONFIG_DIR, or jamd --config-dir, or the platform default) is the unit of isolation: jamd binds <dir>/jam.sock, opens the store under <dir>/cfg, and holds jam_service::try_acquire_daemon_lock on the directory for its lifetime. Profiles in one directory share that daemon and store. Owned runtimes receive JAM_CONFIG_DIR and JAM_PROFILE through jam-host/src/runtime_env.rs so their jam calls reach the owning daemon.
An Account row has an AuthKind: ApiKey (a durable band_u_ key in secret user:<profile>) or Oauth (empty user_key, refresh token in secret user_refresh:<profile>, issuer and client_id in AccountIdentity). jam init defaults to AuthMode::Token, which is OAuth; --auth mint trades the id_token for a durable key at POST /api/v1/auth/api-keys (legacy compatibility); --user-api-key stores an explicit key. The desktop always uses token sign-in.
What happens between the sign-in click and a live Bearer credential in the daemon, with the durable commit marked:
jam-auth opens /oauth2/authorize with PKCE S256 and a random state, requests openid email profile offline_access (overridable at build time with JAM_OAUTH_SCOPES), and reads the client ID from the compiled JAM_OAUTH_CLIENT_ID or from the customer-hosted deployment's discovered configuration. The whole interactive flow is bounded by SIGNIN_TIMEOUT (600 seconds) and each HTTP request by HTTP_TIMEOUT (30 seconds); redirects are not followed. A grant without a refresh token is rejected. account_identity_from_id_token decodes the claims to fill user_id from sub, plus email, names, and handle; it does not verify the signature locally. Inferred: this is acceptable only because the token arrives directly from the discovered token endpoint over TLS; the platform's mint path is where audience validation happens.
Manager::add_oauth_account serializes on lock_account_lifecycle, rejects a missing user_id, and rejects a different principal on a profile that already has one (ManagerError::Conflict); the desktop's save_after_account_switch reacts to that Conflict by removing the old profile and saving again. It cancels and joins any previous refresh loop before writing, because start_oauth_session's own replacement path can only abort under the lock and an old loop mid-rotation could otherwise overwrite the fresh refresh token. save_account with an empty key deletes user:<profile>. If set_user_refresh_token then fails, the prior account (and its API key) is restored and discovery is re-announced (test oauth_refresh.rs::add_oauth_account_restores_the_api_key_when_refresh_persistence_fails).
The CLI jam init --auth token does the same conversion by writing the store directly rather than through the daemon, then asks the user to restart jamd. It first refuses when a wire-0 daemon is running (oauth_wire_gate), because such a daemon would read the OAuth row as an API-key account with an empty key.
#Token refresh and auth_required
The refresh loop is one task per OAuth account, owned by the manager. The manager does not depend on jam-auth for this: it calls the TokenRefresher port, and jamd injects AuthTokenRefresher through Manager::with_oauth. That refresher discovers the token endpoint from the account's issuer once per issuer and calls jam_auth::refresh. How a Bearer credential stays fresh, and how a revoked grant becomes auth_required:
The schedule comes from oauth.rs. refresh_retry_window sums the backoff ladder (2, 4, 8 … 256 seconds plus one second of jitter each, 518 seconds total, test oauth::tests::retry_window_matches_the_actual_backoff_ladder), and wait_until_refresh renews that long before expires_at, or halfway through a shorter lifetime. An unknown expiry refreshes immediately, which is how a daemon restart re-materializes the credential without a browser. A missing expires_in becomes 300 seconds (FALLBACK_EXPIRES_IN_SECS) so the loop cannot spin.
Transient failures back off (2 seconds doubling to 300, plus jitter) and never sign out (test transient_failure_backs_off_and_never_signs_out). A rotated refresh token is adopted in memory unconditionally, because the server has already consumed the old one, and the durable write is retried at most every REFRESH_BACKOFF_MAX until it lands (test rotated_token_is_adopted_in_memory_even_when_the_durable_write_fails). A Bearer 401 from any REST client sends an OAuthRefreshRequest that wakes the loop; requests that arrive during one HTTP refresh share its outcome, and a request carrying the credential that already failed gets Unavailable instead of another attempt (tests bearer_401_wakes_refresh_and_retries_the_rejected_room_read, concurrent_401_callers_share_one_in_flight_refresh_result, repeated_401_across_rest_clients_does_not_bypass_refresh_backoff).
mark_account_auth_required flips the in-memory account_auth_states entry only from absent or Active (refresh_rejection_can_require_auth), wakes any ready_user_transport waiters with an empty credential so they return the typed error, cancels the human feed and stats tasks, withdraws the profile from discovery on a spawned task, clears the analytics identity, and publishes EventKind::AccountAuthChanged. Agent workers are left running. From then on user_transport and ready_user_transport fail fast with ManagerError::AuthRequired, which maps to ControlError::AuthRequired so clients branch on the variant (test invalid_grant_enters_auth_required_with_event_and_halts). A later add_oauth_account calls start_oauth_session, which sets the state back to Active (test add_oauth_account_clears_a_previously_auth_required_state).
The states an account's live credential can be in, and what moves it between them:
API-key accounts have no entry and read as Active. At daemon start, jamd calls spawn_oauth_refreshers before rebuild and before room feeds. It starts a loop for every AuthKind::Oauth account on the pinned deployment that has a stored refresh token, with authenticated: false, so the first successful refresh publishes Active. ready_user_transport lets an early command wait for that first credential instead of sending an empty Bearer token (test oauth_room_feed_waits_for_the_initial_bearer_credential).
#Sign-out ordering
Sign-out keeps local data and removes only credentials. The order matters because the refresh loop can be in the middle of persisting a rotated token. What sign_out_account stops, joins, and deletes, in order:
cancel_oauth_session removes the session, cancels its token, aborts the task, and awaits it. Abort cannot interrupt a synchronous store write, so the join guarantees any in-flight rotation write happens before the delete (test sign_out_during_an_in_flight_refresh_never_resurrects_the_token). Discovery is withdrawn before the credential delete so a failed delete cannot leave a presence the user asked to remove. jam logout goes through the daemon; when the daemon is unreachable it deletes the secrets directly from the store, which is safe because no loop is running.
Two other removal paths exist. remove_account deletes the profile's rows and secrets but first moves shared account-instance caches (room directory, message cache, inbox, attention) to a surviving alias profile of the same account instance. delete_local_account_data is destructive: it also deletes, across the whole database, the room boards and plans, the session ledger, every usage table including both archives, activity, and artifacts, and it rotates the installation UUID.
#Account instance, generations, and the room directory
Account::stable_account_instance is "<len>:<base_url><user_id>" with trailing slashes removed. Durable caches (account_directories, room_directory_cache, room message and attention tables) are keyed by that string, not by profile, so re-signing the same principal reuses its caches and a different principal in the same profile cannot read them. account_directory_profiles maps each profile to its instance.
The manager keeps an in-memory account generation per profile (account_generations, AccountGenerationGuard): the instance, a counter, and a cancellation token. account_generation_guard is taken by 34 production call sites (31 in manager.rs, 3 in manager/board_bridge.rs) before account-scoped network work, and quiesce_account_generation cancels the token, bumps the counter, and joins the account's feed, stats, connect, room-refresh, and message-refresh tasks. Late completions check account_generation_is_current and are discarded. The OAuth loop checks it too before acting on InvalidGrant.
Observe-only rooms are rooms visible to the human only because an agent they own is a member. rooms_at_with_guard computes them only for refresh reasons AgentRoom, Startup, and Reconnect, because it costs a second full pagination: list_agent_only_rooms in jam-transport-band reads the same endpoint with and without include=agent_rooms and diffs the two, using the server's predicates on both sides rather than a client-side participant test. The IDs persist in account_directories.observe_only_room_ids_json through replace_room_directory_if_revision, which writes only when the stored revision still equals the revision read before network I/O. Some(ids) replaces the classification, None carries it forward, and the set is kept disjoint from member rooms (tests room_reconcile.rs::observe_only_classification_survives_an_unwidened_refresh, routed_board.rs::an_observe_only_directory_entry_classifies_band). A cold cache read omits observe-only rooms rather than returning them unclassified.
#Linear OAuth
Linear is a per-profile integration credential, not a Band account. The desktop's linear_connect command and jam linear connect run jam_auth::linear_oauth_tokens, a PKCE S256 authorization-code flow with actor=user and read scope against the fixed loopback redirect http://127.0.0.1:47837/linear/oauth/callback and a public client ID from JAM_LINEAR_OAUTH_CLIENT_ID. The client process then hands the token set to the daemon through Control::linear_connect_oauth, and linear::connect_oauth validates it with a GraphQL call and stores a StoredLinearCredential JSON in secret integration:<profile>:linear.
From then on the daemon owns the credential. usable_credential refreshes an OAuth token that expires within 60 seconds by calling jam_auth::refresh_linear_oauth directly, under a per-profile tokio::sync::Mutex from a process-global OnceLock map (credential_lock), and re-reads the credential under the lock so a second caller uses the first caller's rotation. disconnect holds the same lock, revokes the refresh token, deletes the secret, and clears scope, issue-progress, and auto-start settings independently, reporting every step that failed. The background Linear poller and the Linear work-source UI are gated by the linear-work-source experiment; the connection itself is not.
#Discovery
Discovery has one manager-owned registry and loop behind the DiscoveryProvider trait, and four provider implementations chosen once at startup by JAM_DISCOVERY. Which provider runs, what it talks to, and how the desktop learns of changes:
Provider selection. In jamd, off or a managed enabled: false installs NullDiscovery; off also calls Manager::with_discovery_disabled, which makes set_discoverable(true) return Conflict for the whole run. band installs BandDiscovery alone, lan installs MdnsDiscovery alone, and any other value, including unset, installs AggregateDiscovery over both. BandDiscovery is built before the manager, so jamd later wires Manager::account_transport_resolver into it; that resolver uses ready_user_transport, which is how the provider reaches OAuth accounts whose Bearer token lives only in the refresh loop.
The loop. Manager::spawn_discovery subscribes to the provider before spawning, then selects over four inputs. Provider events are applied to DiscoveryState only while discoverable, and set a marker flag. Fanout events split into structural changes (PeerAdded, PeerState, PeerUpdated, PeerRemoved, RoomBoard, or a lagged receiver) that require a re-announce, and detail changes (Activity projected through project_activity, runtime permission requested and resolved, WorkItems) that need only refresh_detail. A cost ticker (JAM_PRESENCE_COST_REFRESH_SECS, default 120 seconds, 0 disables) recomputes presence_cost_maps from UsageSource::cached_sessions, which never scans. The 2-second ticker ages sources, heals stale activity, publishes at most one payload-free EventKind::DiscoveryChanged, and, inside discovery_provider_gate, re-reads discoverable and then announces or refreshes. A failed announce calls discovery_fail_closed, which withdraws, clears sources, and sets discoverable false (test discovery.rs::failed_background_reannounce_fails_closed_and_flips_state_off).
Presence payload. assemble_presence builds one AccountPresence per signed-in, deployment-eligible account with a non-empty user_id, from store and board reads only. Agents carry agent ID, handle, name, PeerState, a content-free AgentActivity (Idle, Thinking, Tool { name }, Notifying), TaskCounts, and cost. Teams are room boards with at least one local assignee, capped at 64, carrying member handles, task counts, cost, and the room title. Room titles are shared unless discovery.share_room_titles is "false" or managed settings say otherwise, and cost is shared unless discovery.share_cost is "false" (tests room_titles_shared_by_default_and_withheld_when_opted_out, share_cost_defaults_on_and_persists_the_toggle). profile is #[serde(skip)]. deployment_scope is a 16-byte SHA-256 of the account's origin, so equal user UUIDs from different deployments do not merge (test discovery::tests::equal_user_ids_from_different_deployments_never_merge). The DTOs have no fields for prompts, tool input, paths, PIDs, or keys, and activity_projection_is_content_free checks the projection.
mDNS. MdnsDiscovery is idle until the first announce. It then binds a Tier-2 HTTP server on 0.0.0.0 with an ephemeral port and registers one _jam-peer._tcp.local. instance per account whose TXT record carries only protocol, user_id, handle, display_name (truncated to 64 characters), and the agent count. Detail travels by unicast pull: the browse side fetches GET /v1/presence on resolve and re-polls known sources every 20 seconds, with a 3-second timeout, a 256 KiB response cap, and at most 16 accounts per source. The server serves only that route, rate-limits 20 requests per 10 seconds per source IP (tracking at most 1,024 IPs), and bounds each request to 5 seconds. multicast_health reports whether the daemon heard its own advertisement within 15 seconds.
Band. BandDiscovery polls each signed-in account's organization roster every 30 seconds (JAM_DISCOVERY_POLL_SECS) within a 40-second sweep deadline and emits roster rows as sources band:{profile}:{user_id} with live: false. It also joins the organization presence channel and emits live bandp:{profile}:{user_id} sources. It publishes only agents the account has explicitly shared with its organization, at most MAX_PUBLISHED_AGENTS (64) within MAX_META_BYTES (16,384), ranked by readiness (published_agents, publish_rank). A failed poll changes nothing; only a positive "no org" or "member gone" retires a source. poll_now runs a forced sweep no more often than every 5 seconds.
Aggregate. AggregateDiscovery::announce announces to every child. A child that fails is withdrawn and retried with backoff starting at 2 seconds. The aggregate succeeds when at least one child started and every failed child's cleanup succeeded; it fails closed when none started or a cleanup failed (tests discovery_agg::tests::one_failed_announce_keeps_the_successful_mechanism_active, failed_child_cleanup_fails_the_aggregate_closed). It does not deduplicate; the registry merge folds sources by (deployment_scope, user_id).
Registry. DiscoveryState holds at most MAX_SOURCES (4,096) sources. A source not re-sighted for 45 seconds makes its user offline; after 120 seconds it is evicted. A source with no sighting time is treated as fresh. merged_at sets a user online only if some fresh source with live: true vouches for them, so a roster baseline cannot keep a dead user online. Advertised Thinking or Tool heals to Idle after 120 seconds and Notifying after 600 seconds.
#State and ownership
| Aggregate | Key and scope | Authority | Mutation coordinator | Durable representation | Projections | Freshness or fence | Recovery | Deletion authority |
|---|---|---|---|---|---|---|---|---|
| Legacy usage archive | Session (provider, session_id); day (provider, date, model) |
Largest token total ever observed | UsageSource under refresh_gate, days_gate, sessions_gate |
usage_session_archive, usage_daily_archive |
Reports, sessions, agent rollups, discovery cost | Grow-only merge; response cache TTL 120 s | Next capture re-merges | delete_local_account_data only (whole table) |
| Captured billing blocks | One value per database | Latest capture | UsageSource::store_captured_blocks |
settings key usage.captured_billing_blocks.v1 |
usage_active_block, blocks report |
Replaced each capture | Next capture | Overwritten, not deleted |
| Raw usage observation | report_id, deterministic |
First write for the ID | ingest_runtime_usage_observation |
usage_observations |
Diagnostics, projections | Replay is Duplicate, changed facts Conflict | Pending outbox replay | delete_local_account_data |
| Reconciliation outbox | report_id |
Ingestion function | Same, plus rebuild replay |
usage_reconciliation |
usage_reconciliation_diagnostics |
Pending, Accepted, Conflict | reconcile_pending_runtime_usage at rebuild, 10,000 rows |
delete_local_account_data |
| Usage counter | (provider, provider_session_id) |
UsageCounterRecord::apply |
Store apply_usage_counter |
usage_counters, usage_exact_overlaps, usage_counter_mutations |
effective_delta, diagnostics high-water |
revision; per-report receipt; FileStore journal |
Idempotent replay by report ID | delete_local_account_data |
| Cost projection | projection_id over report, catalog, content version, formula |
Catalog quote at ingestion or explicit reprice | Ingestion; reprice_accepted_usage |
usage_cost_projections |
Archive rows via synchronize_archive |
Latest priced_at wins per report |
Append-only | delete_local_account_data |
| Capture failure ledger | Deterministic failure_id per stage and class |
Capture pipeline | record_/resolve_usage_capture_failure |
usage_capture_failures |
jam usage failures, desktop warning |
Pending or Resolved; occurrences counter | Healthy pass resolves | delete_local_account_data |
| Session ledger | (provider, agent_session_id) |
Last binding observed | Manager::record_session_ledger at upsert_host_session |
usage_session_identity_bindings, usage_session_room_bindings, legacy session_ledger |
Usage attribution | Upsert; room bindings accumulate | Best effort, rewritten on next bind | delete_local_account_data |
| Account record | profile |
CLI jam init or daemon add_oauth_account |
lock_account_lifecycle in the daemon; none for the CLI |
accounts table |
Control::accounts, desktop account gate |
None | None needed | remove_account, delete_local_account_data |
| Refresh token | user_refresh:<profile> |
FusionAuth, rotate-on-use | OAuth refresh loop | SecretStore, 0600 file by default | None | Latest rotation; persist retried | Loop restarts from it at boot | sign_out_account after join |
| Live access token | Profile | Refresh loop | run_account_refresh |
Memory, watch::Sender<Credential> |
Bearer transports | expires_at |
Refresh at boot | Dropped with the session |
| Account auth state | Profile | Manager | Refresh loop and human socket | Memory, account_auth_states |
AccountAuthChanged |
Only Active can become AuthRequired | Recomputed by the next boot refresh | Sign-out, delete |
| Account generation | Profile | Manager | replace_account_generation, quiesce_account_generation |
Memory | Guards on async work | Counter plus cancel token | Rebuilt lazily | Invalidated on sign-out and delete |
| Room directory | account_instance |
Band | rooms_at_with_guard |
account_directories, account_directory_profiles, room_directory_cache |
account_room_snapshot |
revision compare-and-replace |
Next refresh | remove_account moves to an alias; delete clears |
| Observe-only set | account_instance |
Band visibility predicates | Widened refreshes only | observe_only_room_ids_json |
Room list classification, board routing | None carries forward |
Next widened refresh | With the directory |
| Linear credential | integration:<profile>:linear |
Linear | linear.rs under credential_lock |
SecretStore JSON | linear_connection_status |
Refresh within 60 s of expiry | Re-read under lock | linear_disconnect, remove_account |
| Analytics policy | Config directory | User toggle, managed settings | AnalyticsControlFile under file lock |
analytics-control.json |
Desktop analytics status | Re-read per capture | None needed | Not deleted |
| Installation identity | Config directory | InstallationIdentity |
File lock | analytics-installation-id |
Every CanonicalEvent |
Rotated after local-data deletion | Journaled rotation setting | Rotated, not deleted |
| Local analytics events | event_id |
Sink writer | QueuedLocalAnalyticsSink thread |
product-analytics.db (SQLCipher) |
Export, status | 365-day retention, 100 MiB cap | Quarantine and recreate on corruption | Purge, scrub |
| Band user identity | Source (profile) | Account lifecycle | BandUserIdentity::set_source |
Memory | Hosted sink attribution | revision |
Rebuilt from accounts at startup | Cleared on sign-out |
| Discoverable choice | Daemon | User or managed settings | set_discoverable_impl under discovery_provider_gate |
settings discovery.discoverable |
list_discovered |
Read error means off | Boot reads it | Overwritten |
| Discovered sources | source_id |
Provider | Discovery loop | Memory, DiscoveryState |
DiscoveredUser list |
45 s stale, 120 s evict | Provider re-sighting | Toggle-off clears |
#Contracts
#Control methods
The generated reference lists every route, Tauri command, and CLI use: Usage & cost (8 methods), the account rows of Peers and lifecycle (accounts, add_account, add_oauth_account, sign_out_account, delete_local_account_data, delete_account, remove_account, whoami), LAN discovery (5 methods), and the Linear rows (14 methods). Events are in events.md; this subsystem publishes account_auth_changed, deployment_changed, and discovery_changed.
The semantic guarantees that code enforces:
- Usage display reads never scan provider files when an archive is configured, and never fetch pricing. They do synchronize the canonical ledger into the archive first, and fall back to the last durable archive if that fails.
usage_refreshis bounded.USAGE_REFRESH_TIMEOUT_SECSis 120 seconds on the daemon route (jam-daemonraises the route timeout forUSAGE_REFRESH), andjam-clientwaits 135 seconds (USAGE_REFRESH_TIMEOUT, the budget plus 15).usage_sessionsrejects ambiguous agent selectors withControlError::Conflictrather than merging identities.- Missing pricing is never zero. Canonical projections carry
missing_pricingwith no amount; ccusage blocks with usage but no price are marked (testa_block_with_usage_but_no_price_is_marked_rather_than_zeroed). - Ingestion is idempotent by
report_id. Replay of identical facts isDuplicate; different facts under one ID areConflict, never an overwrite. add_oauth_accountis all or nothing. A second-write failure restores the prior account and credential.sign_out_accountjoins the refresh task before deleting the refresh token.- Requests on an
auth_requiredOAuth account fail withControlError::AuthRequired; an OAuth account without a live session never falls back to an empty API key (testoauth_room_read_without_a_live_session_never_falls_back_to_an_empty_api_key). set_discoverable(false)persists before withdrawing and returns an error if the persist failed; a failedset_discoverable(true)commits nothing and fails closed (testfailed_toggle_on_commits_nothing_and_fails_closed).refresh_discoveryis a read trigger. It reaches the provider only while discoverable (testa_refresh_reaches_the_provider_only_while_discovery_is_on) and has a 60-second route timeout (DISCOVERY_REFRESH_TIMEOUT_SECS).DiscoveryChangedcarries no payload. Clients hydratelist_discoveredafter it.
#Ports and traits
| Seam | Provided by | Consumed by | Guarantee |
|---|---|---|---|
jam_usage::UsageArchive |
StoreUsageArchive in manager.rs |
UsageSource |
Replace-upsert by key, list all; billing blocks optional |
ManagedUsageCaptureSource |
UsageSource |
capture_usage_from_managed_homes_with_source |
Test seam for capture failures |
jam_store::UsageObservationRepo |
SqliteStore, FileStore |
Ingestion, canonical bridge, diagnostics | First write wins; atomic counter apply; paged projections in key order |
jam_manager::oauth::TokenRefresher |
AuthTokenRefresher in jamd |
run_account_refresh |
InvalidGrant is terminal, Transport is retryable |
jam_manager::ProductAnalytics |
AnalyticsClient, no-op |
Manager, board, task lanes | track returns an error the caller logs and ignores |
jam_analytics::AnalyticsSink |
Fanout, Amplitude, PostHog, local, noop | AnalyticsClient |
Accepted or Suppressed; capture_durable acknowledges after commit for queued sinks |
jam_contract::DiscoveryProvider |
NullDiscovery, MdnsDiscovery, BandDiscovery, AggregateDiscovery |
Discovery loop | announce on structural change only; poll_now is a no-op while withdrawn and rate-limited; subscribe streams deltas |
AccountTransport resolver |
Manager::account_transport_resolver |
BandDiscovery |
Returns a ready transport or None; a dropped manager returns None |
SecretStore |
FileSecretStore (default), KeychainStore (JAM_SECRET_STORE=keychain) |
jam-store |
Opaque keys; delete is idempotent |
#External protocols
- FusionAuth OIDC. Discovery at
<issuer>/.well-known/openid-configuration; authorization code with PKCE S256 and loopback redirect path/callbackon an ephemeral 127.0.0.1 port; device grant (RFC 8628) in the CLI; public-clientgrant_type=refresh_token. The only terminal refresh error is the OAuth body{"error":"invalid_grant"}. - Band mint (legacy).
POST /api/v1/auth/api-keyswith{"id_token": …};mint_errormaps platform codes includingonboarding_misconfigured. - Linear.
https://linear.app/oauth/authorize,https://api.linear.app/oauth/token,https://api.linear.app/oauth/revoke, and GraphQL athttps://api.linear.app/graphql. - mDNS and Tier 2.
_jam-peer._tcp.local.with TXTprotocol=1;GET /v1/presencereturningVec<AccountPresence>. - Amplitude HTTP API v2 at
https://api2.amplitude.com/2/httpapiwith batches of 10 and 3 attempts; PostHog throughposthog-rswith a 100-event queue and 2-second request timeout.
#Invariants
| Rule | Enforced by | Known exceptions |
|---|---|---|
| Display reads never scan provider history when an archive is configured | UsageSource::merged_days/merged_sessions branch on archive; test archive_backed_display_reads_do_not_scan_provider_history |
A source built with UsageSource::new (no archive) scans on display; only tests and embedders use it |
| Only the capture fetches pricing over the network | options(fetch) passes offline: !fetch; capture is the only fetch = true caller |
JAM_USAGE_OFFLINE=1 makes capture offline too |
| At most one full provider scan runs at a time | refresh_gate; test concurrent_refreshes_do_not_scan_provider_history_in_parallel |
None |
| A slow capture does not block page loads | Capture scans outside days_gate; test a_slow_capture_scan_does_not_block_display_reads |
None |
| Archive rows only grow | merge_by_key keeps the larger token total; archive tests |
delete_local_account_data deletes every usage table |
| One managed turn is not archived twice | managed_provider_session_matches filters legacy rows; test managed_provider_sessions_reach_canonical_snapshots_without_legacy_archive_duplication |
Relies on the persisted agent_session_id equalling ccusage's ID or its path suffix |
| Canonical usage requires exact immutable ownership | runtime_usage_observation checks; tests live_usage_resolves_immutable_agent_host_session_and_room_ownership, ccusage_snapshot_refuses_ambiguous_persisted_runtime_ownership |
Host-native owned sessions are skipped entirely, not attributed canonically |
| An observation is staged before its raw insert | Call order in ingest_runtime_usage_observation; tests live_usage_stages_before_insert_and_restart_replays_the_crash_window, pending_usage_replay_reuses_the_original_delta_after_counter_commit |
None |
| Only Accepted reports reach reports | load_archive_projection filter; test store_projection_includes_only_accepted_rows |
None |
| One estimate per report contributes | SelectedReportProjections; test one_raw_report_contributes_once_and_latest_price_version_wins |
None |
| Billed cost never adds to estimates | add_selected_row pushes provenance only; test authoritative_billing_is_visible_but_never_adds_to_estimate_totals |
No adapter supplies billed cost today |
Missing pricing is never $0 |
quote_canonical_usage fail-closed; tests canonical_quote_never_turns_missing_model_pricing_into_zero, canonical_quote_fails_closed_for_unpriced_extension_category |
Legacy archive rows use legacy_archive provenance (test legacy_daily_archive_is_explicitly_unpriced_without_a_zero_dollar_claim) |
| Usage is never sent to Band | No production caller of stage_usage_platform_delivery; lifecycle tests named above |
None |
| Attribution totals reconcile | usage_attr::roll_up and totalize over the same sessions; test rollup_reconciles_with_sessions_and_buckets_unattributed_last |
None |
| A rotated refresh token is never lost to a transient write failure | In-memory adoption plus persist_pending; test rotated_token_is_adopted_in_memory_even_when_the_durable_write_fails |
A crash while the write is pending leaves the consumed token on disk; the next boot refresh fails invalid_grant |
| Sign-out cannot resurrect a refresh token | Join before delete in cancel_oauth_session; test sign_out_during_an_in_flight_refresh_never_resurrects_the_token |
None |
| A new sign-in cannot be overwritten by an old loop | add_oauth_account joins the prior loop before writing |
start_oauth_session's own replace path only aborts; callers other than add_oauth_account rely on the loop being absent |
| An OAuth account never sends an empty credential | ready_user_transport waits for a non-empty Bearer token; tests oauth_room_feed_waits_for_the_initial_bearer_credential, a_directory_search_waits_for_the_first_bearer_credential_instead_of_sending_an_empty_one |
None |
Only Active can become AuthRequired |
refresh_rejection_can_require_auth |
None |
| Secrets never reach the WebView | Secret fields are SecretKey; desktop DTOs carry non-secret status only |
Not enforced by a type boundary on generated bindings; reviewed by hand |
| Hosted analytics needs one unambiguous Band user | BandUserIdentity::with_current_user; FanoutSink, AmplitudeSink, PostHogSink suppress on None |
None |
Hosted transports never send $exception |
capture_with_user early return in both sinks |
Local analytics keeps error reports by design |
| An event is emitted only by its owning surface and emitter | event_allowed in map_event; test contract.rs::mapper_produces_exact_bounded_name_allowlist uses the owning context |
See Refactor notes: daemon ParticipantAddedToRoom is always rejected |
| PostHog admission stops at 2026-09-25 00:00 UTC | posthog_admission_open; test posthog_admission_closes_at_september_25_utc |
PostHogConfig::local_test has no cutoff |
| Discovery payloads cannot carry content | DTO fields in jam-domain/src/discovery.rs; profile is serde(skip); tests toggle_on_announces_projected_presence_with_no_content, activity_projection_is_content_free |
Room titles and cost are shared by default and are opt-out |
| Toggle-off wins over an in-flight update | discovery_provider_gate and re-check of discoverable inside it; test toggle_off_wins_over_an_in_flight_discovery_update |
None |
| An unreadable discoverable setting means off | Manager::with_usage reads DISCOVERABLE_SETTING; test boot_fails_closed_when_the_persisted_toggle_cannot_be_read |
None |
JAM_DISCOVERY=off holds for the whole run |
discovery_env_disabled; test env_disabled_discovery_is_pinned_off_for_the_whole_run |
None |
| The desktop never links usage or discovery code | just no-daemon greps the desktop dependency tree for `jam-(manager |
core |
| The ccusage fork is built without its SQLite adapters | default-features = false in the root Cargo.toml |
This also disables the fork's OpenCode, Kilo, Hermes, and Goose database readers |
#Failure and recovery
Daemon crash during live ingestion. Every write in the ingestion sequence is idempotent. A crash after staging leaves a Pending row; rebuild replays it. A crash after the counter commit but before acceptance replays with the original effective_delta because the counter recognizes the report by its receipt row (test pending_usage_replay_reuses_the_original_delta_after_counter_commit). A durable conflict makes project_runtime_usage return an error, which the worker logs as "runtime event projection failed" and continues; the turn is not failed.
Capture failure. Each failed stage writes a bounded UsageCaptureFailureRecord with provider, source, stage, class, and counts, never scanner text or paths. The next capture resolves it. jam usage failures and the desktop warning (under usage-diagnostics) read the ledger; jam usage refresh or Retry now runs the same capture. Test manager::managed_usage_capture_tests::scan_failure_is_bounded_durable_and_resolved_after_restart and lifecycle.rs::restarted_manager_exposes_persisted_usage_capture_recovery cover the path.
Archive projection failure on a display read. synchronize_canonical_usage_archive records archive_projection_unavailable and the read serves the last archive. Inferred: because the synchronize runs on every usage_report and usage_sessions call, a persistent projection failure is re-recorded on every poll until it clears.
Transcript pruning, checkpoint deletion, sandbox removal. The archive keeps the larger reading, and canonical observations live in the store outside provider state and Docker lifecycle (test canonical_usage::tests::canonical_usage_survives_provider_pruning_retention_host_removal_and_archive_reopen).
Startup ordering. The capture interval is spawned independently of the startup task that runs spawn_oauth_refreshers and rebuild. Inferred: the first capture can therefore run before rebuild replays Pending usage rows; both paths go through the same idempotent ingestion, so the order changes timing, not totals.
Refresh endpoint unreachable. The loop backs off and keeps the account Active; Human-API calls that receive a 401 wait on the shared refresh and return Unavailable if it fails. Agent workers use agent keys and are unaffected.
Refresh token revoked. invalid_grant moves the account to AuthRequired, stops the human feed and stats, withdraws discovery, and publishes AccountAuthChanged. Recovery is a fresh sign-in through add_oauth_account. The state is not persisted, so after a daemon restart the boot refresh fails again and re-enters AuthRequired.
Rotated-token write lost to a crash. The disk keeps the consumed token and the next boot refresh fails with invalid_grant. This window is documented in run_account_refresh and accepted.
Partial add_oauth_account. A failure in save_account or set_user_refresh_token restores the prior account, the prior refresh token and live session, and discovery. If a stale-discovery cleanup fails after a successful save, the account stays saved and active and the method returns Unavailable naming the cleanup failure.
Partial sign-out. If withdraw_discovery_profile fails, discovery fails closed and sign_out_account returns an error before the secrets are deleted. Inferred: the refresh loop has already been stopped at that point, so the account keeps its refresh token but has no live session until the user retries or the daemon restarts and spawn_oauth_refreshers starts it again.
Superseded connection. When the human socket reports ConnState::Superseded, mark_account_superseded stops the account's feed and stats, withdraws discovery, and publishes AccountAuthChanged { Superseded }; there is no reconnect loop.
Linear refresh failure. usable_credential returns ManagerError::Unavailable("Linear refresh: …"); connection_status reports the connection as needing attention. A disconnect reports each failed cleanup step together and still deletes the credential.
Analytics sink failures. A full local queue or a failed hosted admission returns an error that track logs at debug level. AnalyticsClient::shutdown flushes with bounded timeouts (PostHog 2.5 seconds). Local-store corruption is classified and quarantined by LocalAnalyticsStore::open_or_recover, keeping at most three quarantine sets.
Discovery provider failure. A failed announce fails the whole surface closed. In the aggregate, a failing child is withdrawn and retried with backoff while the other keeps running; a failed child cleanup fails the aggregate closed. A failed Band poll changes nothing. A missing mDNS goodbye is covered by 45-second staleness and 120-second eviction.
Daemon shutdown. Manager::close cancels root_cancel, which ends the refresh loops (their tokens are child tokens) and the discovery loop. The usage capture interval task selects on jamd's own shutdown token instead. Inferred: a scan already running on the blocking pool is not cancelled by either token and finishes before its result is dropped.
#Extension points
#A new harness that reports usage
The changes depend on how far the harness participates. Today three adapters participate; ACP and OpenCode do not.
- Emit live usage. In the
jam-hostadapter, map the provider's per-call usage toAgentEventKind::Usagewith missing categories asNone,provider_turn_idand optionalprovider_event_idstable across replays, andauthoritative_costonly for an exact documented USD amount. Existing examples:codex/app_server.rsusage_event,copilot/sdk.rsmap_assistant_usage_event,claudecode/owned/runtime.rs. - Accept it canonically. Nothing to change in
worker.rsif the harness runs sandboxed;runtime_usage_observationis provider-neutral. A host-native harness is skipped by thesandbox.enabledcheck, and removing that restriction is a product decision because host-native sessions have no runtime-host ownership record to validate. - Price it.
quote_canonical_usageinjam-usage/src/lib.rshardcodesmatches!(provider, "codex" | "claudecode")and allows only the Claude cache-write extension. A new provider staysmissing_pricinguntil this function and its tests change. - Scan its files. The provider needs an adapter in the ccusage fork, which means a fork commit, a
revbump in the rootCargo.toml,cargo update -p ccusage, andjust update-pricing. A SQLite-backed adapter will not work because the fork is built withdefault-features = false. - Reconcile its snapshots. Add the provider to
CANONICAL_SNAPSHOT_PROVIDERSonly after reviewing its category-presence semantics, as the constant's comment requires. - Scan managed homes.
managed_codex_usage_homesfiltershost.provider != "codex"and joinscodex-home, andUsageSourceConfighas onlyclaude_dirsandcodex_dirs. A sandboxed non-Codex harness needs a new home resolver and a new config field. - Normalize its provider key. Four independent functions default an empty provider to
claudecode:jam-usageprovider_key,usage_attr::normalized_provider(which also mapspullandcopilot-pull),sqlite.rsusage_provider, and the inline logic inFileStore::record_session_binding. A harness with an alias must be added consistently. - Attribute it. Attribution works when the harness persists its provider session ID in
HostSession::agent_session_idorprovider_session_id. If the provider reports a path-wrapped ID that does not end in a UUID,trailing_uuidwill not match it.
#A new harness in analytics
crates/jam-manager/src/analytics.rs agent_runtime_configuration_for matches HostTransport exhaustively into RuntimeTransport, so a new transport fails to compile until it is mapped. Mapping to an existing value (Generic) needs no contract change; adding a RuntimeTransport value changes jam-analytics/src/contract.rs, docs/analytics.md, the contract tests, and requires privacy review. agent_provider maps Peer::host strings to AgentRuntime with a catch-all Other.
#A new analytics event
Touch, in crates/jam-analytics/src/contract.rs: the AnalyticsEvent enum, EVENT_NAMES (a fixed-size array, currently 16), event_name, event_allowed, and the property arm in map_event; if the WebView emits it, FrontendAnalyticsEvent and its From conversion, followed by just bindings. Update crates/jam-analytics/tests/contract.rs (catalog, context, and the property allowlist) and the transport catalog tests in transport.rs. Document the product question and frequency in docs/analytics.md. Place the producer at the accepted mutation boundary and call crate::analytics::track (daemon) or AnalyticsState::track (desktop).
#A new discovery provider
Implement jam_contract::DiscoveryProvider: announce, withdraw, subscribe, and, if applicable, refresh_detail, withdraw_profile, poll_now with polls_on_demand, and multicast_health. Choose a source_id prefix and set SourcePresence::live truthfully: false for baseline rows that do not prove reachability. Add a branch to the JAM_DISCOVERY match in bins/jam/src/jamd.rs and, for the default, add it to the AggregateDiscovery::new vector. If it needs Human-API access for OAuth accounts, take an AccountTransport resolver the way BandDiscovery::set_account_transport does. Extend crates/jam-manager/tests/discovery.rs; null_provider_satisfies_the_same_contract is the template for provider-agnostic assertions.
#A new advertised field
Add the field to the DTO in crates/jam-domain/src/discovery.rs, fill it in Manager::assemble_presence, decide whether it is content-class (and therefore needs a sharing setting and a managed-settings override like share_room_titles_enabled), extend published_agents/agent_meta_json in discovery_band.rs if Band should carry it, and keep the TXT record in MdnsDiscovery::announce unchanged unless the field is needed for merge. Update runtime-features.md "Privacy boundary".
#A new credential kind
AuthKind in crates/jam-domain/src/credential.rs, the branch in Manager::user_transport and ready_user_transport, the boot filter in spawn_oauth_refreshers, a SecretStore key in jam-store/src/file.rs (shared with SQLite), the migration that added accounts.auth_kind (0024_account_auth_kind.sql), jam init in bins/jam/src/main.rs, and the desktop sign_in command.
#Refactor notes
The subsystem is spread through the two largest files. manager.rs is 58,145 lines and holds the usage Control implementation, the capture pipeline functions, StoreUsageArchive, the OAuth session and refresh loop, sign-in, sign-out, and deletion, account generations, analytics identity reconciliation, the discovery loop, assemble_presence, and the discoverable toggles. worker.rs (10,609 lines) holds the whole canonical ingestion and ccusage reconciliation pipeline, which is usage domain logic rather than worker glue. jam-auth/src/lib.rs (3,338 lines) combines FusionAuth sign-in, the legacy mint, device grant, loopback HTML, and Linear OAuth. discovery_band.rs is about 2,500 production lines.
Boundary exception: jam-manager depends on jam-auth. oauth.rs states that the TokenRefresher port exists "keeping jam-auth out of its dependency graph", but jam-manager/Cargo.toml lists jam-auth, and linear.rs calls jam_auth::refresh_linear_oauth and jam_auth::revoke_linear_oauth directly (plus the LinearOAuthTokens type in manager.rs). Band refresh goes through a port; Linear refresh does not.
Duplicated account conversion. jam init --auth token in bins/jam/src/main.rs and Manager::add_oauth_account each implement the identity-conflict check, fill_missing_from, issuer and client-ID overwrite, empty-key save, and rollback. The CLI writes the store directly while a daemon may be running, so the daemon does not start a refresh loop until it restarts, and two writers own the same account row.
Duplicated analytics composition. jamd.rs analytics_mode/hosted_sink and the desktop's deployment_analytics_mode/hosted_sink implement the same rules and the same FanoutSink construction.
Daemon participant analytics are always rejected. event_allowed permits ParticipantAddedToRoom only for (Full, Desktop), but the daemon client is (Daemon, Daemon) and manager.rs tracks it three times, including the only producer with actor: ParticipantKind::Agent. Those calls return InvalidEventOwner and are logged at debug level. The desktop path always sets actor: User in From<FrontendAnalyticsEvent>. No test asserts daemon delivery of this event.
Every usage read does ledger-sized work. usage_report and usage_sessions call synchronize_canonical_usage_archive, which pages every cost projection, looks up each report's reconciliation and raw row, upserts archive rows, and calls UsageSource::invalidate twice. The invalidation clears the 120-second response cache, so each read then reloads the archive tables from the store. The desktop polls these every 60 seconds per mounted query key. Inferred: cost grows with the number of canonical observations, and the TTL cache no longer protects repeated reads.
String-matched error classes. usage_ingestion_error_class maps exact error prose produced a few lines earlier to bounded classes; synchronize_canonical_usage_archive and capture_usage_from_managed_homes_with_source do the same. A reworded error silently becomes the generic class. Two classes (usage_platform_payload_invalid, usage_platform_outbox_unavailable) can no longer be produced.
Hardcoded provider in the failure ledger. usage_capture_failure always writes provider codex and source CcusageImport, whatever stage failed.
Legacy platform-delivery surface. usage_platform_deliveries, UsagePlatformDeliveryRecord, five UsageObservationRepo methods, Control::usage_delivery_failures, jam usage delivery-failures, and retry_pending_usage_delivery_for_agent remain for compatibility with no live producer.
Provider-key normalization in four places, listed under Extension points.
Canonical coverage is narrow. Live canonical ingestion covers sandboxed sessions only; canonical snapshot reconciliation covers codex only; canonical pricing covers codex and claudecode only. Host-native owned runtimes appear in reports only through the ccusage scan of their default homes.
Account auth state is memory-only. account_auth_states, oauth_sessions, and account_generations live on the manager. The durable signal for "this account is OAuth" is the presence of the refresh-token secret.
Process-global Linear lock. credential_lock is a static OnceLock map rather than manager state, so two managers in one process (tests) share it, and entries are never removed.
Analytics policy is read from disk per event. AnalyticsPolicyGate::enabled_without_lock reads and parses analytics-control.json on every capture and every enabled() call.
Keychain isolation. KeychainStore uses the fixed service com.thenvoi.jam and keys that do not include the configuration directory (user:<profile>, db:master). Inferred: with JAM_SECRET_STORE=keychain, two configuration directories that both use profile default would share the same keychain entries, including the store master key. The default file backend under <dir>/cfg/secrets does not have this problem, and jam-analytics-local avoids it by hashing the config directory into its key name.
Whole-table deletion. SqliteStore::delete_local_account_data runs DELETE FROM without a profile filter on the usage tables, the ledger, room boards, plans, activity, and artifacts. In a configuration directory with two profiles, deleting one profile's local data deletes the other's usage history.
Presence cost depends on the ccusage session cache. presence_cost_maps reads UsageSource::cached_sessions. Inferred: because every usage display read and every canonical merge calls invalidate, the cache can be empty when the 120-second cost ticker fires, and the advertised cost then drops to 0.0 until the next report read or capture repopulates it.
Contradictions between documentation and code that a refactor should resolve:
AGENTS.mdstill contains a bullet saying "the default stays--auth mint" and "do not flip the default in a PR".AuthMode::Tokenis the#[default]inbins/jam/src/main.rs, anddocs/internals/authentication.mdand the laterAGENTS.mdsection say so. The module doc at the top ofjam-auth/src/lib.rsalso still describes the mint as the flow's final step.AGENTS.mdandauthentication.mdsayjam-authlogs the mint or discovery URL, HTTP status, and response body on failure. The code logs status and elapsed time (and a transport error chain); it does not log the URL or the response body, which is used only to build the user message.authentication.mdsays to publishAccountAuthChanged"after that durable state change".AuthRequiredis held only in memory.authentication.mdsays the Tauri Rust process owns the Linear token set. After connect, the daemon stores, refreshes, rotates, and revokes it.AGENTS.mdandauthentication.mdsayJAM_CONFIG_DIRgives a separate keychain namespace. The keychain service name is fixed, and the keychain is opt-in.AGENTS.mdcalls the session ledger "append-only". It is an upsert keyed by(provider, agent_session_id)that overwrites peer and identity fields; only room bindings accumulate.AGENTS.mdsays nothing ever deletes archive rows.delete_local_account_datadeletes all of them.- The comment in
UsageSource::merge_canonical_archivesays a display request "must still perform its ordinary live provider scan". With an archive configured, display requests never scan. runtime-features.md"Sonar discovery" describes multicast DNS only. The default provider is the mDNS plus Band aggregate. Its privacy list omits room titles and cost, both shared by default, and theDiscoveredTeam::titledoc comment says the title is "deliberately blank in v1".