> ## Documentation Index
> Fetch the complete documentation index at: https://oxy.tech/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Instant-Loading Custom Apps, Per-Org Transactional Postgres, Fleet-Wide Pre-Aggregation, and Schedule Variables

> Custom apps load instantly, know who is viewing them, and can now write transactional data.

<Icon icon="calendar" iconType="regular" size={16} /> **August 24, 2026** · v0.5.130, v0.5.131

### New Features

#### Custom Apps That Load Instantly

Serving a byte was already cheap; what a person actually waited on was the round trips a browser spends *discovering* which bytes it needs, and a repeat visit re-fetching all of them. Both are gone:

* **Entry chunks start before the HTML finishes** - Every build now ships an asset manifest, written by `oxy publish` and `oxy seed`, that drives `preload` / `modulepreload` hints on the shell. The hints ride the `304` too, which has no body to carry `<link>` tags — so an unchanged page still gets them.
* **A repeat load is zero-network on the critical path** - A platform service worker precaches each build's entry assets, keyed by build id, with a separate build-agnostic cache for chunks discovered later. Promote and roll back stay correct: the worker re-syncs off the build the server names.
* **A new build never swaps out from under a running page** - The worker deliberately waits for open tabs to close rather than claiming them mid-session. Nothing is lost by waiting — navigations are network-first, so a tab is already up to date.
* **Open an app beside HQ** - Cards on the home launcher can open an app in a right-hand pane. Focus mode hides the main content and top bar while keeping the rail; the dock closes on navigation.

#### Usage Analytics On by Default

Custom apps now report how they are used without any bundle changes:

* **Pageviews, Core Web Vitals, engagement, and error counts** - Collected client-side and surfaced in the app's Activity tab, so "is this app fast for real users" is a number rather than an impression.
* **Opt out per build** - Set it in `oxy-app.json` if an app should not be instrumented.

<Warning>
  **Wire-contract change.** `POST /api/customer-apps/<project>/events` now rejects any
  `event_name` beginning with `oxy-` — the prefix is reserved for platform-measured
  metrics, so an app can't move its own performance numbers. An app already emitting an
  `oxy-` prefixed event will start receiving `400`s; rename those events before upgrading.
</Warning>

#### Verified Viewer Identity for Oxy Functions

An Oxy Function could read an id, an email and a per-app role off `ctx.user` — enough to gate on, not enough to greet, label, or tell a person apart from a scheduled run. Apps were compensating by passing display identity up from the browser, which is forgeable and defeats the point of reading identity from `ctx` at all.

* **`ctx.user` gains `name`, `picture`, `orgRole`, `teams`, and `kind`** - `appRole` remains the only field to gate on; `orgRole` and `teams` are facts for explaining and shaping a view. `kind` (`"user"` or `"system"`) replaces the email-sniffing heuristic a schedule used to require — note that a scheduled run executes under the org owner, so it still reads `appRole: "admin"` and the human fields are simply absent.
* **`teams` is scoped to the caller's org** - A consultant who belongs to several orgs sees only the team names of the org the app lives in, and someone removed from an org gets neither role nor teams.
* **Who visited, in what capacity** - View events now snapshot the app role and org role at view time, surfaced as a Role column in the admin Activity tab. The snapshot is deliberate: a role joined at read time would show today's role against last quarter's activity.

<Warning>
  **`ctx.user.orgId` was reading `undefined`.** The host serialized the field as `org_id`
  while the SDK types and the docs both said `orgId`. It now serializes correctly, with the
  old key mirrored so existing functions keep working. **Any function filtering SQL on the
  org id should be re-checked** — it was comparing against `undefined`.
</Warning>

#### External Health Checks for Published Custom Apps

There was no way to ask "is this custom app alive?" from outside, and the obvious answer failed in the worst direction: a custom-app hostname answers `200` with the app shell for every path — including a hostname with no app behind it — so a monitor pointed at a bare `/health` would be green forever.

* **A real endpoint** - `GET /api/customer-apps/{org}/{app}/health` is pollable from any host, so one monitor covers every app. A companion `GET /api/customer-apps/health` resolves the app from the request host for the subdomain form.
* **A body that survives a sloppy matcher** - The verdict key is `oxy_app_health` with values `pass` / `fail`, deliberately not `healthy` / `unhealthy` — `"unhealthy"` contains `"healthy"`, so a `Contains` matcher would report a broken app as fine.
* **Checks the published channel, in order** - Registration, publication, build record, source config, and bundle entry point, each reported separately with `skipped` as a real third answer rather than being folded into a pass or a fail.
* **`200` means pass; every other status means fail** - Auth and lookup outcomes carry `"oxy_app_health":"fail"` too, so status-matching and body-matching monitors always agree. Responses are never cached.

