diff --git a/apps/console/docs/error-tracking.md b/apps/console/docs/error-tracking.md
index ffde6f0c6..b7ee1dedd 100644
--- a/apps/console/docs/error-tracking.md
+++ b/apps/console/docs/error-tracking.md
@@ -1,164 +1,189 @@
-# Error Tracking Integration Guide
+# Error Tracking (Console)
-This guide explains how to integrate error tracking (Sentry or equivalent) into the ObjectUI Console for production deployments.
+Sentry error reporting is **already built into the Console**. There is nothing to
+install and no init code to write — this guide is about *turning it on*, which takes
+two independent opt-ins, one at build time and one at runtime.
-## Option 1: Sentry (Recommended)
+> ⛔ **Do not add your own `Sentry.init()` / `src/lib/sentry.ts` to the Console.** A
+> second init is not gated by anything below, so it would report regardless of what the
+> deployment permits — rebuilding the exact "decision frozen at build time, no operator
+> switch" shape that objectui#5522 existed to remove. The integration below is the
+> supported path; extend it, don't duplicate it.
-### Installation
+## The gate: reporting needs BOTH halves
-```bash
-pnpm add @sentry/react --filter @object-ui/console
+```
+send ⇔ a DSN was injected at BUILD time ∧ the RUNTIME granted permission
```
-### Configuration
-
-Create `src/lib/sentry.ts`:
-
-```typescript
-import * as Sentry from '@sentry/react';
-
-export function initErrorTracking() {
- if (import.meta.env.PROD) {
- Sentry.init({
- dsn: import.meta.env.VITE_SENTRY_DSN,
- environment: import.meta.env.VITE_ENVIRONMENT || 'production',
- release: import.meta.env.VITE_APP_VERSION || '1.0.0',
-
- // Performance monitoring
- tracesSampleRate: 0.1, // 10% of transactions
- replaysSessionSampleRate: 0.01, // 1% of sessions
- replaysOnErrorSampleRate: 1.0, // 100% of error sessions
-
- // Filter out noise
- ignoreErrors: [
- 'ResizeObserver loop limit exceeded',
- 'ResizeObserver loop completed with undelivered notifications',
- 'Non-Error promise rejection captured',
- ],
-
- // Scrub sensitive data
- beforeSend(event) {
- if (event.request?.headers) {
- delete event.request.headers['Authorization'];
- }
- return event;
- },
- });
- }
-}
+Both are opt-in, and **either one alone denies**. This is what lets one artifact serve
+every posture: `@object-ui/console` publishes a single pre-built SPA that the hosted SaaS
+console and the on-premises / air-gapped EE images all embed, so the bundle cannot tell
+those deployments apart — only the server can.
+
+The decision lives in one place, `resolveSentryGate()` in
+`packages/app-shell/src/observability/sentry.ts`, and is pinned case by case in
+`sentry.test.ts`.
+
+### Half 1 — build time (the build environment of the Console)
+
+Set these in your **deploy environment** (hosting panel / CI), the same way you already
+inject `VITE_SERVER_URL`. The authoritative list is the comment block in
+`apps/console/.env.production` — mirror it, don't invent knobs.
+
+| Variable | Effect |
+|:--|:--|
+| `VITE_SENTRY_DSN` | **Required.** Presence *is* the build-time opt-in — there is no separate "enable" flag. Absent ⇒ `initSentry()` returns `false` and `@sentry/react` is never even imported, so the vendor-sentry chunk is never fetched. |
+| `VITE_SENTRY_SEND_DEFAULT_PII` | `=true` opts in to sending **IP address + User-Agent**. Off by default: one artifact serves both SaaS and on-prem, so PII collection must be the deliberate choice of the build that wants it. |
+| `VITE_SENTRY_ENABLED` | `=false` force-disables reporting even when a DSN was injected — for a pipeline that keeps the DSN in its environment but wants reporting stopped. |
+| `VITE_SENTRY_ENVIRONMENT` | Defaults to Vite's `MODE`. |
+| `VITE_SENTRY_RELEASE` | Defaults to `VITE_APP_VERSION`, then `unknown`. CI typically injects the commit SHA. |
+| `VITE_SENTRY_TRACES_SAMPLE_RATE` | Defaults to `0.1`. |
+| `VITE_SENTRY_REPLAY` | `=true` records 10% of **error** sessions. Session replay is otherwise off. |
+
+⛔ **Never commit a DSN** — not to `.env.production`, not as an "example". Vite inlines
+every `VITE_*` from a committed `.env` file into the published bundle as a frozen object
+literal, so a committed DSN is a live third-party endpoint compiled into an artifact that
+lands inside customer networks, and it cannot be switched off afterwards (the
+`VITE_SENTRY_ENABLED` kill switch is read off that same frozen literal). That is not
+hypothetical: an air-gapped EE deployment was measured sending 14 Sentry envelopes per
+session carrying IP + User-Agent PII, with no way for the customer to stop it
+(objectstack-ai/cloud#1508, objectui#5522). The ratchet
+`packages/app-shell/src/observability/committed-telemetry-endpoint.test.ts` fails CI if
+any committed `.env*` file carries a telemetry endpoint or turns PII on by default.
+
+A DSN looks like `https://your-key@your-org.ingest.sentry.io/your-project-id`.
+
+### Half 2 — runtime (each production server)
+
+The server grants permission through `telemetry.allowClientErrorReporting` on
+`GET /api/v1/runtime/config`. Set it on the **ObjectStack runtime**, not the Console
+build:
+
+```bash
+OS_TELEMETRY_CLIENT_ERROR_REPORTING_ENABLED=true
```
-### Integration in `main.tsx`
-
-```typescript
-import { initErrorTracking } from './lib/sentry';
-
-// Initialize error tracking before React renders
-initErrorTracking();
-
-// Wrap your app with Sentry error boundary
-import * as Sentry from '@sentry/react';
-
-const SentryErrorBoundary = Sentry.withErrorBoundary(App, {
- fallback: ({ error }) => (
-
-
-
Something went wrong
-
{error?.message}
-
-
-
- ),
-});
+…or, from a host that composes the plugin directly:
+
+```ts
+new RuntimeConfigPlugin({ allowClientErrorReporting: true })
```
-### Environment Variables
+The explicit plugin option wins over the environment variable. The switch answers to a
+**closed** vocabulary — `1` / `true` / `on` / `yes` grant, `0` / `false` / `off` / `no`
+deny — and an **unrecognised spelling is refused, never coerced**: the permission stays
+denied and `RuntimeConfigPlugin` names the rejected value in a warning at mount time. So
+`=enable` or `=Y` does not quietly half-work; check your server's startup log if
+reporting stays silent.
+
+⚠️ **`OS_CLOUD_URL=off` overrules a grant.** A runtime that declared its control plane
+off has declined outbound calls, and this one with it — the permission is lowered to
+`false` and the refusal is warned about at mount. This is the copied-env-file shape
+cloud#1508 reported: a hosted configuration landing on an air-gapped box.
+
+Canonical row for this variable:
+[Environment Variables → Observability](https://objectstack.ai/docs/deployment/environment-variables)
+(`content/docs/deployment/environment-variables.mdx` in the objectstack repo).
+
+## Fail-closed contract
+
+**Either half missing ⇒ no reporting, silently, by design.** Every "cannot determine the
+answer" state — the config fetch failed, the endpoint 404s, the runtime predates the key,
+a third-party host, the config has not arrived yet — reads as **denied**. An unreported
+error is recoverable; PII leaving an air-gapped deployment is not. Silence is therefore
+the *correct* behaviour, not a bug to work around: if you want reporting, supply both
+halves rather than loosening the gate.
+
+Note the direction differs from `isMarketplaceEnabled()` / `isAiStudioEnabled()`, which
+fail **open**. Do not "make it consistent" with them.
+
+### Ordering, if you embed app-shell in your own host
+
+`initSentry()` must run **after** `initRuntimeConfig()` has settled — the runtime
+permission is a server-pushed value that reads denied until the payload arrives, and
+`initSentry()` memoizes its verdict on first call. Calling it at module-eval time freezes
+`denied` for the whole session, turning the operator switch into a permanent removal.
+`apps/console/src/main.tsx` does this correctly: `initSentry()` is kicked off inside
+`.finally()` on the boot `Promise.all`, so a failed config fetch still never blocks first
+paint (and on that path the permission is denied, so the failure direction is silence).
+
+## Reporting errors from your own code
-Add to your deployment environment:
+Use the built-in helpers from `@object-ui/app-shell` — they route through the same gate
+and no-op when it denied, so they cannot become a second ungated path:
-```env
-VITE_SENTRY_DSN=https://your-key@sentry.io/your-project-id
-VITE_ENVIRONMENT=production
-VITE_APP_VERSION=1.0.0
+```ts
+import { captureError, setSentryUser } from '@object-ui/app-shell';
+
+captureError(err, { where: 'record-save' }); // no-op unless the gate passed
+setSentryUser({ id: user.id }); // pass null on logout
```
-### Source Maps (Optional)
+`packages/app-shell/src/chrome/ErrorBoundary.tsx` already calls `captureError()` with the
+React component stack, so uncaught render errors are covered without any wiring.
+
+## Verifying a deployment
+
+1. **Check the runtime half** — it is the half you can inspect from outside:
+
+ ```bash
+ curl -s https://your-deployment.example.com/api/v1/runtime/config | jq .telemetry
+ # → { "allowClientErrorReporting": true }
+ ```
+
+ `false` (or an absent `telemetry` block) means the server is denying; fix that before
+ looking at the build.
+
+2. **Check the build half** — in the browser devtools **Network** tab, confirm the
+ `vendor-sentry` chunk is fetched on load. If it never appears, the bundle carries no
+ DSN (or `VITE_SENTRY_ENABLED=false`), and no runtime grant can rescue it: the server
+ supplies a *permission*, never a source.
-For readable stack traces in production, upload source maps during CI:
+3. **Trigger a test error** — in the browser console, `throw new Error('Test error')`.
+
+4. **Confirm it lands** in your Sentry project, tagged with the expected `environment`
+ and `release`.
+
+## Source maps
+
+The Console build sets `sourcemap: false` (`apps/console/vite.config.ts`). For readable
+stack traces, enable source maps in CI only, upload them, then discard them rather than
+publishing them with the bundle:
```yaml
-# In your CI/CD pipeline
- name: Upload Source Maps
run: |
npx @sentry/cli sourcemaps upload \
--auth-token $SENTRY_AUTH_TOKEN \
--org your-org \
--project objectui-console \
- --release $APP_VERSION \
+ --release $VITE_SENTRY_RELEASE \
apps/console/dist/assets/
```
-> **Note:** The console build has `sourcemap: false` by default. To generate source maps for Sentry only, temporarily enable them in CI and upload before deleting.
-
-## Option 2: Custom Error Boundary
-
-If you prefer a lightweight solution without a third-party service, use React's built-in error boundary with a custom reporter:
-
-```typescript
-// src/lib/error-reporter.ts
-export function reportError(error: Error, context?: Record) {
- const payload = {
- message: error.message,
- stack: error.stack,
- context,
- timestamp: new Date().toISOString(),
- url: window.location.href,
- userAgent: navigator.userAgent,
- };
-
- // Send to your error tracking endpoint
- if (import.meta.env.VITE_ERROR_ENDPOINT) {
- fetch(import.meta.env.VITE_ERROR_ENDPOINT, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
- }).catch(() => {
- // Silently fail — don't create error loops
- });
- }
-}
-
-// Catch unhandled errors
-window.addEventListener('error', (event) => {
- reportError(event.error || new Error(event.message));
-});
-
-window.addEventListener('unhandledrejection', (event) => {
- reportError(
- event.reason instanceof Error
- ? event.reason
- : new Error(String(event.reason))
- );
-});
-```
+Keep `--release` identical to the `VITE_SENTRY_RELEASE` the bundle was built with, or the
+uploaded maps will not match the events.
+
+## Content Security Policy
-## CSP Compatibility
+The Console ships **no CSP meta tag** today — `apps/console/index.html` sets none, and the
+repo defines no default policy. Nothing in the Console needs relaxing for Sentry out of
+the box.
-The console includes a Content Security Policy (CSP) meta tag. If your error tracking service requires additional domains, update the CSP in `index.html`:
+If **your hosting layer** serves CSP headers (many do), Sentry's ingest endpoint has to be
+reachable or events are dropped silently by the browser:
-```html
-
-
+```
+connect-src 'self' https://*.ingest.sentry.io;
```
-The default CSP already includes `https://*.sentry.io` in the `connect-src` directive.
+Match the origin to your own DSN — `*.ingest.sentry.io` for current Sentry SaaS projects,
+your own host for a self-hosted Sentry.
-## Verifying the Integration
+## Related
-1. **Build the console:** `pnpm --filter @object-ui/console build`
-2. **Preview:** `pnpm --filter @object-ui/console preview`
-3. **Trigger a test error:** Open the browser console and run `throw new Error('Test error')`
-4. **Check your dashboard:** Verify the error appears in Sentry / your tracking endpoint
+- `packages/app-shell/src/observability/sentry.ts` — the gate and its rationale
+- `packages/app-shell/src/runtime-config.ts` — `isClientErrorReportingAllowed()`
+- `apps/console/.env.production` — authoritative build-time variable list
+- objectui#5522 · objectstack#10805 · objectstack-ai/cloud#1508