07 · Access, approvals, audit, retention and metrics
The Hub assistant, assigned agents, external engines and the grant-scoped Context Bridge have different permissions; no single policy table governs all four.
1. Identity & access
Browser sign-in uses hashed session cookies, a password or configured Google/GitHub/Microsoft login, and separate verification and recovery tokens. Signing in with a provider does not grant connector access. Hosted model, connector and agent operations require a verified email. Host credentials and filesystem execution are operator-only (lib/hosted-access.ts); everyone else pairs their own computer.
A space scopes content by userId; the actor is accountId. Sharing adds route and resource checks in sharing/policy.ts, gate.ts and context.ts; unclassified routes stay owner-only. A viewer or editor never uses the owner's private credentials, reminders or account settings (28, 29).
Account export and deletion use lib/account-data.ts. Deletion includes owned spaces and checks for active work, including a run on the person's computer in a shared space. Account-lifetime foreign keys stop late writes from recreating erased rows. Third-party copies and infrastructure backups have their own lifetimes.
Account-scoped ActivePlace leases enforce separate simultaneous-place limits for Ensemble and Node independently of sharing presence (02 §16). Each pool admits three; one pool's replacement cannot affect the other. Leases are excluded from account exports and cascade away with their session/account. Guarded record saves compare _base under a row lock and preserve conflicting values for the person's choice (02 §5).
External MCP clients use a static key or hosted OAuth (Context Bridge §4.1). mcp_oauth_clients holds registered app metadata and hashed client secrets; mcp_oauth_requests holds browser-bound consent and hashed codes; mcp_oauth_grants binds an account, owned default space, client, resource and scopes; access keys in api_tokens and hashed refresh tokens reference that grant. Every access checks expiry, revocation, resource and current ownership. Code/refresh claims are atomic. Known refresh replay invalidates the entire grant, and disconnect/self-logout cannot leave a live refresh family.
Rotation is the default and mandatory for public/CIMD clients. A confidential client can explicitly register persistent linking for a vendor that requires it: secret authentication on every refresh, stable credential, extended 30-day expiry and no scope restoration after narrowing. Revocation and ownership checks are unchanged.
OAuth connection metadata belongs to the authorizing account, including when its default space differs. Export returns app/grant metadata without authentication tables or secrets. Workspace erasure keeps connection configuration; account/default-space deletion cascades through grants and tokens. Consent mutations need the current browser session, matching web origin and CSRF proof. Provider sign-in carries its MCP return in a signed, flow-bound cookie, not an arbitrary redirect.
1.1 Completed and deleted data retention
jobs/retention.ts and jobs/skill-mining.ts apply these windows:
| Setting / data | Default and behaviour |
|---|---|
completedRetentionDays |
60 days (1–365). Once at least 10 unpinned completed items are past it, they get 24 hours' notice and a skill-learning pass (04), then move to Trash; with learning off they move without one. The agent sessions of tasks past it are deleted from the person's agents (30, Cleaning up sessions) |
retentionDays |
30 days (1–365). Trash is purged permanently after this |
historyRetentionDays |
90 days (30–365). Audit ledger, task status history, and how runs chose their agent sessions (session_choices, 30, Choosing a session) |
| Remote log chunks and changed job files | Fixed 30 days (Ensemble on the web keeps no changed-file text, 25 §4.6) |
The AI activity log (ai_activity, §1.4) |
Fixed 30 days (AI_ACTIVITY_RETENTION_DAYS) |
| MCP OAuth credentials | Consent/code requests: 10 minutes. Access keys: one hour. Refresh tokens: 30 days, rotation by default; confidential persistent linking extends expiry on refresh (Context Bridge §4.1). Daily purgeMcpOAuth removes expired consent, refresh/access rows 30 days past expiry and grants 30 days after revocation; account deletion removes them immediately |
| Blobs nothing uses (file originals, plot tables) | Removed hourly once an hour old (jobs/blob-sweep.ts); the storage account's own soft delete keeps them 7 more days |
| Code reviews | Expire after 14 days; their snapshots are unpinned |
Pinning, reopening, active jobs, a learning pass waiting for its model or a conflicting skill change keep completed items. A batch holds at most 50 tasks and 50 deliverables. Completion summaries created at retirement expire with the Trash window. The board's completed count and Context's review window are display choices, not retention.
/api/deleted lists, restores and empties Trash in the current space. Standalone notes keep their document and discussions until purge; task pages follow their task. Retention never removes workspace checkouts. People, skills, files and projects do not expire from inactivity.
1.2 Private reminders and platform notifications
dispatchDueReminders turns due reminders into in-app notifications. In-app notifications are unread items, not a log: the bell lists only unread ones (02 §14) and retention removes old rows. Browser notifications need an open tab and permission; native ones need the desktop app running and system permission. Quiet hours defer ordinary popups; Needs me can notify urgently. Nothing alarms while Ensemble is closed. Private reminders are excluded from shared context, and from the editor bridge's briefs and search; an editor's key reads and changes them only through the reminder actions (CONTEXT_BRIDGE §5.1).
1.3 The operator's interaction log and dashboards
Ensemble on the web logs how signed-in people use the Hub, for the operator's own analysis (disclosed in the landing page's privacy policy, apps/landing/app/privacy/page.tsx). Never content.
- Browser (
apps/hub-web/lib/telemetry.ts, mounted inapp/(hub)/layout.tsx): one random key per tab; asessionevent when the Hub opens; aviewfor each screen with its active time (counted in 5 s ticks only while the tab is visible and there was input in the last 60 s,IDLE_MS; under 1 s is dropped); namedactions throughtrack()/useTrackOpen()(palette.open,shortcuts.open,capture.open,nav.drawer.open,pages.panel.open,assistant.open,peek.open). Sent every 30 s and withsendBeaconwhen the tab hides or closes; a failed send is kept (200 events at most). - Server (
apps/hub-api/src/analytics/telemetry.ts,POST /api/telemetry, classpersonal): only a signed-in browser session (no API tokens, no visitors on a shared item or link). At most 50 events, 64 KB; events older than a day or more than 5 minutes ahead are dropped. A path keeps its known shape only (/pages/:id; an unknown first segment becomes/:other), its area comes from the first segment, a view counts at most 30 minutes, and props keep only short identifier-like words, numbers and booleans. Stored ininteraction_events(migration20261021120000_interaction_events), deleted with the account or "Delete everything", included in the export, and purged daily afterENSEMBLE_INTERACTION_RETENTION_DAYS(default 730). - What the server already recorded joins it: the audit ledger's actions by
me,metric_eventsmodel calls and assistant turns, andincluded_usage. - The person's own weekly recap (
GET /api/recap/week,weeklyRecapinapps/hub-api/src/cowork/service.ts) reads the signed-in account's ownviewrows, never the space owner's or another member's, for "Active in Ensemble": total active minutes, minutes per weekday and hour in the person's time zone (a view counts from its start, split at hour boundaries), and the top five areas. Rows carry no space, so the figure covers every space the person opened. It is screen time, not hours worked. - Metrics (
apps/hub-api/src/analytics/prometheus.ts): withENSEMBLE_METRICS_PORTset, a separate listener on127.0.0.1serves/metrics:ensemble_http_requests_total{route,method,status,via}(via="vercel"when Vercel's rewrite forwarded it, from itsx-vercel-*headers),ensemble_http_request_duration_seconds,ensemble_live_streams,ensemble_live_channels, and Node's process metrics (ensemble_api_*). The public API never serves it. - Dashboards (
infra/deploy/home/observability, home server only): Prometheus (kept 730 days), Grafana, node-exporter (CPU, memory, disk, network, temperatures, RAPL power includingpsys) and postgres-exporter, all on loopback, plusazure-metrics.pyevery 6 hours (Azure Cost Management month to date, last month and a linear month-end estimate for every enabled subscription; blob counts and bytes by container and prefix). Grafana reads the database asensemble_grafana, which may read only theanalyticsviews (analytics.sql): activity, usage, sizes and kinds, never content, password hashes or tokens. Active there means any action, opening the Hub, or a screen used for at least 10 s (analytics.engagement). Dashboards are generated bybuild-dashboards.mjs: Overview, Users, Activity, LLMs, Cost & storage, Traffic, Server.telemetry.integration.test.tsapplies the views to the test database so a migration that breaks them fails CI; on the server,update.shdrops them before migrating and recreates them after.
1.4 The AI activity log
lib/ai-activity.ts keeps what each person's AI work sent and got back, for 30 days, so the operator can look into how Ensemble served them and build features on it (disclosed in the privacy policy). It is always on; ENSEMBLE_AI_ACTIVITY=off turns it off for a deployment, and this repository's own tests run without it unless a test turns it on. One ai_activity row per entry (migration 20261127100000_ai_activity), on the account of the person whose work it was (keysFor, like metrics: work in a space shared with you is on your account):
kind |
What it holds | Where it is logged |
|---|---|---|
model.call |
The messages sent (a turn's later steps log only the messages added since the step before, newMessages, with from), the answer with its tool calls and reasoning, the model, provider or AI plan's agent (app), tokens, credits, duration, and the error with its stack when it failed |
askRuntime and askRuntimeStream (lib/model-turn.ts: chats, Ask Ensemble, routines, Ensemble Node, summaries, web tasks), complete (lib/runtime.ts: triage, briefs, meeting and document automations), runSubscriptionTurn (subscriptions/turn.ts: Ensemble's instructions, the prompt and the files' names, the reply, the computer and session), and an agent task's prompt and answer (workspace/agent.ts) |
tool.call |
One of Ensemble's tools run for a model or agent: its input, what the model was told, its state (ok, failed, waiting for Apply) and duration | runOne (assistant/agent.ts), for API models and for an AI plan's agent alike |
agent.tool |
The agent's own tool calls: a chat's shell, files and web (OwnToolLog, subscriptions/own-tools.ts, read from each agent's own output: Claude Code's stream, Codex's items, Copilot's tool.execution_* events, ACP tool_call updates), the hard floor's refusals with why (/api/engine/assistant/ask), and every tool call of an agent task with fuller input and output than its run step (EngineTrace, workspace/engines/trace.ts; a call that read a secrets file keeps no output) |
On the computer that runs the agent |
error |
A failed agent task's error with its stack | workspace/agent.ts |
server.log |
Ensemble on the web's own warnings and errors (pino's logMethod hook, app.ts), with their stack, for the account whose request (or work naming its userId) logged them |
captureLogLine |
runner.log |
A computer's warnings and errors, all its owner's | captureLogLine on the computer |
Each entry also has purpose ("Hub chat", "Ask Ensemble", "Document summary", "Triage"…), refs (what it belongs to: conversation, message, session, page, task, project, meeting, file, job, run, turn, device) and meta. Callers set the purpose and refs once for a piece of work (withAiContext: an assistant turn, a file's summary, an automation's complete); a call that comes back as another request (a relayed tool call, an agent's hook) keeps its turn's (bindAiContext). Before anything is queued, keys and known secret shapes are removed (redactText, fields named like keys or passwords), a picture or file sent inline becomes its type and size, a string keeps 16,000 characters and an input or output 64,000, a stack 16,000. The same server or computer log line again within a minute is counted on the next one kept (meta.repeated), and at most 300 log lines a minute are kept per process.
Writing never slows or fails the work: entries queue in memory and go to the database in batches each second (2,000 at most queued; an account deleted meanwhile only loses its own rows). A paired computer sends its entries every few seconds to POST /api/devices/self/activity (25 §4.1) instead, with its runs' web ids; the web stores them on the account the computer is paired with, keeps a run only when it is that account's, adds its task and the computer, and cuts and cleans them again. A server without the route (older) or a computer that works only locally keeps them in its own database. Retention deletes entries older than 30 days each day (deleteOldAiActivity, §1.1); they are included in the account export and removed by Delete context, Delete everything and account deletion (§6).
2. Action policy (the gates)
| Path | Control |
|---|---|
| Hub assistant local writes | Per-tool classification, allowed write areas, module and sharing checks. Default write policy "Just do it": Hub writes run at once and are undoable; connected-app writes, settings and agent assignments always wait for Apply |
| Sensitive assistant actions | Calendar/app writes, dependent workflows, skills and settings changes go through their tool's Apply path |
| Connected MCP writes | Server/tool enablement, schema validation and confirmation; read-only annotations affect classification but grant nothing |
| Assigned research/code jobs | Saved run profile, tools, folder trust, Ask / Review only / Unattended mode, sandbox, network and Git permissions, budgets |
| Publishing | The exact saved approval and payload, with continuation checks |
| Human terminal | Passkey unlock and its own rules; not an assistant tool (16 §5) |
| Context Bridge | Read/write/app grants, owned-space scope, static bearer or OAuth access keys, bounded retrieval; named catalog and legacy profiles use the same API checks |
assistant/apply.ts validates approved proposals; an edited proposal is revalidated. Tools routed through assistant/request.ts reuse API authentication, quotas and resource checks. A guest never gains the owner's connections through a workflow.
workspace/policy.ts, guard.ts, trust.ts, git-gate.ts, engine adapters and the OS sandbox enforce assigned-run rules (24). Review only means fewer prompts, not read-only file access. Unattended picks recommended answers and reports them; it grants no new filesystem rights. There is no undo window for sends or other external writes.
3. Audit ledger
lib/ledger.ts appends to audit_ledger in one serialized per-user transaction: actor, action, task/run/approval links, payload, previous hash and SHA-256 hash, with a monotonic timestamp. Shared-space actions add payload.byAccountId; metrics go to the actor's own account.
POST /api/ledger/verify checks only that each row's prevHash matches the previous row's hash in timestamp order; it does not recompute payload hashes.
Limits: this is application-written, hash-linked history. There is no verify-ledger or db:harden command, INSERT-only database role, OpenTelemetry export or immutable audit store. Configured retention and account deletion delete history. It is not compliance-grade immutable storage.
4. Reliability and dependent actions
Revision/hash/version preconditions protect page, file and skill edits. Scoped task/source IDs deduplicate imports. Run checkpoints avoid repeating completed work when a decision resumes. None of this is exactly-once delivery for every network call.
assistant_workflows stores ordered action receipts scoped by space, actor, conversation and call ID. hub_run_workflow validates the approved plan, resolves only backward references and rechecks permissions and settings at each step. Successful steps are not replayed after a later failure; an interrupted Running step needs inspection, not automatic replay (34).
Health and readiness report API and dependency state; the scheduler records its tick. There is no production canary, dead-letter screen or automatic mid-command crash recovery.
5. Metrics and included AI
lib/metrics.ts records MetricEvent; GET /api/metrics/summary (routes/runs.ts) serves aggregates. Metrics belong to the person, including work in a shared space. The UI shows AI calls over 14/30/90 days with provider filters applied before every aggregate: reported tokens, daily activity, models, purposes and recent calls. Calls without token reports still count, and missing counts or prices show as missing. Local models and subscriptions report only what responses to Ensemble-routed work contained; Copilot assistant turns read per-call assistant.usage events from the CLI process Ensemble started, and context-window snapshots or vendor billing units never become token or cost estimates.
Included AI is separate: included/budget.ts reserves and settles each account's usage and the operator's global ceiling. included_usage keeps call metadata and token counts, not prompts or replies, until workspace erasure or account deletion; erasure scrubs call details and keeps minimal settlement records. included_credit_balances enforces the allowance by an email-derived HMAC key, month and charged amount, survives account deletion and re-signup with the same email, and is removed by the scheduler two days after its month ends. included_periods keeps aggregate operator spend (33).
6. Data erasure
Hosted account deletion is in Settings → Data → Danger zone, not Account. Its button opens a confirmation with the exact DELETE phrase and password, or the recent-sign-in requirement for social accounts. It retains the API's active-run checks. Metrics' general reporting explanation is available from About usage rather than a persistent paragraph.
Settings → Data offers erasure scopes through lib/workspace-data.ts, driven by current Prisma metadata. Context (people and app information) also removes the AI activity log (§1.4), which holds that context in its prompts. Everything removes workspace content, metrics, baselines, the AI activity log, audit history and plot dataset files, and keeps authentication, devices, connector and model configuration, settings and monthly credit consumption; the ledger keeps only a content-free deletion receipt. Deletion and receipt commit together under the shared FOR NO KEY UPDATE user lock. The web client sends the request straight to the API, not through the web rewrite proxy. Active jobs block erasure with a 409. The Danger zone asks for a scope, the typed phrase delete everything or delete people and app information (paste and drop disabled), then a confirmation dialog; the server checks the exact phrase. Account deletion is a separate, broader action.
GET /api/metrics/summary first runs repairErasedUsage: once per Everything receipt (tracked in the hub.usage-erasure-repair preference) it preserves monthly credit charges, then removes metric events and baselines at or before that receipt. Later usage stays.
account_deletion_logs keeps only an HMAC-derived email key, deletion time and expiry, with no readable email, profile, feedback or account link. The same email overwrites the earlier entry and may sign up again. Entries expire after 90 days; the scheduler purges them even when no accounts remain. After restoring a backup or with the scheduler off, the operator must check this cleanup. pnpm --filter @ensemble/hub-api deletion-log:erase removes one entry, reading the address from stdin without printing it. The public privacy policy discloses the log. This guide describes code behaviour, not legal compliance.
Google data disclosures
The public Google data and Limited Use disclosure and terms are rendered by apps/landing/app/privacy/page.tsx and terms/page.tsx. They distinguish encrypted OAuth credentials from saved/imported content, calendar refresh from manual contact sync, selected AI-provider transfers from model training, and disconnect from workspace/account deletion. components/connectors/connector-detail.tsx links that disclosure in the Google connector before consent and while connected. These pages describe the hosted service and its policy commitments; they are not a certification of Google approval, universal provider zero retention, end-to-end content encryption or a fixed cross-provider deletion deadline.
7. Tests
API auth, isolation and public-signup suites send real Fastify requests to an isolated migrated database with mocked providers; Postgres/Redis CI covers integration and constraints; OS tests exercise sandbox boundaries. They do not prove live notifications, calendar writes or provider billing. There is no eval/ corpus or productivity baseline in the repo.