#### Google Sheets as an Airway Source

Pull a spreadsheet into a pipeline like any other source:

* **Credentials that survive a schedule** - The managed secret is a Google service-account JSON key, not an access token, and a fresh token is minted per run. A stored token would make a scheduled pipeline succeed exactly once and then fail forever.
* **Read-only by design** - Access is fixed to read-only spreadsheet scope and is not configurable.
* **Clear failures** - A missing secret names the field to fix rather than surfacing an opaque error from Google.

```yaml theme={null}
source:
  kind: google_sheets
  config:
    spreadsheet_id: <from the sheet URL>
    service_account_json_var: GOOGLE_SA_JSON
    ranges:
      - "Main!A:S"
resources:
  - main
```

#### Airhouse Fleet Console

Answering "does this workspace have a warehouse, and is it actually usable?" meant a database session, and provisioning one meant a CLI call from a box holding the credentials. `/admin/airhouse` now covers both, one row per workspace:

* **The silent failure it exists to catch** - A tenant whose row exists but whose service account was never bound looks fully provisioned from every angle and fails on the first query the workspace runs. That state is now its own column.
* **Workspaces without a warehouse are listed too** - "Who still needs one" is usually the question an operator arrives with, and a list of existing tenants can't answer it. Every provisioned workspace is always shown in full; only the "no warehouse" list can be truncated, and it says so.
* **Diagnostics without a second query** - Rows expand in place to show the service account id, its creation and rotation dates, and the account's role and lifetime ceilings — a row that doesn't match the rest of the fleet was provisioned under an older policy.
* **"Never rotated" now means something** - Rotation age is read against the account's own age, so a tenant provisioned this morning reads differently from one provisioned two years ago. It's kept out of the severity chips deliberately: a stale-but-working credential can still serve a query.
* **Provisioning from the page** - Idempotent, so a double-click or a retry converges on one tenant, and confirmed by a dialog naming the workspace, because provisioning the wrong one can't be undone. Every provision is recorded in the audit log.

#### Viewer Identity in the Starter App

The seeded starter bundle gains a fourth pattern: reading who is viewing it, on the same session that authorized the page, with no build step and still one HTML file. It also teaches the distinction that matters — the browser-side context carries **display** identity and deliberately no role, because a bundle is JavaScript the viewer can edit; the verified half lives on `ctx.user` inside a Function, where the caller can't reach it.

#### Per-Org Transactional Postgres

Oxy could read a warehouse and write derived rows back to it, but it had nowhere to put **transactional** data. That gap showed up the moment a custom app needed to do anything other than read — a booking form, an approval queue, an annotation on a metric. Those writes either went to a warehouse never built for row-level updates, or the app stayed read-only. Each org can now be provisioned its own Postgres, with one schema per writer:

* **`ctx.oltp` in Oxy Functions** - A custom app's server-side functions get a transactional connection scoped to that app's own schema and nothing else. A worked example ships as `oltp-bookings` in the custom-app examples.
* **One schema per writer, isolated by construction** - Every writer — a custom app or an Airway pipeline — owns exactly one schema and one role, and cannot see another writer's schema unless it is made visible. The default differs by kind on purpose: a pipeline's `raw_*` schema holds data that exists in order to be analyzed, so the analytics agent reads it by default; an app's `app_*` schema holds the app's own state and stays private.
* **A real landing zone for Airway** - A pipeline can land raw extracts into `raw_<source>` on a genuine Postgres instead of a columnar store, which is what a raw landing zone actually wants.
* **Admin console at `/admin/oltp`** - The fleet at a glance, one row per org, with a per-org panel to provision, mint credentials, set a writer's visibility, or deprovision. Every action lands in the audit log.
* **CLI and self-discovery** - `oxy oltp provision | apply | status | connect | rotate | deprovision`, plus `GET /oltp/me/connection` and `GET /oltp/me/erd`, so an app can find its own schema without an operator handing over a connection string.

<Warning>
  **App and function names are now validated wherever they can be created.** A custom app's
  slug becomes a schema and role name, so it must be 1–63 lowercase letters, digits, and
  single hyphens — underscores are rejected outright, because `my-app` and `my_app` would
  otherwise map onto the same writer's schema. Function names follow the same rule and are
  now enforced by the server at publish time, not only by the CLI. An audit of every
  deployed app found no slug that violates the rule, so nothing already published is
  affected.
