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.
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.
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 400s; rename those events before upgrading.
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.
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.
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.
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.
source: kind: google_sheets config: spreadsheet_id: <from the sheet URL> service_account_json_var: GOOGLE_SA_JSON ranges: - "Main!A:S"resources: - main
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.
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.
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.
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.
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.
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.
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.
source: kind: ubereats config: {} # landing zone derived by the server
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.
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.
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.
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 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.
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.