</Warning>

#### Fleet-Wide Pre-Aggregation, and a Panel to Watch It

A pre-aggregated rollup is a local Parquet summary the semantic layer answers from instead of the warehouse. On a real cloud deployment they were being declared and never served — for four independent reasons, all silent, all indistinguishable from "nothing is cached." Rollups now work across the fleet, and the Semantic Layer in Oxygen Factory has a tab for them:

* **A Pre-aggregation tab** - One row per **declared** rollup: status, dimensions, measures, time dimension and granularity, refresh key, and build time. Three states, because "the fleet has built this" and "this instance holds the file" are different questions — **Cached**, **Built elsewhere**, and **Not built**. Not cached is a status, not an error.
* **Rebuild on demand** - Per row, or all at once from the toolbar. Rebuild forces the build: someone pressing it is saying the refresh key isn't the authority right now.
* **A rollup is readable wherever it was built** - Parquet is read in place from blob storage, with projections and filters pushed down, so a rollup built on one instance serves queries and status reads on every other one.
* **Refresh runs per workspace, on that workspace's own cadence** - Opt in with a `pre_aggregations:` block in `config.yml`; the refresh is durable, so it survives a restart. Previously a single loop ran against the server's own working directory and quietly skipped every rollup in every tenant.
* **The badge can't lie** - Every read falls back to the warehouse when a rollup won't read, and the **Pre-aggregated** badge follows the tier that actually answered. That fallback used to surface as a 500 in the IDE, a tool error in the builder, and an analytics run asking the model to repair SQL over a missing file.
* **The panel lists what was declared, not what was built** - A declared-but-unbuilt rollup, an evicted Parquet, or a run of failing builds used to vanish from the list rather than showing as uncached; a stale entry from a deleted `pre_aggregations:` block used to linger.

#### Schedule Variables

Schedules can now carry variables from the UI:

* **A Variables field in the schedule dialog** - A JSON object, validated on save, folded over the target's declared `variables:` defaults. The backend has always accepted these — only the form lacked an input, so the API was the only way to set them.
* **The form can no longer erase state it can't show** - Editing a monitor scan's schedule, to fix a typo in its name say, silently dropped the granularity stored alongside it, and every later fire failed. Variables now round-trip on every save for every target kind, whether or not there's an editor for them.
* **Static by design** - A schedule stores one value and replays it on each fire, so a relative variable such as `lookback_days` gives a rolling window where fixed dates would rebuild the same frozen one forever.

#### Uploadable Sources Derive Their Own Landing Zone

An Airway source that receives uploaded files had to hand-write the full `base_path` for its landing zone — bucket, workspace, source kind, and slug. Every segment is already known to the server, none of it is the operator's to decide, and a wrong value isn't visibly wrong: uploads just land where no run looks.

* **Omit it and the server fills it in** - `config: {}` is now enough. The zone is derived from the same helper the upload endpoint uses to decide where it writes, so the path a file is written to and the path a run reads from cannot drift apart.
* **A declared path still works and is never redirected** - Useful for a zone nothing derives, like a pre-existing bucket. It is now normalized identically on both sides, so a stray trailing space no longer passes the upload check and then reaches the connector as a different string.
* **Mismatches are still refused by name** - A declared zone that disagrees with where uploads are written is rejected with both values shown.

```yaml theme={null}
source:
  kind: ubereats
  config: {} # landing zone derived by the server
```

***

### Platform Improvements

#### Analytics Agent

* **No more confident answers from the wrong rows** - The agent was answering "no sales last week" for workspaces that had sales last week — not erroring, but answering, with a chart and a summary. Three different spellings of a date filter caused it: a range whose two endpoints compiled to `IN (start, end)` (two days presented as a week), a single-valued range filter (one day presented as a week), and six per-day equality filters ANDed together (a predicate no row can satisfy, presented as "no sales"). One validator now gates both paths that can produce a query, and an unrecognized operator is rejected rather than silently substituted. A wrong number with a chart under it is worse than an error, because an error gets retried.
* **A refined proposal supersedes the one before it** - A model that proposed something valid and then refined it into something invalid used to leave the earlier proposal in place, so the run answered a question that had already been replaced — carrying the first proposal's confidence.
* **Invalid proposals are visible** - A rejection now renders as its own step in the transcript and routes as a retryable compile error, instead of terminating the run or stamping its internal prose onto an unrelated step.

#### Warehouses & CLI

* **Postgres connections can insist on TLS** - The Postgres connector opened every connection without TLS, so a resolved `sslmode` had nowhere to land. Managed Postgres providers (Neon, Supabase, RDS with forced SSL) refuse a plaintext session, which meant a `postgres` connector could only ever reach a local cluster. `sslmode` is now honored, following libpq's own semantics — `require` encrypts, `verify-ca` / `verify-full` also validate the chain. An author-configured `type: postgres` database stays at `prefer`.
* **`oxy airway run` exits non-zero on failure** - It printed an error mark and returned success, so CI, cron wrappers, and `set -e` all read a failed pipeline as a passing one.
* **`--workspace-id` is honored for the run, not just the build** - The run started under a nil workspace, so it couldn't see a concurrent run on the real workspace — bypassing the one-run-per-pipeline guarantee — and landed in history and Workspace Health under the wrong tenant.
* **Managed Postgres connections now verify the server, not just encrypt to it** - `require` means "don't fall back to plaintext," not "check who you're talking to," so a managed tenant's credentials and every row read crossed the public internet open to anyone presenting a certificate. Managed providers now validate the chain against a trusted root store; local connections negotiate no TLS at all.
* **`oxy api` describes the whole HTTP surface** - `oxy api --help` prints the full endpoint table, and `--routes [FILTER]` / `--json` narrow it, so the API can be discovered from the binary alone — no running server, credentials, or spec file. The catalog is generated from the router itself, so it can't drift out of date.
* **`oxy apps list` returns every app** - It was returning a single row instead of paging the whole registry.
* **The dev server binds the loopback address only** - It bound every interface, which put a machine holding real cloud credentials on whatever network it happened to be joined to. `OXY_DEV_HOST=true` opts back in.

#### Semantic Layer

* **No more confident answers from the wrong warehouse** - A view naming a `datasource:` whose connector had failed to register was answered by whichever database `config.yml` listed first — with SQL compiled for a different engine's dialect. The result was a plausible number produced by the wrong warehouse, or occasionally a baffling error from a server the view was never addressed to. A datasource that is set but unregistered is now an error that names the connected databases; only an unset one falls back to the default. The same guard covers Verified Queries (a `.sql` file whose `database:` annotation is stale or renamed), column sampling, and freshness checks — a sampled value becomes a literal in generated SQL and a watermark becomes a "data through *date*" the agent states as fact, so both are worse wrong than absent.
* **Airhouse and MotherDuck views compile for the engine they actually speak** - The semantic query API passed the raw database type through instead of its dialect, so DuckDB expressions could be compiled as ClickHouse.
* **A time dimension with neither a granularity nor a date range is refused** - It used to be a silent no-op, which turned a missing filter into an all-time total labelled as one week.
* **Two more measure types parse** - `number` and `count_distinct_approx` were accepted by the semantic engine but rejected by Oxy's own parser, so a view using either failed in the semantic API, the MCP semantic tools, and the World Model's metric tree — while the frontend rendered both. `avg` is also now accepted as a spelling of `average`.
* **The explorer's Limit control does something** - It was in the UI but was never sent with the request. The measure-type badge (`(sum)`, and so on) also renders again in the pre-aggregation views.

#### Custom Apps

* **Renaming or deleting an app is refused while its transactional writer is provisioned** - The schema and role outlive the app row, and nothing would ever reach them again. `oxy oltp deprovision --writer app:<slug>` is the deliberate way out.

#### Chat & IDE

* **Chat panel state no longer leaks across workspace switches** - SQL or text written in the chat panel in one workspace kept appearing in another after switching, because switching only changes a route parameter and the component was never remounted. The chat panel, the Ask dock, the builder dialog, and database sync status now all reset on a workspace change.
* **IDE SQL tabs are per workspace** - Query tabs and connections were global and persisted to local storage with their SQL text, so a tab opened in one workspace's IDE stayed visible and editable after switching — and survived a reload. Each workspace now keeps its own tab set, and existing open tabs are carried over rather than dropped on upgrade.

#### Model Configuration

* **Thinking settings work on current Claude models** - An agent on a recent Claude model configured with `budget_tokens` failed on every call, and `effort:` was parsed and then discarded, so it changed nothing at all. Between them there was no working way to ask a current Claude model to think less. `effort:` now takes real effect, `budget_tokens` is translated with a warning on models that no longer accept it and left untouched on those that do, and `xhigh` and `max` are reachable for the first time.
